Skip to content

feat(social-feed): paste-any-link ingestion via OpenGraph unfurl - #180

Merged
NewWorldVenture merged 5 commits into
mainfrom
claude/loving-mccarthy-e1ahq8
Jun 27, 2026
Merged

NewWorldVenture merged 5 commits into
mainfrom
claude/loving-mccarthy-e1ahq8

Conversation

@NewWorldVenture

Copy link
Copy Markdown
Owner

What & why

The Social Feed (#176, #179 — merged) had a complete store + UI, but no ungated way to get content into it — live per-platform OAuth ingestion needs API keys per network. This PR ships the real, working ingestion path: paste any link (a video, post, or article) and it becomes a real feed item. 100% Supabase-wired, no third-party keys required.

Changes

  • lib/social/unfurl.ts (pure, 18 tests):
    • detectPlatform — host → platform (YouTube/IG/TikTok/X/FB/LinkedIn/Reddit/Pinterest/WhatsApp), unknown → web.
    • isSafePublicUrl — SSRF guard: http(s) only, blocks localhost, private/link-local ranges, bare hostnames, .local/.internal.
    • normalizeUrl — drops the hash + tracking params (utm_*, fbclid, igshid, …) → a canonical link that doubles as a stable idempotency key.
    • parseMeta — OpenGraph / Twitter-card / <title> extraction, attribute-order-independent and resilient to messy markup; decodeEntities, resolveImage, inferKind, formatDuration.
    • buildItemFromHtml(url, html) → a complete UnfurlDraft (platform, author, content, image/thumbnail, kind, duration, permalink, externalId).
  • addByUrlAction(url) (social-feed/actions.ts) — validates the URL (SSRF-safe), server-fetches the HTML (real UA, 12s timeout, 600 KB cap, html content-type check), unfurls, and inserts into social_reader_items with external_id = canonical url. Idempotent: re-adding the same link is a no-op.
  • social-feed-module.tsx — a prominent "Paste any link…" bar above the feed tabs (Enter or Add → unfurl → it appears). Opening a post now marks-read + refreshes so the unread ring clears; the empty state nudges paste-a-link. Generic links render a calm "Web" badge (WEB_META / platformMeta('web') in lib/social/feed.ts).

ℹ️ LIVE auto-pull per platform stays integration-gated (OAuth keys); a future worker calls the same idempotent insert path. URL-unfurl is the shipping ingestion today.

⚠️ Migration 0101_social_feed.sql still needs to be applied to Supabase prod for the social_reader_* tables to exist — until then the page renders an empty state.

Verification

tsc clean · eslint clean · full suite 1288/1288 (18 new) · npm run build ✓ (/dashboard/social-feed, 8.9 kB).

🤖 Generated with Claude Code


Generated by Claude Code

claude added 5 commits June 22, 2026 20:53
Admin could author marketing_forms but nothing served them publicly.
Mirrors the #54 landing-page wiring (service-role read/write, middleware
PUBLIC, best-effort form_submitted automation). No migration — the
marketing_forms / marketing_form_submissions tables already exist and
service-role writes bypass RLS.

- Public route app/(marketing)/f/[id]/page.tsx serves active, non-deleted
  forms with >=1 field; client form-renderer.tsx posts to the endpoint.
- POST /api/forms/submit validates server-side, inserts a submission via
  the service role, and fires fireAutomationEvent('form_submitted', ...)
  deduped per submission. Rate-limited 10/min/IP.
- Pure helpers in lib/marketing/forms.ts (field-type inference for legacy
  fields, validation, email/name extraction) + 10 vitest tests.
- Admin forms page: Active/Archived pill, "View public form" link, and an
  Activate/Archive toggle (setFormStatus). Added /f + /api/forms to
  middleware PUBLIC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJBpzPzPrBbMzYFSVCQaTM
…y-e1ahq8

