mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-26 01:24:59 +02:00
* 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.
118 lines
4.1 KiB
JavaScript
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;
|
|
}
|