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, anALERT TRIGGERED/RADAR UPDATEpill, the title, a body snippet, the date, and aView full analysis →CTA.
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 aradar_id can mint a
Silvia-branded card for that radar. Mitigations:
-
Field-level whitelist. Only
radar_id,prompt,summary,radar_type,trigger_met,send_alert, andcreated_atare ever fetched (seelib/radar/og-fetch.ts). Noclerk_id, nofull_analysis, no email, no dollar amounts. -
Race-safe delivery gate. The row must satisfy
status = 'COMPLETED' AND send_alert IS NOT falseAND one of:email_status = 'SENT' OR sms_status = 'SENT'— primary path (proven delivery;send_alert=NULLis allowed here because SENT independently proves the source system chose to deliver), ORsend_alert = trueANDcompleted_atwithin 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).
send_alertsemantics (CR3-F1, Sept 2026):handler.py:1341readssendAlertvia.get("sendAlert", True)— the default fires ONLY on missing key.handler.py:1359then branches onif not send_alert:— a Python truthiness check wherenot None→True, so an explicitsendAlert: nullin the LLM structured output causes the producer to SKIP delivery. Meanwhiledynamodb_sync/handler.py:357writessend_alertvia a bare.get("sendAlert")(no default), collapsing both “missing key” and “explicit null” into SupabaseNULL. Since we can’t distinguish those two cases at the row level, we allowNULLon the primary path (SENT proves delivery regardless) but require explicittrueon the grace path (no delivery proof — must mirror handler.py’s truthiness check).send_alertalone is insufficient (stores the model’ssendAlertdecision, defaults totrue) — over the 30-day corpus (Sept 2026), 2,250 rows havesend_alert = truebut were never delivered. But pureSENT-only gating is racy: the AWS processor writes those columns AFTERsend_completion_smsreturns, 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
completed_atup 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. -
Shape validation.
radar_idmust match/^[a-zA-Z0-9_-]{1,128}$/before touching the DB. Rejects path traversal, HTML injection, SQL-ish payloads, and DoS-via-long-string. -
Sanitizer share-through. Both
promptandsummaryare run throughsanitizeRadarSummaryForEmailbefore rendering into the image or the OG tag, so the ~0.28% of production radars that contain XML tool-invocation leaks (seelib/email/sanitize-radar-summary.tsdocstring) do not leak into preview surfaces. -
Fail-closed redirects. Every failure branch returns a
same-origin 302 redirect to
/opengraph-imagewithCache-Control: no-store, so a transient DB blip cannot poison the CDN with a bad response for a legitimate radar. Theno-storeon 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 carriesCache-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).
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)
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-maxagewindow (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 anX-Robots-Tagpurge 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=smsUTM-style query param at theopenGraph.images[].urlbuild site (would land in Vercel Edge logs and can be aggregated post-hoc).