# Conflicts:
#	docs/AGENT_HANDOFF.md
- Surface Social Feed in the Home "Quick Access" grid: add `social_feed`
  to the dashboard feature registry (free tier, rss icon) and to all
  default layouts (free/basic/plus) so the tile shows by default and is
  addable in Customize. Nav + feature-catalog entries already shipped (#176).
- Super-admins are now fully unlocked on the Home dashboard: force the
  dashboard tier to 'plus' when isSuperAdmin(), so every Quick Access tile
  is available, nothing is locked, and the "Unlock more" block disappears.
  Mirrors the nav, which already unlocks super-admins.
- Daniel.Hughen@gmail.com is already on the built-in super-admin allowlist.

tsc clean · eslint clean · 1261 tests pass · build ✓

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJBpzPzPrBbMzYFSVCQaTM
The Social Feed had no ungated way to get content in (live per-platform
OAuth ingestion needs API keys). Add a real, shipping ingestion path:
paste any link — a video, post, or article — and it becomes a feed item.

- lib/social/unfurl.ts (PURE, 18 tests): detectPlatform (host → platform,
  unknown → 'web'), isSafePublicUrl (SSRF guard), normalizeUrl (canonical +
  stable idempotency key), parseMeta (OpenGraph/Twitter-card/<title>),
  inferKind, resolveImage, decodeEntities, buildItemFromHtml.
- addByUrlAction(url): SSRF-safe server fetch (real UA, 12s timeout, 600KB
  cap, html content-type check) → unfurl → insert into social_reader_items
  with external_id = canonical url (idempotent; re-adding a link is a no-op).
- social-feed-module: prominent "Paste any link…" bar above the tabs;
  opening a post marks-read + refreshes; empty state nudges paste-a-link.
  Generic links render a calm "Web" badge (WEB_META in lib/social/feed.ts).

100% Supabase-wired, no third-party keys required.
tsc clean · eslint clean · 1279 tests pass (18 new) · build ✓

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJBpzPzPrBbMzYFSVCQaTM
@vercel

vercel Bot commented Jun 27, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
familyos Ready Ready Preview, Comment Jun 27, 2026 10:56pm

Request Review

@NewWorldVenture
NewWorldVenture marked this pull request as ready for review June 27, 2026 22:55
@NewWorldVenture
NewWorldVenture merged commit cc41716 into main Jun 27, 2026
4 checks passed
NewWorldVenture pushed a commit that referenced this pull request Sep 13, 2026
Found by crawling the internal links on the live site. The homepage and /pricing
both render a section headed "Family stories", introducing what follows as
quotes "In the family's own words". What followed were three database seed rows:

  Seed Case Studies #450   Seed Summary value 450   Seed Result Metric value 450
  Seed Case Studies #180   Seed Summary value 180   Seed Result Metric value 180
  Seed Case Studies #90    Seed Summary value 90    Seed Result Metric value 90

Each linked to a detail page answering 200 with <h1>Seed Case Studies #450</h1>
at /customers/seed-case_studies-450.

Same class as the sitemap defect on this branch — seeded rows reaching a public
surface — but worse in kind. That one published dead links; this publishes
invented customers under a heading that states they are real families.

is_published could never have caught it: the seeder sets it true, which is what
made them public. The rows are identifiable only by the seeder's slug
convention, `seed-<table>-<n>`.

isSyntheticSeedSlug() therefore joins publishedOnly() in the shared reputation
helpers, so both pages' list reader excludes them, and loadPublishedCaseStudy()
refuses one BEFORE touching the database — a link surviving in a cache or a
search index must not still render one. The list and the detail page agreeing is
the entire point: the sitemap defect existed precisely because two readers of
the same content did not agree.

Deliberately narrow, and both halves pinned:

  - testimonials have no `slug` column, so they pass through untouched. The
    predicate takes null/undefined and answers false.
  - the marker must be at the START of the slug, so a genuine story called
    `seeds-of-change` or `seed-starting-with-kids` is not swept up.

Verified non-vacuous: without the filter, `keeps seeded rows out of the public
list` and `404s a seeded story on its detail page without querying` both fail.
The existing marketing-reputation and customer-story suites pass unchanged.

The rows themselves remain in the database. The site no longer renders them,
which is the visible outcome, but deleting them is an admin action this session
has no credentials for; finalaudit.md records that.

tsc clean, lint 0 errors, 1149 files / 13098 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4
NewWorldVenture added a commit that referenced this pull request Sep 13, 2026
* Stop the sitemap advertising 435 URLs that 404, and name every gated surface in robots

An audit of the live site found the sitemap carrying 1,508 URLs of which 435 —
29% — answered 404. All of them were `/blog/Seed <uuid>`, a slug shape that also
carries a literal space and so is not a valid <loc> either.

Sampling was unambiguous: 8 of 8 seed URLs returned 404, 8 of 8 real ones 200.

The cause is two halves of one system disagreeing about what exists. Blog
entries come from getAllPosts(), which filters through publicRows ->
isSyntheticBlogSeedSlug; that filter is correct and catches 435 of the 435 live
slugs. But the sitemap ALSO emits rows from `marketing_pages`, which stores a
path directly and had no seed filter of any kind, so any published row went out
verbatim. The renderer meanwhile refuses them outright — getPost() opens with
`if (isSyntheticBlogSeedSlug(slug)) return undefined` and the page calls
notFound(). So one table advertised what the other would not render.

The file's own comment already stated the rule it was breaking: "a sitemap must
never point at a 404", written for the benchmarks entry directly above.

isAdvertisablePlatformPath() now applies the SAME predicate the renderer uses,
so the two cannot drift apart again, and rejects a non-rooted path as well.
canonicalSitemapUrl() collapses a stored root of `/` onto the static spelling
`''` — the dedupe keys on the URL string, so the homepage was published twice,
as `…com` and `…com/`.

WHY THIS SHIPPED UNDER A GREEN TEST. tests/marketing-sitemap-closed-loop.test.ts
— the file named for this exact property — never ran the sitemap. All three
cases read app/sitemap.ts as TEXT and asserted substrings appeared in it. A grep
cannot observe what a function emits. Demonstrated rather than argued: against
the unfixed code all three source cases still pass while the new behavioural
ones fail. They are kept (they do pin real wiring) and five cases added that
execute sitemap() against a stubbed database and assert on the URLs returned.
The stub is a Proxy rather than a fixed method list, so adding a .limit() or
.range() to either query cannot quietly turn the suite into a false pass.

Separately, robots.ts carried the comment "Authenticated app surfaces should not
be indexed" above a list of four. The authenticated route group holds 22
segments, and production confirmed 20 gated surfaces missing from that list —
/admin and its 80 pages among them — each answering 307 to /login.

That is NOT a data leak and is not claimed as one: every one of them redirects
while signed out. What was wrong is that the stated policy and the
implementation disagreed, crawl budget was spent discovering them, and a future
route answering 200 anonymously would have inherited no protection at all. A new
test derives the segment list from app/(app) on disk so it cannot fall behind,
and asserts the public marketing routes stay allowed — /family-display is public
while /family and /display are not, so a prefix rule would have taken it down
with them.

Also: page titles doubled the brand. Live: `Security & Privacy — Bubaly · Bubaly`
and `Contact Bubaly · Bubaly`. The titles come from the admin SEO store, not the
catalogue (0 catalogue strings end that way), so correcting the rows would only
last until someone typed another one. The layout template owns the brand, so a
self-branded title now opts out of it via Next's `absolute`. Only a TRAILING
brand counts — "Bubaly — The AI Family Operating System" still wants the suffix —
and the separator boundary keeps "Meet the Bubalys" untouched.

finalaudit.md records all of this, including what was checked and found healthy,
one finding I withdrew after re-checking (a missing-metadata reading that was my
own extraction bug), and the measured i18n payload problem left open for a
decision rather than fixed blind.

tsc clean, lint 0 errors, i18n gate clean, 1148 files / 13091 tests, build
exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4

* Stop presenting seeded rows as real customer stories

Found by crawling the internal links on the live site. The homepage and /pricing
both render a section headed "Family stories", introducing what follows as
quotes "In the family's own words". What followed were three database seed rows:

  Seed Case Studies #450   Seed Summary value 450   Seed Result Metric value 450
  Seed Case Studies #180   Seed Summary value 180   Seed Result Metric value 180
  Seed Case Studies #90    Seed Summary value 90    Seed Result Metric value 90

Each linked to a detail page answering 200 with <h1>Seed Case Studies #450</h1>
at /customers/seed-case_studies-450.

Same class as the sitemap defect on this branch — seeded rows reaching a public
surface — but worse in kind. That one published dead links; this publishes
invented customers under a heading that states they are real families.

is_published could never have caught it: the seeder sets it true, which is what
made them public. The rows are identifiable only by the seeder's slug
convention, `seed-<table>-<n>`.

isSyntheticSeedSlug() therefore joins publishedOnly() in the shared reputation
helpers, so both pages' list reader excludes them, and loadPublishedCaseStudy()
refuses one BEFORE touching the database — a link surviving in a cache or a
search index must not still render one. The list and the detail page agreeing is
the entire point: the sitemap defect existed precisely because two readers of
the same content did not agree.

Deliberately narrow, and both halves pinned:

  - testimonials have no `slug` column, so they pass through untouched. The
    predicate takes null/undefined and answers false.
  - the marker must be at the START of the slug, so a genuine story called
    `seeds-of-change` or `seed-starting-with-kids` is not swept up.

Verified non-vacuous: without the filter, `keeps seeded rows out of the public
list` and `404s a seeded story on its detail page without querying` both fail.
The existing marketing-reputation and customer-story suites pass unchanged.

The rows themselves remain in the database. The site no longer renders them,
which is the visible outcome, but deleting them is an admin action this session
has no credentials for; finalaudit.md records that.

tsc clean, lint 0 errors, 1149 files / 13098 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4

* Give five public pages the h1 they never had

Verified against production: /faq, /contact, /mobile and /family-display each
returned ZERO <h1> elements and began their document outline at <h2>.

All four used SectionHeading for their PAGE title, and that component hard-coded
h2. Pages with a hand-rolled hero (/, /pricing, /features) render exactly one h1
and were fine — which is why this only ever hit the pages that reused the shared
component. A missing h1 costs screen-reader users their primary way of orienting
on a page, and drops the strongest on-page relevance signal for search.

SectionHeading now takes an optional `as="h1"`, defaulting to h2 so every
existing section heading is untouched. The class list is identical at either
level and a test asserts that: this is a document-outline change and those pages
must look exactly as they did.

THE FIFTH PAGE. The structural test caught /resources/benchmarks, which probing
the live site never could — it answers 404 while its admin publication flag is
off. Same defect, same fix, found only because the check reads the tree rather
than the deployment.

A VACUOUS TEST, CAUGHT. The first version of the page sweep passed with the fix
reverted. The JSDoc I had just written on SectionHeading contains the literal
`as="h1"` as an instruction to callers, so every page importing that module
matched on the COMMENT rather than on its own markup. Found by reverting /faq
and watching the test stay green — the same failure as the sitemap closed-loop
test earlier on this branch, which is sharp enough twice to be worth writing
down. The sweep strips comments before scanning now, and reverting /faq
correctly fails and names it.

The sweep also resolves each page's local imports one level deep. Without that
it reported /pricing, /privacy, /terms and others as missing an h1 when their
hero simply lives in a sibling component — all three were confirmed live to
render exactly one.

tsc clean, lint 0 errors, i18n gate clean, 1150 files / 13102 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4

* Close the audit: finalaudit.md records every finding and its owner

Thirteen findings. Seven fixed and shipped on this branch, one disproved and
withdrawn, five closed as decisions or assigned to someone with access this
session does not have. Every remaining item has an owner and a next step.

The substantial work in this commit is the investigation behind F9 — the whole
848 KB catalogue serialized into every page — which is now closed as a decision
rather than left as an open question.

Measured what the public site actually needs, as a transitive import closure
per route group:

  marketing    81 client files    324 keys    21 KB   2.6% of the catalogue
  auth         62 client files     89 keys     5 KB   0.6%
  signed-in  1122 client files   8282 keys   442 KB  55.7%

So the marketing site ships 793 KB to deliver 21 KB of value.

Then established that pruning marketing and auth is SAFE rather than assuming
it: 9 dynamic `t(variable)` call sites across both groups, every one reading
from a static module inside the closure — MARKETING_NAV, VALUE_ROWS, the consent
categories, the local LINKS map in legal-consent — none from the database. 36 of
36 keys reachable through them are in the computed set; zero missing.

And disproved the tempting shortcut. A single global client-only subset needs no
path detection and saves 40%, and it is WRONG: 123 catalogue keys are defined as
literals in server components and handed to client components as props —
admins.tab.users, supportTickets.tab.open, runHistory.filterActive,
displaySetup.stepInstallTitle and 119 more. Every one would have rendered as a
raw key in front of a person. Found by scanning for it rather than by shipping
it.

What is left is the delivery mechanism, and all of it turns on verification I
cannot do here. Scoping by request path needs a header from middleware, whose
own comment documents that mishandling its request/response pair makes Supabase
treat a reused refresh token as stolen and revoke the entire session family.
Per-group root layouts would be clean but mean deleting app/layout.tsx and
moving eight ungrouped routes. Both risk surfaces are client-side hydration, and
no browser runs in this environment — Chromium dies in the proxy relay for every
host including google.com, which the document states plainly so nothing in it is
over-read.

The scaffolding written while proving this out — a scope generator, a scope
module and a generated key manifest — is deliberately NOT committed. It is not
wired to anything, and unused machinery in a repo is a claim that something is
handled when it is not.

Two process notes are recorded rather than quietly corrected: a finding I
withdrew after re-checking (a missing-metadata reading that was my own
extraction bug), and a vacuous test I caught in the fix for F11 — it passed with
the fix reverted because the JSDoc under test contains the literal it grepped
for, which is the same failure as F4.

tsc clean, 1150 files / 13102 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4

* Record the sitemap finding this audit's method could not see

A parallel session raised it on #526; verified independently before recording:

    GET /blog?category=Parenting   -> 200
         <link rel="canonical" href="https://www.bubaly.com/blog"/>

app/sitemap.ts emits one entry per blog category, nine of them. Each answers 200
and then names /blog as its canonical, so the sitemap asks Google to index URLs
the pages themselves declare are not the canonical version. The entries are
dropped and the crawl budget is spent anyway.

Same family as F1 — the sitemap claiming something the site does not support —
and squarely in this audit's scope. I missed it because I checked whether every
sitemap URL RESOLVED, which they do, and never checked whether it was
INDEXABLE. That is a gap in the method, not only in the sitemap, so it is
written down as one.

Not fixed here on purpose. #526 rewrites the same file with a broader
canonicalUrl() — trailing-slash normalisation, per-segment percent-encoding, and
refusal of query strings, fragments, non-relative paths and anything robots.txt
disallows, reading that disallow list from robots.ts so the two cannot drift.
That is a better fix than a second patch of mine, and two PRs rewriting
app/sitemap.ts in different directions helps nobody.

The overlap is stated plainly in the document rather than left for whoever
merges second to discover: #526 also found F1 and F3 independently. Its URL
hygiene is broader; the one property worth carrying across is that
isAdvertisablePlatformPath() applies the SAME predicate the renderer uses, so
the sitemap and the blog page cannot drift apart again — which is the root cause
F1 actually had. Nothing else on this branch touches #526's files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4

* Sharpen two findings on re-examination, and close the dependency question

F7 was understated. I had recorded "a Free family is given an address it cannot
see". Tracing the whole path found the gate exists ONLY on the screen:

  Contact Center screen     requirePlanLevel(2)  — Family+
  provisioning at onboarding   none — every tier
  inbound webhook              none
  lib/contact-center/*         none

The webhook resolves the family from the recipient local-part and proceeds. So a
Free family has a working @bubaly.com address that receives mail, files it, runs
the AI concierge over it and auto-replies AS the family, while the product
surface says Family+. The only thing they cannot do is look at the inbox.

That cuts both ways, which is why it stays a decision rather than getting a
default: either Free families are consuming AI inference and sending mail under
the product's domain ungated, or the screen's requirePlanLevel(2) is the thing
that is wrong. Gating the route would also start dropping mail for any Free
address already in circulation, so the ordering matters and is written down.

Dependency security is closed rather than open, and checked rather than assumed.
npm audit reports 8 advisories; every package was resolved against the
production tree and NONE of them appear in it — js-yaml, tar, brace-expansion,
browserslist, @xmldom/xmldom, vitest, @vitest/mocker, baseline-browser-mapping
are all build and test tooling. `npm audit --omit=dev --audit-level=moderate`,
the exact command CI runs, reports 0. There is no site finding here, so it is
recorded as closed with the evidence instead of sitting on a list.

F9 gains a third mechanism that is better than both I had, and removes the
objection I raised against them. A nested provider needs no middleware change
and no app-router restructure: the root layout keeps LocaleProvider with only
the keys routes outside app/(app) can reach, and app/(app)/layout.tsx — which
already exists — renders a nested one carrying the full catalogue, whose context
wins for signed-in pages.

Measured: the root set is 702 keys, 44 KB of 793 KB. Public pages drop ~94%;
signed-in pages carry 44 KB extra, behind a login. Completeness leaves exactly
three uncovered keys, all marketingDisplay.* in family-display/page.tsx, defined
in the server page and passed to a client component — the same pattern that
makes a global subset unsafe, but three keys in one file rather than 123 across
the admin console.

Still not shipped, and the reason is unchanged and specific: the blast radius is
every public page and this environment cannot render one. What is different is
that the remaining risk is now "is a key missing", which a regeneration test
settles exhaustively without a browser — so whoever picks it up has a mechanism,
a number, and three named gaps rather than a research task.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4

* Put the audit document in reading order

F14 was appended after "Verified healthy" because it was recorded last, which
put a finding after the section that says what had no findings. It now sits with
F1-F13 where a reader looks for it.

Three headings disagreed with their own status rows after the items were
reassigned this round — F5 and F6 to operator ownership, F12 to closed. The
heading and the summary table now say the same thing about each.

No content changed; this is the deliverable and it should read as one document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4

---------

Co-authored-by: Claude <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — f84d548a Deployed Jun 27, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants