Skip to main content

Per-radar Open Graph preview cards

Linear: CFO-8027 Status: Shipped Sep 2026 (staging → main)

What this changes

Before this change, every radar URL shared over Linq / SMS / email unfurled with the site-wide generic Silvia OG card — same image, same title (“Radar — Automated alerts…”), same description for every radar. iMessage bubbles for radar alerts felt beige and utility-grade; users could not tell what a link was about until they tapped through. After this change, every radar link renders with a radar-specific preview card:
  • Title = the radar’s prompt / name (e.g. “Daily crypto portfolio monitor”)
  • Description = the sanitized summary, truncated to ≤200 chars
  • Image = a 1200×630 PNG rendered on-demand at /api/og/radar/[radarId] showing the Silvia mark, an ALERT TRIGGERED / RADAR UPDATE pill, the title, a body snippet, the date, and a View full analysis → CTA.
Third-party clients that request the URL (iMessage, Slack, Discord, Facebook, Twitter/X, LinkedIn) all render the per-radar 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 (Sep 13 2026 UX fix)

The 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. Fixed by moving the fallback INTO the route (302 redirect on every failure branch) and shrinking openGraph.images to a single entry. Result: iMessage renders exactly one card, always. All major crawlers (iMessage LP, Facebook, X, Slack, LinkedIn) follow 302s on og:image URLs.

Security / safe-subset policy

The OG endpoint is unauth-callable (iMessage’s unfurler is anonymous), so anyone who knows / guesses a radar_id can mint a Silvia-branded card for that radar. Mitigations:
  1. Field-level whitelist. Only radar_id, prompt, summary, radar_type, trigger_met, send_alert, and created_at are ever fetched (see lib/radar/og-fetch.ts). No clerk_id, no full_analysis, no email, no dollar amounts.
  2. Race-safe delivery gate. The row must satisfy status = 'COMPLETED' AND send_alert IS NOT false AND one of:
    • email_status = 'SENT' OR sms_status = 'SENT' — primary path (proven delivery; send_alert=NULL is allowed here because SENT independently proves the source system chose to deliver), OR
    • send_alert = true AND completed_at within the last 2 hours — grace path (covers the DDB→Supabase sync race for the first iMessage unfurl; requires explicit affirmative because we can’t rely on delivery status yet).
    Split-path send_alert semantics (CR3-F1, Sept 2026): handler.py:1341 reads sendAlert via .get("sendAlert", True) — the default fires ONLY on missing key. handler.py:1359 then branches on if not send_alert: — a Python truthiness check where not None → True, so an explicit sendAlert: null in the LLM structured output causes the producer to SKIP delivery. Meanwhile dynamodb_sync/handler.py:357 writes send_alert via a bare .get("sendAlert") (no default), collapsing both “missing key” and “explicit null” into Supabase NULL. Since we can’t distinguish those two cases at the row level, we allow NULL on the primary path (SENT proves delivery regardless) but require explicit true on the grace path (no delivery proof — must mirror handler.py’s truthiness check). send_alert alone is insufficient (stores the model’s sendAlert decision, defaults to true) — over the 30-day corpus (Sept 2026), 2,250 rows have send_alert = true but were never delivered. But pure SENT-only gating is racy: the AWS processor writes those columns AFTER send_completion_sms returns, and the DDB→Supabase Streams sync propagates them asynchronously. Meanwhile Linq delivers the SMS and iMessage unfurls the URL within seconds — the unfurl races the sync. Measured on the 7-day production corpus (Sept 2026, 17,711 delivered radars):
    • p50 sync lag: 3.31 s — the median case already loses
    • p95 sync lag: 7,092 s (~2 hours)
    • p99 sync lag: 130,258 s (~36 hours)
    • 8% of delivered radars have >5 min sync lag
    Without the grace window, the median first iMessage unfurl would fail the gate → generic OG card → iMessage caches the generic card client-side and never retries, silently defeating the entire per-radar OG feature. The 2-hour grace bound matches p95 sync lag and caps URL- enumeration exposure at 2 hours per radar for the in-product- only sub-population (2^32 IDs/hour × 2 hours ≈ 8.6 B, infeasible to enumerate). After 2 hours, only the primary path (proven delivery) passes, permanently sealing the in-product-only leak. Clock-skew defense: completed_at up to 10 minutes in the future still passes (minor drift between AWS Lambda and Vercel clocks is normal); farther-future values are rejected so a single corrupt row can’t paper over the seal.
  3. Shape validation. radar_id must match /^[a-zA-Z0-9_-]{1,128}$/ before touching the DB. Rejects path traversal, HTML injection, SQL-ish payloads, and DoS-via-long-string.
  4. Sanitizer share-through. Both prompt and summary are run through sanitizeRadarSummaryForEmail before rendering into the image or the OG tag, so the ~0.28% of production radars that contain XML tool-invocation leaks (see lib/email/sanitize-radar-summary.ts docstring) do not leak into preview surfaces.
  5. 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 radar. 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-radar.

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 radar’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-radar-specific image cannot be re-pinned; the unfurl bot follows the redirect to /opengraph-image — the site-wide Silvia hero — instead).
The cap is deliberately bounded to a real revocation window (not the radar’s immutability window). Radars CAN be deleted (in-app radar delete, account-deletion cron, GDPR requests) — day-long caching would let a deleted radar’s public card linger for up to a week under the previous s-maxage=86400 + swr=604800 policy. 10 min preserves ~99% of the cache savings for viral radar links (which see hundreds of unfurls per URL per day) while bounding the revocation window to ~10 min. iMessage / Slack / etc. still cache client-side per their own heuristics, but that’s typically bounded to one unfurl per delivered message anyway.

Testing

Unit + route tests total 85+ across three new files (grows as bot findings land regression guards):
  • lib/radar/__tests__/og-fetch.test.ts — 50+ tests (validator + fetch + timestamp parser + grace window)
  • app/(app)/radar/__tests__/generate-radar-metadata.test.ts — 20+ tests (builder)
  • app/api/og/radar/[radarId]/__tests__/route.test.ts — 13+ tests (route)
Coverage locks in every fail-closed branch, the sanitizer share- through, Unicode-safe truncation on both title and description caps, the send_alert broadcast gate, the PostgreSQL vs ISO timestamp tolerance in formatDate, and the pill / branding invariants that make the card feel on-brand regardless of trigger state.

What this does NOT do (deliberately)

  • No per-radar OG image variants (sparkline / ticker icons / dark mode). Phase 1 is a single card layout that visually matches the in-app radar surface. Design polish is a follow-up.
  • No fine-grained ownership signalling on the card (no “radar for @user”). Cards are share-safe and intentionally do not identify the owner.
  • No explicit cache invalidation. If a radar gets edited / retracted (rare, since summaries are one-shot), the CDN may serve the old card for up to the s-maxage window (10 min) before it revalidates against the origin — which itself 302-redirects a deleted radar’s request to /opengraph-image (the site-wide Silvia hero). Acceptable for a preview surface; if this ever becomes a real complaint, add an X-Robots-Tag purge or version the OG URL with ?v=<timestamp>.
  • No metrics. OG image requests aren’t attributed to a specific outbound channel today. If this becomes important, add a ?src=sms UTM-style query param at the openGraph.images[].url build site (would land in Vercel Edge logs and can be aggregated post-hoc).