Skip to main content

Per-deep-research Open Graph preview cards

Status: Shipped Sep 2026 (staging) Companion doc: PER-RADAR-OG-CARDS.md — same architecture applied to the /radar surface

What this changes

Before this change, every deep-research (DR) completion URL Silvia sent over Linq / SMS unfurled with the site-wide generic Silvia OG card — same image, same title (“Research — In-depth research reports…”), same description for every completion. iMessage bubbles for DR completions felt beige and utility-grade; the user couldn’t distinguish “your Nvidia stress test is done” from any other Silvia link until they tapped through. After this change, every DR completion URL (bubble 2 in app/api/sms/research-complete/route.ts — the bare https://www.cfosilvia.com/research?session=<id>&utm_source=sms URL) renders with a DR-specific preview card:
  • Title = the user’s DR question, sanitized + truncated to ≤80 chars (e.g. “Stress test my portfolio against a 2008-style crash.”)
  • Description = a data-dense meta line — duration + source count (e.g. “27 min · 199 sources analyzed — tap for the full analysis.”)
  • Image = a 1200×630 PNG rendered on-demand at /api/og/research/[sessionId] showing the Silvia mark, a DEEP RESEARCH pill, the sanitized question, the meta line, the completion date, and a View full report → CTA.
Third-party clients that request the URL (iMessage, Slack, Discord, Facebook, Twitter/X, LinkedIn) all render the per-DR preview from these tags automatically — Silvia does not send them separately.

Architecture

Files

Failure modes (fail-closed at every layer)

Every non-happy branch redirects to the site-wide generic Silvia OG card (/opengraph-image) — same visual experience as before this change: Correctness contract: the URL bubble is never worse than today. Broken data is a preview downgrade, never a broken image, and always a single card (never a stacked double-render).

Why the route redirects instead of 404-ing on failure

The radar OG’s first ship (Sep 12) 404’d on every failure branch and listed /opengraph-image as openGraph.images[1] for defence against that 404. Real-world iMessage testing showed iMessage’s Link Presentation framework STACKS every og:image entry into a separate preview card, so the fallback rendered as a SECOND card every time — a double-card unfurl instead of a fallback. That fix was landed on the radar OG and this DR OG ships with the corrected posture from day one: openGraph.images carries exactly one entry, the route redirects 302 to /opengraph-image on every failure branch. Result: iMessage renders exactly one card, always.

Security / safe-subset policy

The OG endpoint is unauth-callable (iMessage’s unfurler is anonymous), so anyone who knows / guesses a session_id can attempt to mint a Silvia-branded card for that DR. DR content is materially more sensitive than radar content — DR questions can contain SSN benefits, dates of birth, dollar-figure planning inputs, spouse names, and other financial PII (verified against the prod corpus, Sept 2026). This surface is therefore stricter than its radar sibling in two dimensions:
  1. Field-level whitelist. Only session_id, question, duration_seconds, total_tool_calls, and completed_at are ever fetched (see lib/research/og-fetch.ts). NO s3_key (path to the full PDF), NO clerk_id, NO error messages, NO token counts, NO ECS internals, NO legacy_response.
  2. Strict delivery gate. The row must satisfy status = 'completed' AND one of:
    • Primary path — dr_sms_notifications.sent_at IS NOT NULL for the same session_id. Proven delivery of the DR completion SMS to a user-visible channel (bubble 1 in /api/sms/research-complete, stamped just before bubble 2’s URL fires — see that route’s block comment for the exact ordering).
    • Grace path — completed_at within the last 2 hours. Covers the tiny happy-path race between status = 'completed' being written by the DR backend and our completion route stamping sent_at. In practice sub-second, but the 2-hour bound matches radar-OG for consistency (see lib/radar/ og-fetch.ts — the OG_DELIVERY_GATE_GRACE_MS constant is re-exported from that module so both surfaces stay in lockstep).
    Why session_ids need this gate. Session IDs in the current corpus (19,145 completed rows, Sept 2026) are dominated by 8-character lowercase hex (~17k+ rows) — a 32-bit space. Enumeration at 1000 req/s would find one real hit every ~4 min against the current row count. That’s why we require delivery proof or a recent completion; a bare “completed status” gate would give an enumeration attacker persistent card-render access to every historical DR — with all the sensitive question text those cards carry.
  3. Shape validation. session_id must match /^[A-Za-z0-9_-]{1,64}$/ before touching the DB. Same shape used by SESSION_ID_FORMAT in app/api/sms/research-complete/ route.ts (line 146) and app/api/email/scenario-complete/ route.ts (line 81). Rejects path traversal, HTML injection, SQL-ish payloads, and DoS-via-long-string.
  4. Sanitizer share-through. The question is run through sanitizeRadarSummaryForEmail before rendering into the image or the OG tag, so the same class of LLM-adjacent leaks the sanitizer catches on radar summaries (markdown, code fences, leaked XML tool-invocation blocks) is caught here too.
  5. Aggressive truncation. Title cap at 80 chars, description cap at 200 chars. For a long-form DR question with multi-paragraph planning inputs (verified against a prod row containing SSN dollar figures + DOBs), the truncation strips the sensitive tail — the title only ever carries the first 79 chars of the question, which is typically the high-level framing (“Give me a road map on how to…”) rather than the PII-bearing input parameters.
  6. Fail-closed redirects. Every failure branch returns a same-origin 302 redirect to /opengraph-image with Cache-Control: no-store, so a transient DB blip cannot poison the CDN with a bad response for a legitimate DR. The no-store on the redirect means the CDN forwards each request through to the origin (which will resolve as soon as the underlying issue clears) and never pins a fallback-for-a-live-DR.

What DOES NOT get an OG card (deliberate)

  • Webapp-triggered DRs (no SMS handoff row) that completed more than 2 hours ago. If a user pastes the URL into Slack / email later, they’ll see the generic Silvia card. Rationale: no delivery proof → the card would be enumeration bait for arbitrary historical DRs. If real user demand appears for webapp-share cards, we ship a v2 with a different gate (e.g. the DR owner’s own opt-in click).
  • Running / failed / cancelled DRs. A preview card advertising a DR that then 404s (or 500s) on tap-through would be worse UX than the generic card.

Cache

Happy-path responses carry:
  • s-maxage=600 — CDN caches the image for 10 minutes.
  • max-age=300 — browsers / third-party unfurl caches keep it for 5 min.
  • No stale-while-revalidate — a deleted DR’s card is purged from the CDN within 10 min, and revalidation on the 11th minute hits the origin’s 302-redirect branch (which itself carries Cache-Control: no-store, so the deleted-DR-specific image cannot be re-pinned; the unfurl bot follows the redirect to /opengraph-image — the site-wide Silvia hero — instead).
Same policy as the radar OG so both surfaces share the same revocation contract. Matches the same “day-long caching would let a deleted asset’s public card linger” security concern the radar OG addressed in Sept 2026.

Testing

Unit + route tests total 78+ across three new files (grows as bot findings land regression guards):
  • lib/research/__tests__/og-fetch.test.ts — 35 tests (validator + fetch + delivery gate + grace window + payload shape)
  • app/(app)/research/__tests__/generate-research-metadata.test.ts — 29 tests (builder + duration/tool-call formatting + PII-safe truncation)
  • app/api/og/research/[sessionId]/__tests__/route.test.ts — 14 tests (route + 302 fallback + image mint + card content)
Coverage locks in every fail-closed branch, the sanitizer share-through, Unicode-safe truncation, the strict SMS-proof delivery gate, the 2-hour grace window, the raw-Postgres timestamp parsing shared with the radar OG, and the pill / branding invariants that make the card feel on-brand.

Follow-ups (deliberately deferred)

  • Webapp-share card gate. As above, webapp-triggered DRs (no SMS handoff) fall back to the generic card. A v2 could open the gate for DRs whose owner has explicitly opted-in to sharing (e.g. a “Share this report” button that also flips a share_public flag on the row). No user demand today; ships when needed.
  • Card variants. Phase 1 is a single card layout. If we ever want a data-visualisation variant (small sparkline of duration vs. token spend, source-mix pie chart, etc.), that’s a follow-up Satori composition.
  • Explicit cache invalidation. If a DR gets edited / retracted (rare — the question is one-shot), the CDN may serve the old card for up to the s-maxage window (10 min) before it revalidates against the origin. Acceptable for a preview surface; if this becomes a real complaint, add a ?v=<timestamp> query param to the OG URL at build time.
  • Metrics. OG image requests aren’t attributed to a specific outbound channel today. If important, add a ?src=sms UTM-style query param at the openGraph.images[].url build site (lands in Vercel Edge logs, can be aggregated post-hoc).