> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cfosilvia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# PER RADAR OG CARDS

# Per-radar Open Graph preview cards

**Linear:** [CFO-8027](https://linear.app/cfosilvia/issue/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

```
Unfurler                                                    Vercel
   │                                                          │
   │ GET /radar?radar=<id>                                    │
   ├───────────────────────────────────────────────────────>  │
   │                                                          │
   │                                                    ┌─────┴──────┐
   │                                                    │ middleware │  bypasses sign-in
   │                                                    │ (unfurl-   │  redirect for known
   │                                                    │  bots      │  crawler UAs
   │                                                    │  helper)   │  (already existed)
   │                                                    └─────┬──────┘
   │                                                          │
   │                                                    ┌─────┴──────────────┐
   │                                                    │ app/(app)/radar/   │
   │                                                    │ page.tsx (server)  │
   │                                                    │  generateMetadata  │
   │                                                    └─────┬──────────────┘
   │                                                          │
   │                                                    ┌─────┴─────────────────┐
   │                                                    │ generate-radar-       │
   │                                                    │ metadata.ts           │
   │                                                    │  buildRadarPage-      │
   │                                                    │  Metadata()           │
   │                                                    └─────┬─────────────────┘
   │                                                          │
   │                                                    ┌─────┴──────────────┐
   │                                                    │ lib/radar/         │
   │                                                    │ og-fetch.ts        │
   │                                                    │  fetchRadarForOg() │  ← service-role
   │                                                    └─────┬──────────────┘    Supabase lookup
   │                                                          │                    (safe subset only)
   │  HTML with per-radar OG tags                             │
   │  <meta property="og:image" content=".../api/og/radar/X">│
   │ <────────────────────────────────────────────────────────┤
   │                                                          │
   │ GET /api/og/radar/[id]                                   │
   ├─────────────────────────────────────────────────────> ┌──┴──────────────┐
   │                                                       │ route.tsx       │
   │                                                       │ (edge runtime)  │
   │                                                       │  next/og        │
   │                                                       │  ImageResponse  │
   │                                                       └──┬──────────────┘
   │  1200×630 PNG + Cache-Control                            │
   │ <────────────────────────────────────────────────────────┤
```

## Files

| File | Role |
| - | - |
| `app/(app)/radar/page.tsx` | Server-component wrapper. Exports `generateMetadata`. Renders `<RadarPageClient />`. New. |
| `app/(app)/radar/radar-page-client.tsx` | The existing `'use client'` radar UI, unchanged. Renamed from the old `page.tsx`. |
| `app/(app)/radar/generate-radar-metadata.ts` | Builds `Metadata` from `searchParams.radar`. Sanitizes prompt + summary, truncates safely. Falls back to generic Radar metadata on every failure branch. New. |
| `app/api/og/radar/[radarId]/route.tsx` | Edge-runtime OG image endpoint. Renders 1200×630 PNG via `next/og`. Same-origin 302-redirects to `/opengraph-image` (with `Cache-Control: no-store`) on all failure branches — never 404s, so iMessage's LP framework never surfaces a broken image and the single-`og:image` posture is preserved. New. |
| `lib/radar/og-fetch.ts` | Shared safe-subset Supabase lookup + radar\_id shape validator. New. |

## 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:

| Trigger | Result |
| - | - |
| No `?radar=` query param | Generic Radar OG (layout inherits, metadata builder returns fallback) |
| Malformed radar\_id | Metadata builder returns generic; if the URL is fetched anyway, route 302s to `/opengraph-image` |
| Radar row missing | Metadata builder returns generic; route 302s to `/opengraph-image` |
| Status ≠ `COMPLETED` (PROCESSING / FAILED / SKIPPED\_TOKEN\_LIMIT) | Metadata builder returns generic; route 302s to `/opengraph-image` (delivery gate) |
| `send_alert = false` (model decided not to alert) | Metadata builder returns generic; route 302s to `/opengraph-image` (universal hard block) |
| `send_alert = NULL` on grace-window row (no SENT yet) | Generic Radar OG (grace requires explicit `true` — CR3-F1) |
| `send_alert = NULL` on delivered row (`SENT` present) | Per-radar OG (primary path — SENT proves delivery) |
| Not delivered AND completed >2h ago | Generic Radar OG (grace expired, strict SENT re-asserts) |
| `completed_at` corrupt (>10 min in the future) | Generic Radar OG (clock-skew bound) |
| Supabase env unconfigured | Generic Radar OG (route 302s to `/opengraph-image`) |
| Supabase error / timeout | Generic Radar OG (route 302s to `/opengraph-image`) |
| `/api/og/radar/[id]` fetch fails after metadata already emitted | Same 302 to `/opengraph-image` — crawler follows the redirect and lands on the site-wide Silvia card |

**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:

```
Cache-Control: public, max-age=300, s-maxage=600
```

* **`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).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.