Files
worldmonitor/api/_upstash-json.js
Elie Habib d46c170012 feat(brief): hosted magazine edge route + latest-brief preview RPC (Phase 2) (#3153)
* feat(brief): hosted magazine edge route + latest-brief preview RPC (Phase 2)

Phase 2 of the WorldMonitor Brief plan (docs/plans/2026-04-17-003).
Adds the read path that every downstream delivery channel binds to.
Phase 1 shipped the renderer + envelope contract; Phase 3 (composer)
will write brief:{userId}:{issueDate} to Redis. Until then the route
returns a neutral "expired" page for every request — intentional, safe
to deploy ahead of the composer.

Files:

- server/_shared/brief-url.ts
  HMAC-SHA256 sign + verify helpers using Web Crypto (edge-compatible,
  no node:crypto). Rotation supported via optional prevSecret in
  verifyBriefToken. Constant-time comparison. Strict userId and
  YYYY-MM-DD shape validation before any crypto operation. Typed
  BriefUrlError with code enum for caller branches.

- api/brief/[userId]/[issueDate].ts  (Vercel edge)
  Auth model: the HMAC-signed ?t= token IS the credential. No Clerk
  session required — URLs are delivered over already-authenticated
  channels (push, email, dashboard panel). Flow: verify token →
  Redis GET brief:{userId}:{issueDate} → renderBriefMagazine → HTML.
  403 on bad token, 404 on Redis miss, 503 on missing secret. Never
  echoes the userId in error pages.

- api/latest-brief.ts  (Clerk JWT + PRO gated, Vercel edge)
  Returns { issueDate, dateLong, greeting, threadCount, magazineUrl }
  or { status: 'composing', issueDate } when Redis is cold. Mirrors
  the auth + entitlement pattern in api/notify.ts. magazineUrl is
  freshly signed per request against the current BRIEF_URL_SIGNING_
  SECRET so rotation takes effect immediately.

- tests/brief-url.test.mjs
  20 tests: round-trip, tampering, wrong user/date/secret, malformed
  token/inputs, rotation (accept prev secret + reject after removal),
  URL composition (trailing-slash trim, path encoding).

Quality gates: typecheck + typecheck:api clean, biome lint clean,
render tests still 30/30, new HMAC tests 20/20.

PRE-MERGE: BRIEF_URL_SIGNING_SECRET must be set in Vercel before this
deploys. The route returns 503 (not 500) with a server-side log when
the secret is missing, so a mis-configured deploy is safe to roll.

* fix(brief): address Phase 2 review — broken import + HEAD body + host reflection

Addresses todos 212–217 from the /ce-review on PR #3153.

P0 / P1 blockers:

- todo 212 (P0): renderer import path was broken — pointed at
  shared/render-brief-magazine.js which no longer exists (Phase 1
  review moved it to server/_shared/brief-render.js). Route would
  have 500'd on every successful token verification; @ts-expect-error
  silenced the error at compile time. Fixed the path and removed the
  now-unnecessary @ts-expect-error since brief-render.d.ts exists.
  New tests/brief-edge-route-smoke.test.mjs imports the handler so a
  future broken path cannot pass CI.

- todo 213 (P1): HEAD returned the full HTML body (RFC 7231
  violation). htmlResponse() now takes the request and emits null
  body when method is HEAD.

- todo 214 (P1): publicBaseUrl reflected x-forwarded-host, allowing a
  signed magazineUrl to point at a non-canonical origin (preview
  deploy, forwarded-host spoof). Now pinned to
  WORLDMONITOR_PUBLIC_BASE_URL env var, falling back to the request
  origin only in dev. This env var joins BRIEF_URL_SIGNING_SECRET on
  the pre-merge Vercel checklist.

P2 cleanups:

- todo 215: dropped dead 'invalid_token_shape' enum member from
  BriefUrlError — never thrown (verifyBriefToken returns false on
  shape failure by design).

- todo 216: consolidated EXPIRED_PAGE + FORBIDDEN_PAGE (and added
  UNAVAILABLE_PAGE) behind a single renderErrorPage(heading, body)
  helper with shared styles.

- todo 217: extracted readRawJsonFromUpstash() into api/_upstash-json.js
  so api/brief and api/latest-brief share one 3-second-timeout raw-GET
  implementation. Dodges the readJsonFromUpstash unwrap that strips
  seed envelopes (our brief envelope is flat, not seed-framed).

Also:
- brief-url.ts header now documents the rotation runbook + the
  emergency kill-switch path (rotate SECRET without setting PREV).
- distinguishable log tag for malformed-envelope vs Redis-miss
  (composer-bug grep vs expired-key grep).

Deferred (todo 218, P2): token-in-URL log leak is a design-level
issue that wants a cookie-based first-visit exchange or iat/exp in
the signed payload. Out of scope for this fix commit.

Tests: 55/55 (20 HMAC + 30 render + 5 edge-route smoke). Typecheck
clean, biome lint clean.

* fix(brief): distinguish infra failure from miss + walk tz boundary

Addresses two additional P1 review findings on PR #3153.

1. readRawJsonFromUpstash collapsed every failure mode (missing creds,
   HTTP non-2xx, timeout, JSON parse failure, genuine miss) into a
   single null return. Both edge routes then treated null as empty
   state: api/brief → 404 "expired", api/latest-brief → 200
   "composing". During any Upstash outage a user with a valid brief
   would see misleading empty-state UX.

   Helper now throws on every infrastructure/parse failure and
   returns null ONLY on a genuine miss (Upstash replied 200 with
   result: null). Callers:
     - api/brief catches → 503 UNAVAILABLE_PAGE
     - api/latest-brief catches → 503 { error: service_unavailable }

2. api/latest-brief probed today UTC only. For users ahead of or
   behind UTC, a ready brief could be invisible for 12-24h around
   the date boundary.

   Route now accepts an optional ?date=YYYY-MM-DD query param so the
   dashboard panel (or any client) can send its local date directly.
   When ?date= is absent, we walk today UTC → yesterday UTC and
   return the most recent hit; composing is only reported when both
   slots miss. Malformed ?date= returns 400.

Tests added:
- helper-level: missing creds → throws, HTTP error → throws,
  genuine miss → null
- route-level: api/brief returns 503 (not 404) on Upstash HTTP error

Final suite: 60/60 (20 HMAC + 30 render + 10 smoke). Typecheck +
lint clean.

* fix(brief): tomorrow-UTC probe + unified envelope validation

Addresses two third-round review findings on PR #3153.

1. Walk-back missed the tomorrow-UTC slot. Users at positive tz
   offsets (UTC+1..UTC+14) store today's brief under tomorrow UTC;
   the previous [today, yesterday] probe never saw them. Fixed to
   walk [tomorrow, today, yesterday] UTC — covers the full tz range
   and naturally prefers the most recently composed slot. Also
   correctly echoes the caller's ?date= in composing responses
   instead of always echoing today UTC.

2. readBriefPreview validated only dateLong + digest.greeting +
   stories (array). The renderer's assertBriefEnvelope is much
   stricter (closed-key contract, version guard, cross-field
   surfaced === stories.length, per-story 7 required fields, etc.).
   A partial envelope could be reported "ready" by the preview and
   404-expired on click. Exported assertBriefEnvelope from the
   renderer module, called it in the preview reader. An envelope
   that would render successfully is exactly an envelope that the
   preview reports as ready; any divergence is logged as a composer
   bug and surfaced as "composing".

Tests: 62/62 (20 HMAC + 30 render + 12 smoke). New cases cover the
shared-validator export and catch the "partial envelope slips past
weak preview" regression.

Typecheck + lint clean.
2026-04-18 07:28:49 +04:00

118 lines
4.1 KiB
JavaScript

import { unwrapEnvelope } from './_seed-envelope.js';
export async function readJsonFromUpstash(key, timeoutMs = 3_000) {
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (!url || !token) return null;
const resp = await fetch(`${url}/get/${encodeURIComponent(key)}`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(timeoutMs),
});
if (!resp.ok) return null;
const data = await resp.json();
if (!data.result) return null;
try {
// Envelope-aware: contract-mode canonical keys are stored as {_seed, data}.
// MCP tool outputs and RPC consumers must see the bare payload only.
// unwrapEnvelope is a no-op on legacy bare-shape values and on seed-meta
// keys (which remain top-level {fetchedAt, recordCount, ...}).
return unwrapEnvelope(JSON.parse(data.result)).data;
} catch {
return null;
}
}
/**
* Raw GET on a Redis key. Returns the parsed JSON value (or bare
* string for non-JSON) without applying seed-envelope unwrap. Use
* this for caches whose stored shape is NOT `{_seed, data}` — e.g.
* the per-user brief envelope `{version, issuedAt, data}` whose
* outer frame must reach the consumer.
*
* Semantics:
* - Returns the parsed value on a hit.
* - Returns `null` ONLY on a genuine miss (Upstash replied 200 with
* no result field).
* - Throws on every other failure mode (missing credentials, HTTP
* non-2xx, timeout/abort, JSON parse failure). Callers MUST
* distinguish infrastructure failure from empty-state to avoid
* showing users "composing" / "expired" UX during an outage.
*
* @param {string} key
* @param {number} [timeoutMs=3000]
* @returns {Promise<unknown | null>}
*/
export async function readRawJsonFromUpstash(key, timeoutMs = 3_000) {
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (!url || !token) {
throw new Error('readRawJsonFromUpstash: UPSTASH_REDIS_REST_URL/TOKEN not configured');
}
const resp = await fetch(`${url}/get/${encodeURIComponent(key)}`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(timeoutMs),
});
if (!resp.ok) {
throw new Error(`readRawJsonFromUpstash: Upstash GET ${key} returned HTTP ${resp.status}`);
}
const data = await resp.json();
if (data.result == null) return null; // genuine miss
try {
return JSON.parse(data.result);
} catch (err) {
throw new Error(
`readRawJsonFromUpstash: JSON.parse failed for ${key}: ${(err instanceof Error ? err.message : String(err))}`,
);
}
}
/** Returns Redis credentials or null if not configured. */
export function getRedisCredentials() {
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (!url || !token) return null;
return { url, token };
}
/**
* Execute a batch of Redis commands via the Upstash pipeline endpoint.
* Returns null on missing credentials, HTTP error, or timeout.
* @param {Array<string[]>} commands - e.g. [['GET', 'key'], ['EXPIRE', 'key', '60']]
* @param {number} [timeoutMs=5000]
* @returns {Promise<Array<{ result: unknown }> | null>}
*/
export async function redisPipeline(commands, timeoutMs = 5_000) {
const creds = getRedisCredentials();
if (!creds) return null;
try {
const resp = await fetch(`${creds.url}/pipeline`, {
method: 'POST',
headers: { Authorization: `Bearer ${creds.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(commands),
signal: AbortSignal.timeout(timeoutMs),
});
if (!resp.ok) return null;
return await resp.json();
} catch {
return null;
}
}
/**
* Write a JSON value to Redis with a TTL (SET + EXPIRE as pipeline).
* @param {string} key
* @param {unknown} value - will be JSON.stringify'd
* @param {number} ttlSeconds
* @returns {Promise<boolean>} true on success
*/
export async function setCachedData(key, value, ttlSeconds) {
const results = await redisPipeline([
['SET', key, JSON.stringify(value), 'EX', String(ttlSeconds)],
]);
return results !== null;
}