diff --git a/api/brief/carousel/[userId]/[issueDate]/[page].ts b/api/brief/carousel/[userId]/[issueDate]/[page].ts index 79e040f3b..d0b1e6f8e 100644 --- a/api/brief/carousel/[userId]/[issueDate]/[page].ts +++ b/api/brief/carousel/[userId]/[issueDate]/[page].ts @@ -46,7 +46,8 @@ import { renderCarouselPng, pageFromIndex } from '../../../../../server/_shared/ const PAGE_CACHE_TTL = 60 * 60 * 24 * 7; // 7 days — matches brief key TTL -const ISSUE_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +// Matches the signer's slot format (YYYY-MM-DD-HHMM). +const ISSUE_DATE_RE = /^\d{4}-\d{2}-\d{2}-\d{4}$/; function jsonError( msg: string, diff --git a/api/brief/share-url.ts b/api/brief/share-url.ts index b153f88d2..65ad53c6e 100644 --- a/api/brief/share-url.ts +++ b/api/brief/share-url.ts @@ -1,17 +1,23 @@ /** - * POST /api/brief/share-url?date=YYYY-MM-DD - * -> 200 { shareUrl, hash, issueDate } on success + * POST /api/brief/share-url?slot=YYYY-MM-DD-HHMM + * -> 200 { shareUrl, hash, issueSlot } on success * -> 401 UNAUTHENTICATED on missing/bad JWT * -> 403 pro_required for non-PRO users - * -> 400 invalid_date_shape / invalid_payload on bad inputs + * -> 400 invalid_slot_shape / invalid_payload on bad inputs * -> 404 brief_not_found when the per-user * brief key is missing (reader can't share what doesn't exist) * -> 503 service_unavailable on env/Upstash failure * + * Omitting ?slot= defaults to the user's most recent brief via the + * brief:latest:{userId} pointer the digest cron writes. That covers + * the Share button in the hosted magazine — it already carries the + * slot in its path — but also gives dashboard/test callers a path + * that doesn't need to know the slot. + * * Materialises the brief:public:{hash} pointer used by the unauth'd * /api/brief/public/{hash} route. Idempotent — the hash is a pure - * function of {userId, issueDate, BRIEF_SHARE_SECRET}, so repeated - * calls for the same reader+date always return the same URL and + * function of {userId, issueSlot, BRIEF_SHARE_SECRET}, so repeated + * calls for the same reader+slot always return the same URL and * overwrite the pointer with the same value (refreshing its TTL). * * Writing the pointer LAZILY (on share, not on compose) keeps the @@ -37,7 +43,7 @@ import { encodePublicPointer, } from '../../server/_shared/brief-share-url'; -const ISSUE_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const ISSUE_SLOT_RE = /^\d{4}-\d{2}-\d{2}-\d{4}$/; // Public pointer lives as long as the brief key itself (7 days), so // the share link works for the entire TTL window even if the user @@ -96,29 +102,57 @@ export default async function handler(req: Request): Promise { return jsonResponse({ error: 'service_unavailable' }, 503, cors); } - // Date may come from ?date=YYYY-MM-DD OR from a JSON body. Supporting - // both makes the call site in the magazine Share button trivial - // (send a POST with an empty body + query param) and leaves room for - // future extension (e.g. refCode) via the body. + // Slot may come from ?slot=YYYY-MM-DD-HHMM OR a JSON body, OR be + // omitted — in which case we look up the user's most recent brief + // via the latest-pointer the cron writes. That lets dashboard/test + // callers POST without knowing the slot while the magazine Share + // button can still pass its own slot through explicitly. const url = new URL(req.url); - let issueDate = url.searchParams.get('date'); + let issueSlot = url.searchParams.get('slot'); let refCode: string | undefined; - if (!issueDate || req.headers.get('content-type')?.includes('application/json')) { + if (!issueSlot || req.headers.get('content-type')?.includes('application/json')) { try { const body = (await req.json().catch(() => null)) as - | { date?: unknown; refCode?: unknown } + | { slot?: unknown; refCode?: unknown } | null; - if (!issueDate && typeof body?.date === 'string') issueDate = body.date; + if (!issueSlot && typeof body?.slot === 'string') issueSlot = body.slot; if (typeof body?.refCode === 'string' && body.refCode.length > 0 && body.refCode.length <= 32) { refCode = body.refCode; } } catch { - /* ignore — empty body is fine when ?date= carries the value */ + /* ignore — empty body is fine when ?slot= carries the value */ } } - if (!issueDate || !ISSUE_DATE_RE.test(issueDate)) { - return jsonResponse({ error: 'invalid_date_shape' }, 400, cors); + // Remember whether the caller supplied anything at all, so we can + // distinguish two miss modes below: bad input shape vs. "no brief + // exists yet for this user". Empty/whitespace counts as omitted. + const callerProvidedSlot = + typeof issueSlot === 'string' && issueSlot.trim().length > 0; + + if (!callerProvidedSlot) { + // No slot given → fall back to the latest-pointer the cron writes. + try { + const latest = await readRawJsonFromUpstash(`brief:latest:${session.userId}`); + const slot = (latest as { issueSlot?: unknown } | null)?.issueSlot; + if (typeof slot === 'string' && ISSUE_SLOT_RE.test(slot)) { + issueSlot = slot; + } else { + // Pointer missing (never composed / TTL expired) — this is a + // "no brief to share" condition, not an input-shape problem. + // Return the same 404 the existing-brief check would return + // so the caller gets a coherent contract: either the brief + // exists and is shareable, or it doesn't and you get 404. + return jsonResponse({ error: 'brief_not_found' }, 404, cors); + } + } catch (err) { + console.error('[api/brief/share-url] latest pointer read failed:', (err as Error).message); + return jsonResponse({ error: 'service_unavailable' }, 503, cors); + } + } + + if (!issueSlot || !ISSUE_SLOT_RE.test(issueSlot)) { + return jsonResponse({ error: 'invalid_slot_shape' }, 400, cors); } // Ensure the per-user brief actually exists before minting a share @@ -127,7 +161,7 @@ export default async function handler(req: Request): Promise { // gives a clean 503 path if Upstash is down. let existing: unknown; try { - existing = await readRawJsonFromUpstash(`brief:${session.userId}:${issueDate}`); + existing = await readRawJsonFromUpstash(`brief:${session.userId}:${issueSlot}`); } catch (err) { console.error('[api/brief/share-url] Upstash read failed:', (err as Error).message); return jsonResponse({ error: 'service_unavailable' }, 503, cors); @@ -141,7 +175,7 @@ export default async function handler(req: Request): Promise { try { const built = await buildPublicBriefUrl({ userId: session.userId, - issueDate, + issueDate: issueSlot, baseUrl: publicBaseUrl(req), secret, refCode, @@ -156,7 +190,7 @@ export default async function handler(req: Request): Promise { throw err; } - // Idempotent pointer write. Same {userId, issueDate, secret} always + // Idempotent pointer write. Same {userId, issueSlot, secret} always // produces the same hash, so this SET overwrites with an identical // value on repeat shares and resets the TTL window. // @@ -166,7 +200,7 @@ export default async function handler(req: Request): Promise { // throw at parse time and the public route would 503 instead of // resolving the pointer. const pointerKey = `${BRIEF_PUBLIC_POINTER_PREFIX}${hash}`; - const pointerValue = JSON.stringify(encodePublicPointer(session.userId, issueDate)); + const pointerValue = JSON.stringify(encodePublicPointer(session.userId, issueSlot)); const writeResult = await redisPipeline([ ['SET', pointerKey, pointerValue, 'EX', String(BRIEF_TTL_SECONDS)], ]); @@ -175,5 +209,5 @@ export default async function handler(req: Request): Promise { return jsonResponse({ error: 'service_unavailable' }, 503, cors); } - return jsonResponse({ shareUrl, hash, issueDate }, 200, cors); + return jsonResponse({ shareUrl, hash, issueSlot }, 200, cors); } diff --git a/api/latest-brief.ts b/api/latest-brief.ts index c28b4b6fa..a5297e815 100644 --- a/api/latest-brief.ts +++ b/api/latest-brief.ts @@ -13,10 +13,11 @@ * * The returned magazineUrl is freshly signed per request. It is safe * to expose to the authenticated client — the HMAC binds {userId, - * issueDate} so it is only useful to the owner. + * issueSlot} so it is only useful to the owner. * - * The route does NOT drive composition. It is a read-only mirror of - * whatever brief:{userId}:{issueDate} Redis happens to hold. + * The route does NOT drive composition. It reads the + * brief:latest:{userId} pointer written by the digest cron to locate + * the most recent slot, then returns that slot's envelope preview. */ export const config = { runtime: 'edge' }; @@ -32,23 +33,26 @@ import { getEntitlements } from '../server/_shared/entitlement-check'; import { signBriefUrl, BriefUrlError } from '../server/_shared/brief-url'; import { assertBriefEnvelope } from '../server/_shared/brief-render.js'; -const ISSUE_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; - -function utcDateOffset(days: number): string { - const d = new Date(); - d.setUTCDate(d.getUTCDate() + days); - return d.toISOString().slice(0, 10); -} +// Slot format written by the digest cron. Must match ISSUE_DATE_RE in +// server/_shared/brief-url.ts — the signer rejects anything else. +const ISSUE_SLOT_RE = /^\d{4}-\d{2}-\d{2}-\d{4}$/; function todayInUtc(): string { - return utcDateOffset(0); + return new Date().toISOString().slice(0, 10); } +type BriefPreview = { + issueDate: string; + dateLong: string; + greeting: string; + threadCount: number; +}; + async function readBriefPreview( userId: string, - issueDate: string, -): Promise<{ dateLong: string; greeting: string; threadCount: number } | null> { - const raw = await readRawJsonFromUpstash(`brief:${userId}:${issueDate}`); + issueSlot: string, +): Promise { + const raw = await readRawJsonFromUpstash(`brief:${userId}:${issueSlot}`); if (raw == null) return null; // Reuse the renderer's strict validator so a "ready" preview never // points at an envelope that the hosted magazine route will reject. @@ -59,18 +63,33 @@ async function readBriefPreview( assertBriefEnvelope(raw); } catch (err) { console.error( - `[api/latest-brief] composer-bug: brief:${userId}:${issueDate} failed envelope assertion: ${(err as Error).message}`, + `[api/latest-brief] composer-bug: brief:${userId}:${issueSlot} failed envelope assertion: ${(err as Error).message}`, ); return null; } const { data } = raw; return { + issueDate: data.date, dateLong: data.dateLong, greeting: data.digest.greeting, threadCount: data.stories.length, }; } +/** + * Resolve the user's most recent brief slot. Reads the + * brief:latest:{userId} pointer the digest cron writes alongside each + * SETEX. Returns null when no pointer exists (user never received a + * brief, or the pointer has expired past its 7d TTL). + */ +async function readLatestPointer(userId: string): Promise { + const raw = await readRawJsonFromUpstash(`brief:latest:${userId}`); + if (raw == null) return null; + const slot = (raw as { issueSlot?: unknown } | null)?.issueSlot; + if (typeof slot !== 'string' || !ISSUE_SLOT_RE.test(slot)) return null; + return slot; +} + /** * Public base URL for signed magazine links. Pinned to * WORLDMONITOR_PUBLIC_BASE_URL in production to prevent host-header @@ -128,36 +147,26 @@ export default async function handler(req: Request): Promise { return jsonResponse({ error: 'service_unavailable' }, 503, cors); } - // Determine which issue slot to probe. - // - If the client passes ?date=YYYY-MM-DD, use that verbatim. The - // dashboard panel should always take this path — it knows the - // user's local tz and computes the local date exactly. - // - Otherwise walk [tomorrow, today, yesterday] UTC in that order. - // The composer writes per user tz; a user at UTC+14 has today's - // brief under tomorrow UTC, a user at UTC-12 has it under - // yesterday UTC. Three candidates cover the full tz range - // without needing a tz database in the edge runtime. The order - // (tomorrow-first) naturally prefers the most recently composed - // slot. + // Locate the user's most recent brief via the pointer the digest + // cron writes. An optional ?slot=YYYY-MM-DD-HHMM lets the client + // request a specific prior brief (e.g. the dashboard's "compare to + // earlier" or tests); on malformed input we fall through to the + // pointer path rather than 400, so a stale URL never hard-breaks + // the panel. const url = new URL(req.url); - const dateParam = url.searchParams.get('date'); - if (dateParam !== null && !ISSUE_DATE_RE.test(dateParam)) { - return jsonResponse({ error: 'invalid_date_shape' }, 400, cors); - } - const todayUtc = todayInUtc(); - const candidates = dateParam - ? [dateParam] - : [utcDateOffset(1), todayUtc, utcDateOffset(-1)]; + const slotParam = url.searchParams.get('slot'); + const requestedSlot = + slotParam !== null && ISSUE_SLOT_RE.test(slotParam) ? slotParam : null; - let issueDate: string | null = null; - let preview: { dateLong: string; greeting: string; threadCount: number } | null = null; + let issueSlot: string | null = null; + let preview: BriefPreview | null = null; try { - for (const slot of candidates) { - const hit = await readBriefPreview(session.userId, slot); + const targetSlot = requestedSlot ?? (await readLatestPointer(session.userId)); + if (targetSlot) { + const hit = await readBriefPreview(session.userId, targetSlot); if (hit) { - issueDate = slot; + issueSlot = targetSlot; preview = hit; - break; } } } catch (err) { @@ -168,12 +177,25 @@ export default async function handler(req: Request): Promise { return jsonResponse({ error: 'service_unavailable' }, 503, cors); } - if (!preview || !issueDate) { - // Echo the caller's date on miss when they supplied one — the - // client cares about THAT slot's status, not today UTC. Default - // to today UTC only when no date was given. + if (!preview || !issueSlot) { + // Two miss cases with different semantics: + // (a) Caller asked for a specific ?slot= that doesn't exist → + // report that slot back as missing, NOT "today is composing". + // Otherwise a client probing a known slot gets a misleading + // "composing today" signal that has nothing to do with what + // they asked about. + // (b) No ?slot= given and no latest pointer → truly "no brief + // yet today". Keep the UTC-today placeholder the panel uses + // to render its empty-state title. + if (requestedSlot) { + return jsonResponse( + { status: 'composing', issueSlot: requestedSlot, issueDate: requestedSlot.slice(0, 10) }, + 200, + cors, + ); + } return jsonResponse( - { status: 'composing', issueDate: dateParam ?? todayUtc }, + { status: 'composing', issueDate: todayInUtc() }, 200, cors, ); @@ -183,7 +205,7 @@ export default async function handler(req: Request): Promise { try { magazineUrl = await signBriefUrl({ userId: session.userId, - issueDate, + issueDate: issueSlot, baseUrl: publicBaseUrl(req), secret, }); @@ -201,7 +223,8 @@ export default async function handler(req: Request): Promise { return jsonResponse( { status: 'ready', - issueDate, + issueDate: preview.issueDate, + issueSlot, dateLong: preview.dateLong, greeting: preview.greeting, threadCount: preview.threadCount, diff --git a/middleware.ts b/middleware.ts index 6ef9f5ede..7cae2c778 100644 --- a/middleware.ts +++ b/middleware.ts @@ -21,11 +21,13 @@ const SOCIAL_IMAGE_UA = // Must match the exact route shape enforced by // api/brief/carousel/[userId]/[issueDate]/[page].ts: -// /api/brief/carousel//YYYY-MM-DD/<0|1|2> +// /api/brief/carousel//YYYY-MM-DD-HHMM/<0|1|2> +// The issueDate segment is a per-run slot (date + HHMM in the user's +// tz) so same-day digests produce distinct carousel URLs. // pageFromIndex() in brief-carousel-render.ts accepts only 0/1/2, so // the trailing segment is tightly bounded. const BRIEF_CAROUSEL_PATH_RE = - /^\/api\/brief\/carousel\/[^/]+\/\d{4}-\d{2}-\d{2}\/[0-2]\/?$/; + /^\/api\/brief\/carousel\/[^/]+\/\d{4}-\d{2}-\d{2}-\d{4}\/[0-2]\/?$/; const VARIANT_HOST_MAP: Record = { 'tech.worldmonitor.app': 'tech', diff --git a/scripts/lib/brief-url-sign.mjs b/scripts/lib/brief-url-sign.mjs index 7e7fd1ffd..a51f7f2bf 100644 --- a/scripts/lib/brief-url-sign.mjs +++ b/scripts/lib/brief-url-sign.mjs @@ -15,7 +15,10 @@ // and Tauri if ever needed from a non-cron path. const USER_ID_RE = /^[A-Za-z0-9_-]{1,128}$/; -const ISSUE_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +// YYYY-MM-DD-HHMM issue slot (local hour+minute of the compose run, +// in the user's tz). Slot-per-run gives each digest dispatch its own +// frozen magazine URL; same-day reruns no longer collide. +const ISSUE_DATE_RE = /^\d{4}-\d{2}-\d{2}-\d{4}$/; export class BriefUrlError extends Error { constructor(code, message) { @@ -30,7 +33,7 @@ function assertShape(userId, issueDate) { throw new BriefUrlError('invalid_user_id', 'userId must match [A-Za-z0-9_-]{1,128}'); } if (!ISSUE_DATE_RE.test(issueDate)) { - throw new BriefUrlError('invalid_issue_date', 'issueDate must match YYYY-MM-DD'); + throw new BriefUrlError('invalid_issue_date', 'issueDate must match YYYY-MM-DD-HHMM'); } } diff --git a/scripts/seed-digest-notifications.mjs b/scripts/seed-digest-notifications.mjs index 9fc617eaf..f78b9072f 100644 --- a/scripts/seed-digest-notifications.mjs +++ b/scripts/seed-digest-notifications.mjs @@ -36,6 +36,7 @@ import { groupEligibleRulesByUser, shouldExitNonZero as shouldExitOnBriefFailures, } from './lib/brief-compose.mjs'; +import { issueSlotInTz } from '../shared/brief-filter.js'; import { enrichBriefEnvelopeWithLLM } from './lib/brief-llm.mjs'; import { assertBriefEnvelope } from '../server/_shared/brief-render.js'; import { signBriefUrl, BriefUrlError } from './lib/brief-url-sign.mjs'; @@ -593,9 +594,9 @@ function truncateTelegramHtml(html, limit = TELEGRAM_MAX_LEN) { /** * Phase 8: derive the 3 carousel image URLs from a signed magazine - * URL. The HMAC token binds (userId, issueDate), not the path — so - * the same token verifies against /api/brief/{u}/{d}?t=T AND against - * /api/brief/carousel/{u}/{d}/{0|1|2}?t=T. + * URL. The HMAC token binds (userId, issueSlot), not the path — so + * the same token verifies against /api/brief/{u}/{slot}?t=T AND against + * /api/brief/carousel/{u}/{slot}/{0|1|2}?t=T. * * Returns null when the magazine URL doesn't match the expected shape * — caller falls back to text-only delivery. @@ -603,13 +604,13 @@ function truncateTelegramHtml(html, limit = TELEGRAM_MAX_LEN) { function carouselUrlsFrom(magazineUrl) { try { const u = new URL(magazineUrl); - const m = u.pathname.match(/^\/api\/brief\/([^/]+)\/(\d{4}-\d{2}-\d{2})\/?$/); + const m = u.pathname.match(/^\/api\/brief\/([^/]+)\/(\d{4}-\d{2}-\d{2}-\d{4})\/?$/); if (!m) return null; - const [, userId, issueDate] = m; + const [, userId, issueSlot] = m; const token = u.searchParams.get('t'); if (!token) return null; return [0, 1, 2].map( - (p) => `${u.origin}/api/brief/carousel/${userId}/${issueDate}/${p}?t=${token}`, + (p) => `${u.origin}/api/brief/carousel/${userId}/${issueSlot}/${p}?t=${token}`, ); } catch { return null; @@ -1093,22 +1094,35 @@ async function composeAndStoreBriefForUser(userId, candidates, insightsNumbers, } } - const issueDate = envelope.data.date; - const key = `brief:${userId}:${issueDate}`; + // Slot (YYYY-MM-DD-HHMM in the user's tz) is what routes the + // magazine URL + Redis key. Using the same tz the composer used to + // produce envelope.data.date guarantees the slot's date portion + // matches the displayed date. Two same-day compose runs produce + // distinct slots so each digest dispatch freezes its own URL. + const briefTz = chosenCandidate?.digestTimezone ?? 'UTC'; + const issueSlot = issueSlotInTz(nowMs, briefTz); + const key = `brief:${userId}:${issueSlot}`; + // The latest-pointer lets readers (dashboard panel, share-url + // endpoint) locate the most recent brief without knowing the slot. + // One SET per compose is cheap and always current. + const latestPointerKey = `brief:latest:${userId}`; + const latestPointerValue = JSON.stringify({ issueSlot }); const pipelineResult = await redisPipeline([ ['SETEX', key, String(BRIEF_TTL_SECONDS), JSON.stringify(envelope)], + ['SETEX', latestPointerKey, String(BRIEF_TTL_SECONDS), latestPointerValue], ]); - if (!pipelineResult || !Array.isArray(pipelineResult) || pipelineResult.length === 0) { + if (!pipelineResult || !Array.isArray(pipelineResult) || pipelineResult.length < 2) { throw new Error('null pipeline response from Upstash'); } - const cell = pipelineResult[0]; - if (cell && typeof cell === 'object' && 'error' in cell) { - throw new Error(`Upstash SETEX error: ${cell.error}`); + for (const cell of pipelineResult) { + if (cell && typeof cell === 'object' && 'error' in cell) { + throw new Error(`Upstash SETEX error: ${cell.error}`); + } } const magazineUrl = await signBriefUrl({ userId, - issueDate, + issueDate: issueSlot, baseUrl: WORLDMONITOR_PUBLIC_BASE_URL, secret: BRIEF_URL_SIGNING_SECRET, }); diff --git a/server/_shared/brief-share-url.ts b/server/_shared/brief-share-url.ts index c34191a68..a24a55734 100644 --- a/server/_shared/brief-share-url.ts +++ b/server/_shared/brief-share-url.ts @@ -27,7 +27,11 @@ */ const USER_ID_RE = /^[A-Za-z0-9_-]{1,128}$/; -const ISSUE_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +// YYYY-MM-DD-HHMM issue slot — matches the magazine signer's slot +// format. deriveShareHash is bound to (userId, slot) so a morning +// brief and an afternoon brief of the same day produce distinct +// public share URLs. +const ISSUE_DATE_RE = /^\d{4}-\d{2}-\d{2}-\d{4}$/; // 12 base64url chars = 72 bits — enough to prevent brute-force // enumeration of active share URLs even at aggressive rates. const HASH_RE = /^[A-Za-z0-9_-]{12}$/; @@ -47,7 +51,7 @@ function assertShape(userId: string, issueDate: string): void { throw new BriefShareUrlError('invalid_user_id', 'userId must match [A-Za-z0-9_-]{1,128}'); } if (!ISSUE_DATE_RE.test(issueDate)) { - throw new BriefShareUrlError('invalid_issue_date', 'issueDate must match YYYY-MM-DD'); + throw new BriefShareUrlError('invalid_issue_date', 'issueDate must match YYYY-MM-DD-HHMM'); } } diff --git a/server/_shared/brief-url.ts b/server/_shared/brief-url.ts index 96a2ada44..aaee7c626 100644 --- a/server/_shared/brief-url.ts +++ b/server/_shared/brief-url.ts @@ -28,7 +28,10 @@ */ const USER_ID_RE = /^[A-Za-z0-9_-]{1,128}$/; -const ISSUE_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +// YYYY-MM-DD-HHMM issue slot — hour+minute of the compose run in the +// user's tz. The token binds userId + slot so each digest dispatch +// gets its own frozen magazine URL. +const ISSUE_DATE_RE = /^\d{4}-\d{2}-\d{2}-\d{4}$/; const TOKEN_RE = /^[A-Za-z0-9_-]{43}$/; // base64url(sha256) = 43 chars, no padding export class BriefUrlError extends Error { @@ -46,7 +49,7 @@ function assertShape(userId: string, issueDate: string): void { throw new BriefUrlError('invalid_user_id', 'userId must match [A-Za-z0-9_-]{1,128}'); } if (!ISSUE_DATE_RE.test(issueDate)) { - throw new BriefUrlError('invalid_issue_date', 'issueDate must match YYYY-MM-DD'); + throw new BriefUrlError('invalid_issue_date', 'issueDate must match YYYY-MM-DD-HHMM'); } } @@ -155,7 +158,7 @@ function base64urlDecode(token: string): Uint8Array | null { * * const url = await signBriefUrl({ * userId: 'user_abc', - * issueDate: '2026-04-17', + * issueDate: '2026-04-17-0800', * baseUrl: 'https://worldmonitor.app', * secret: process.env.BRIEF_URL_SIGNING_SECRET!, * }); diff --git a/shared/brief-filter.d.ts b/shared/brief-filter.d.ts index 942cf068a..e8ccd12f4 100644 --- a/shared/brief-filter.d.ts +++ b/shared/brief-filter.d.ts @@ -57,6 +57,18 @@ export function assembleStubbedBriefEnvelope(input: { */ export function issueDateInTz(nowMs: number, timezone: string): string; +/** + * Slot identifier (YYYY-MM-DD-HHMM, local tz) used as the Redis key + * suffix and magazine URL path segment. Two compose runs on the same + * day produce distinct slots so each digest dispatch gets a frozen + * magazine URL that keeps pointing at the envelope that was live when + * the notification went out. + * + * envelope.data.date (YYYY-MM-DD) is still the field the magazine + * renders as "19 April 2026"; issueSlot only drives routing. + */ +export function issueSlotInTz(nowMs: number, timezone: string): string; + /** Upstream shape from news:insights:v1.topStories[]. */ export interface UpstreamTopStory { primaryTitle?: unknown; diff --git a/shared/brief-filter.js b/shared/brief-filter.js index ebf3c3d28..b7d38c591 100644 --- a/shared/brief-filter.js +++ b/shared/brief-filter.js @@ -271,3 +271,42 @@ export function issueDateInTz(nowMs, timezone) { } return new Date(nowMs).toISOString().slice(0, 10); } + +/** + * Slot identifier for the brief URL + Redis key. Encodes the user's + * local calendar date PLUS the hour+minute of the compose run so two + * digests on the same day produce distinct magazine URLs. + * + * Format: YYYY-MM-DD-HHMM (local tz). + * + * `issueDate` (YYYY-MM-DD) remains the field the magazine renders as + * "19 April 2026"; `issueSlot` only drives routing. + * + * @param {number} nowMs + * @param {string} timezone + * @returns {string} + */ +export function issueSlotInTz(nowMs, timezone) { + const date = issueDateInTz(nowMs, timezone); + try { + const fmt = new Intl.DateTimeFormat('en-GB', { + timeZone: timezone, + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + const parts = fmt.formatToParts(new Date(nowMs)); + const hh = parts.find((p) => p.type === 'hour')?.value ?? ''; + const mm = parts.find((p) => p.type === 'minute')?.value ?? ''; + const hhmm = `${hh}${mm}`; + // Intl in some locales emits "24" for midnight instead of "00"; + // pin to the expected 4-digit numeric shape or fall through. + if (/^[01]\d[0-5]\d$|^2[0-3][0-5]\d$/.test(hhmm)) return `${date}-${hhmm}`; + } catch { + /* fall through to UTC */ + } + const d = new Date(nowMs); + const hh = String(d.getUTCHours()).padStart(2, '0'); + const mm = String(d.getUTCMinutes()).padStart(2, '0'); + return `${date}-${hh}${mm}`; +} diff --git a/tests/brief-carousel.test.mjs b/tests/brief-carousel.test.mjs index 3b569af96..2d0ab4389 100644 --- a/tests/brief-carousel.test.mjs +++ b/tests/brief-carousel.test.mjs @@ -19,13 +19,13 @@ import { pageFromIndex } from '../server/_shared/brief-carousel-render.ts'; function carouselUrlsFrom(magazineUrl) { try { const u = new URL(magazineUrl); - const m = u.pathname.match(/^\/api\/brief\/([^/]+)\/(\d{4}-\d{2}-\d{2})\/?$/); + const m = u.pathname.match(/^\/api\/brief\/([^/]+)\/(\d{4}-\d{2}-\d{2}-\d{4})\/?$/); if (!m) return null; - const [, userId, issueDate] = m; + const [, userId, issueSlot] = m; const token = u.searchParams.get('t'); if (!token) return null; return [0, 1, 2].map( - (p) => `${u.origin}/api/brief/carousel/${userId}/${issueDate}/${p}?t=${token}`, + (p) => `${u.origin}/api/brief/carousel/${userId}/${issueSlot}/${p}?t=${token}`, ); } catch { return null; @@ -48,32 +48,36 @@ describe('pageFromIndex', () => { }); describe('carouselUrlsFrom', () => { - const magazine = 'https://www.worldmonitor.app/api/brief/user_abc/2026-04-18?t=XXX'; + const magazine = 'https://www.worldmonitor.app/api/brief/user_abc/2026-04-18-0800?t=XXX'; it('derives three signed carousel URLs from a valid magazine URL', () => { const urls = carouselUrlsFrom(magazine); assert.ok(urls); assert.equal(urls.length, 3); - assert.equal(urls[0], 'https://www.worldmonitor.app/api/brief/carousel/user_abc/2026-04-18/0?t=XXX'); - assert.equal(urls[1], 'https://www.worldmonitor.app/api/brief/carousel/user_abc/2026-04-18/1?t=XXX'); - assert.equal(urls[2], 'https://www.worldmonitor.app/api/brief/carousel/user_abc/2026-04-18/2?t=XXX'); + assert.equal(urls[0], 'https://www.worldmonitor.app/api/brief/carousel/user_abc/2026-04-18-0800/0?t=XXX'); + assert.equal(urls[1], 'https://www.worldmonitor.app/api/brief/carousel/user_abc/2026-04-18-0800/1?t=XXX'); + assert.equal(urls[2], 'https://www.worldmonitor.app/api/brief/carousel/user_abc/2026-04-18-0800/2?t=XXX'); }); it('preserves origin (localhost, preview deploys, etc.)', () => { - const urls = carouselUrlsFrom('http://localhost:3000/api/brief/user_a/2026-04-18?t=T'); - assert.equal(urls[0], 'http://localhost:3000/api/brief/carousel/user_a/2026-04-18/0?t=T'); + const urls = carouselUrlsFrom('http://localhost:3000/api/brief/user_a/2026-04-18-1300?t=T'); + assert.equal(urls[0], 'http://localhost:3000/api/brief/carousel/user_a/2026-04-18-1300/0?t=T'); }); it('returns null for a URL without a token', () => { - assert.equal(carouselUrlsFrom('https://worldmonitor.app/api/brief/user_a/2026-04-18'), null); + assert.equal(carouselUrlsFrom('https://worldmonitor.app/api/brief/user_a/2026-04-18-0800'), null); }); it('returns null when the path is not the magazine route', () => { assert.equal(carouselUrlsFrom('https://worldmonitor.app/dashboard?t=X'), null); - assert.equal(carouselUrlsFrom('https://worldmonitor.app/api/other/path/2026-04-18?t=X'), null); + assert.equal(carouselUrlsFrom('https://worldmonitor.app/api/other/path/2026-04-18-0800?t=X'), null); }); - it('returns null when issueDate is not YYYY-MM-DD', () => { + it('returns null when the slot is date-only (no HHMM suffix)', () => { + assert.equal(carouselUrlsFrom('https://worldmonitor.app/api/brief/user_a/2026-04-18?t=X'), null); + }); + + it('returns null when slot is not YYYY-MM-DD-HHMM', () => { assert.equal(carouselUrlsFrom('https://worldmonitor.app/api/brief/user_a/today?t=X'), null); }); @@ -92,7 +96,7 @@ describe('carouselUrlsFrom — contract parity with seed-digest-notifications.mj const __d = dirname(fileURLToPath(import.meta.url)); const src = readFileSync(resolve(__d, '../scripts/seed-digest-notifications.mjs'), 'utf-8'); assert.match(src, /function carouselUrlsFrom\(magazineUrl\)/, 'cron must export carouselUrlsFrom'); - assert.match(src, /\/api\/brief\/carousel\/\$\{userId\}\/\$\{issueDate\}\/\$\{p\}\?t=\$\{token\}/, 'cron path template must match test fixture'); + assert.match(src, /\/api\/brief\/carousel\/\$\{userId\}\/\$\{issueSlot\}\/\$\{p\}\?t=\$\{token\}/, 'cron path template must match test fixture'); }); }); diff --git a/tests/brief-edge-route-smoke.test.mjs b/tests/brief-edge-route-smoke.test.mjs index 021f461f5..45a1f0924 100644 --- a/tests/brief-edge-route-smoke.test.mjs +++ b/tests/brief-edge-route-smoke.test.mjs @@ -40,7 +40,7 @@ describe('api/brief handler behaviour (no secrets / no Redis)', () => { it('returns 204 on OPTIONS preflight', async () => { const { default: handler } = await import('../api/brief/[userId]/[issueDate].ts'); - const req = new Request('https://worldmonitor.app/api/brief/user_x/2026-04-17', { + const req = new Request('https://worldmonitor.app/api/brief/user_x/2026-04-17-0800', { method: 'OPTIONS', headers: { origin: 'https://worldmonitor.app' }, }); @@ -51,7 +51,7 @@ describe('api/brief handler behaviour (no secrets / no Redis)', () => { it('returns 405 on disallowed methods', async () => { process.env.BRIEF_URL_SIGNING_SECRET ??= 'test-secret-used-only-for-method-gate'; const { default: handler } = await import('../api/brief/[userId]/[issueDate].ts'); - const req = new Request('https://worldmonitor.app/api/brief/user_x/2026-04-17', { + const req = new Request('https://worldmonitor.app/api/brief/user_x/2026-04-17-0800', { method: 'POST', headers: { origin: 'https://worldmonitor.app' }, }); @@ -64,7 +64,7 @@ describe('api/brief handler behaviour (no secrets / no Redis)', () => { const { default: handler } = await import('../api/brief/[userId]/[issueDate].ts'); // HEAD with a bad token → 403 path; body should still be empty. const req = new Request( - 'https://worldmonitor.app/api/brief/user_x/2026-04-17?t=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'https://worldmonitor.app/api/brief/user_x/2026-04-17-0800?t=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', { method: 'HEAD', headers: { origin: 'https://worldmonitor.app' }, @@ -88,7 +88,7 @@ describe('infrastructure-error vs miss (both routes must not collapse)', () => { delete process.env.UPSTASH_REDIS_REST_TOKEN; try { await assert.rejects( - () => readRawJsonFromUpstash('brief:user_x:2026-04-17'), + () => readRawJsonFromUpstash('brief:user_x:2026-04-17-0800'), /not configured/, ); } finally { @@ -105,7 +105,7 @@ describe('infrastructure-error vs miss (both routes must not collapse)', () => { globalThis.fetch = async () => new Response('internal error', { status: 500 }); try { await assert.rejects( - () => readRawJsonFromUpstash('brief:user_x:2026-04-17'), + () => readRawJsonFromUpstash('brief:user_x:2026-04-17-0800'), /HTTP 500/, ); } finally { @@ -124,7 +124,7 @@ describe('infrastructure-error vs miss (both routes must not collapse)', () => { headers: { 'content-type': 'application/json' }, }); try { - const out = await readRawJsonFromUpstash('brief:user_x:2026-04-17'); + const out = await readRawJsonFromUpstash('brief:user_x:2026-04-17-0800'); assert.equal(out, null); } finally { globalThis.fetch = realFetch; @@ -141,7 +141,7 @@ describe('infrastructure-error vs miss (both routes must not collapse)', () => { const { default: handler } = await import('../api/brief/[userId]/[issueDate].ts'); const { signBriefToken } = await import('../server/_shared/brief-url.ts'); const userId = 'user_test'; - const issueDate = '2026-04-17'; + const issueDate = '2026-04-17-0800'; const token = await signBriefToken(userId, issueDate, process.env.BRIEF_URL_SIGNING_SECRET); const req = new Request( `https://worldmonitor.app/api/brief/${userId}/${issueDate}?t=${token}`, diff --git a/tests/brief-share-url.test.mts b/tests/brief-share-url.test.mts index b879050fd..26d6e488a 100644 --- a/tests/brief-share-url.test.mts +++ b/tests/brief-share-url.test.mts @@ -23,38 +23,47 @@ const SECRET_B = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; describe('deriveShareHash', () => { it('produces a 12-char base64url string', async () => { - const hash = await deriveShareHash('user_abc', '2026-04-18', SECRET_A); + const hash = await deriveShareHash('user_abc', '2026-04-18-0800', SECRET_A); assert.equal(hash.length, 12); assert.match(hash, /^[A-Za-z0-9_-]{12}$/); }); it('is deterministic for the same inputs', async () => { - const a = await deriveShareHash('user_abc', '2026-04-18', SECRET_A); - const b = await deriveShareHash('user_abc', '2026-04-18', SECRET_A); + const a = await deriveShareHash('user_abc', '2026-04-18-0800', SECRET_A); + const b = await deriveShareHash('user_abc', '2026-04-18-0800', SECRET_A); assert.equal(a, b); }); it('differs for different userIds', async () => { - const a = await deriveShareHash('user_abc', '2026-04-18', SECRET_A); - const b = await deriveShareHash('user_xyz', '2026-04-18', SECRET_A); + const a = await deriveShareHash('user_abc', '2026-04-18-0800', SECRET_A); + const b = await deriveShareHash('user_xyz', '2026-04-18-0800', SECRET_A); assert.notEqual(a, b); }); it('differs for different dates', async () => { - const a = await deriveShareHash('user_abc', '2026-04-18', SECRET_A); - const b = await deriveShareHash('user_abc', '2026-04-19', SECRET_A); + const a = await deriveShareHash('user_abc', '2026-04-18-0800', SECRET_A); + const b = await deriveShareHash('user_abc', '2026-04-19-0800', SECRET_A); assert.notEqual(a, b); }); + it('differs for same-day slots at different hours (the whole point of slot rollout)', async () => { + // The regression this slot format prevents: morning + afternoon + // digest emails on the same day must produce distinct public + // share hashes so each dispatch has its own share URL. + const morning = await deriveShareHash('user_abc', '2026-04-18-0800', SECRET_A); + const afternoon = await deriveShareHash('user_abc', '2026-04-18-1300', SECRET_A); + assert.notEqual(morning, afternoon); + }); + it('differs for different secrets (rotation invalidates old hashes)', async () => { - const a = await deriveShareHash('user_abc', '2026-04-18', SECRET_A); - const b = await deriveShareHash('user_abc', '2026-04-18', SECRET_B); + const a = await deriveShareHash('user_abc', '2026-04-18-0800', SECRET_A); + const b = await deriveShareHash('user_abc', '2026-04-18-0800', SECRET_B); assert.notEqual(a, b); }); it('throws BriefShareUrlError on missing secret', async () => { await assert.rejects( - () => deriveShareHash('user_abc', '2026-04-18', ''), + () => deriveShareHash('user_abc', '2026-04-18-0800', ''), (err: unknown) => err instanceof BriefShareUrlError && err.code === 'missing_secret', ); @@ -62,7 +71,7 @@ describe('deriveShareHash', () => { it('throws on malformed userId', async () => { await assert.rejects( - () => deriveShareHash('has spaces', '2026-04-18', SECRET_A), + () => deriveShareHash('has spaces', '2026-04-18-0800', SECRET_A), (err: unknown) => err instanceof BriefShareUrlError && err.code === 'invalid_user_id', ); @@ -95,16 +104,16 @@ describe('isValidShareHashShape', () => { describe('encodePublicPointer / decodePublicPointer', () => { it('round-trips', () => { - const encoded = encodePublicPointer('user_abc', '2026-04-18'); - assert.equal(encoded, 'user_abc:2026-04-18'); + const encoded = encodePublicPointer('user_abc', '2026-04-18-0800'); + assert.equal(encoded, 'user_abc:2026-04-18-0800'); assert.deepEqual(decodePublicPointer(encoded), { userId: 'user_abc', - issueDate: '2026-04-18', + issueDate: '2026-04-18-0800', }); }); it('rejects malformed inputs at encode time', () => { - assert.throws(() => encodePublicPointer('bad user', '2026-04-18'), BriefShareUrlError); + assert.throws(() => encodePublicPointer('bad user', '2026-04-18-0800'), BriefShareUrlError); assert.throws(() => encodePublicPointer('user_abc', 'not-a-date'), BriefShareUrlError); }); @@ -114,7 +123,7 @@ describe('encodePublicPointer / decodePublicPointer', () => { assert.equal(decodePublicPointer(''), null); assert.equal(decodePublicPointer('no-colon'), null); assert.equal(decodePublicPointer('user:not-a-date'), null); - assert.equal(decodePublicPointer('user spaces:2026-04-18'), null); + assert.equal(decodePublicPointer('user spaces:2026-04-18-0800'), null); }); }); @@ -122,7 +131,7 @@ describe('buildPublicBriefUrl', () => { it('returns a full URL under baseUrl with the derived hash in the path', async () => { const { url, hash } = await buildPublicBriefUrl({ userId: 'user_abc', - issueDate: '2026-04-18', + issueDate: '2026-04-18-0800', baseUrl: 'https://worldmonitor.app', secret: SECRET_A, }); @@ -133,7 +142,7 @@ describe('buildPublicBriefUrl', () => { it('attaches ?ref= when refCode is provided', async () => { const { url } = await buildPublicBriefUrl({ userId: 'user_abc', - issueDate: '2026-04-18', + issueDate: '2026-04-18-0800', baseUrl: 'https://worldmonitor.app', secret: SECRET_A, refCode: 'ABC123', @@ -144,7 +153,7 @@ describe('buildPublicBriefUrl', () => { it('URL-encodes refCode safely', async () => { const { url } = await buildPublicBriefUrl({ userId: 'user_abc', - issueDate: '2026-04-18', + issueDate: '2026-04-18-0800', baseUrl: 'https://worldmonitor.app', secret: SECRET_A, refCode: 'a b+c', @@ -155,7 +164,7 @@ describe('buildPublicBriefUrl', () => { it('trims trailing slashes from baseUrl', async () => { const { url } = await buildPublicBriefUrl({ userId: 'user_abc', - issueDate: '2026-04-18', + issueDate: '2026-04-18-0800', baseUrl: 'https://worldmonitor.app///', secret: SECRET_A, }); @@ -178,7 +187,7 @@ describe('pointer wire format (P1 regression — write ↔ read must round-trip) // throw at parse time and the public route would 503 instead of // resolving the pointer. This test locks the wire format. it('JSON.stringify + JSON.parse + decodePublicPointer round-trips cleanly', () => { - const encoded = encodePublicPointer('user_abc', '2026-04-18'); + const encoded = encodePublicPointer('user_abc', '2026-04-18-0800'); // Write side: what api/brief/share-url.ts sends to Redis. const wireValue = JSON.stringify(encoded); // Read side: what readRawJsonFromUpstash returns after parsing @@ -186,7 +195,7 @@ describe('pointer wire format (P1 regression — write ↔ read must round-trip) const parsed = JSON.parse(wireValue); assert.equal(typeof parsed, 'string', 'parsed pointer is a string'); const pointer = decodePublicPointer(parsed); - assert.deepEqual(pointer, { userId: 'user_abc', issueDate: '2026-04-18' }); + assert.deepEqual(pointer, { userId: 'user_abc', issueDate: '2026-04-18-0800' }); }); it('a raw colon-delimited string (the P1 bug) fails JSON.parse', () => { @@ -194,6 +203,6 @@ describe('pointer wire format (P1 regression — write ↔ read must round-trip) // revert to it, readRawJsonFromUpstash's parse will throw and // the public route will 503. Locking the failure so anyone // who reintroduces the bug gets a red test. - assert.throws(() => JSON.parse('user_abc:2026-04-18'), SyntaxError); + assert.throws(() => JSON.parse('user_abc:2026-04-18-0800'), SyntaxError); }); }); diff --git a/tests/brief-url-sign.test.mjs b/tests/brief-url-sign.test.mjs index 2c7db723d..915394341 100644 --- a/tests/brief-url-sign.test.mjs +++ b/tests/brief-url-sign.test.mjs @@ -26,7 +26,8 @@ import { const SECRET = 'consolidation-parity-secret-0xdead'; const USER_ID = 'user_consolidated123'; -const ISSUE_DATE = '2026-04-18'; +// Slot format: YYYY-MM-DD-HHMM (per compose run, user's tz). +const ISSUE_DATE = '2026-04-18-0800'; describe('scripts/lib/brief-url-sign parity with server/_shared/brief-url', () => { it('produces byte-identical tokens for the same inputs', async () => { diff --git a/tests/brief-url.test.mjs b/tests/brief-url.test.mjs index ecdf84a8e..cf06ef452 100644 --- a/tests/brief-url.test.mjs +++ b/tests/brief-url.test.mjs @@ -18,7 +18,8 @@ import { const SECRET = 'primary-secret-for-tests-0123456789'; const PREV_SECRET = 'rotated-out-legacy-secret-abcdefghij'; const USER_ID = 'user_abc123'; -const ISSUE_DATE = '2026-04-17'; +// Slot format: YYYY-MM-DD-HHMM (per compose run). +const ISSUE_DATE = '2026-04-17-0800'; describe('signBriefToken + verifyBriefToken', () => { it('round-trips: verify(sign) is true for matching inputs', async () => { @@ -51,9 +52,10 @@ describe('signBriefToken + verifyBriefToken', () => { assert.equal(await verifyBriefToken('user_xyz', ISSUE_DATE, token, SECRET), false); }); - it('rejects a token bound to a different issueDate', async () => { + it('rejects a token bound to a different issueSlot', async () => { const token = await signBriefToken(USER_ID, ISSUE_DATE, SECRET); - assert.equal(await verifyBriefToken(USER_ID, '2026-04-18', token, SECRET), false); + // Same day, different slot (13:00) must NOT verify. + assert.equal(await verifyBriefToken(USER_ID, '2026-04-17-1300', token, SECRET), false); }); it('rejects a token signed with a different secret', async () => { @@ -110,6 +112,16 @@ describe('signBriefToken + verifyBriefToken', () => { (err) => err instanceof BriefUrlError && err.code === 'invalid_issue_date', ); }); + + it('throws BriefUrlError when slot is missing the HHMM suffix', async () => { + // Bare YYYY-MM-DD is no longer a valid slot — cron must pass the + // full YYYY-MM-DD-HHMM. Guards against an accidental partial + // revert of the slot rollout. + await assert.rejects( + () => signBriefToken(USER_ID, '2026-04-17', SECRET), + (err) => err instanceof BriefUrlError && err.code === 'invalid_issue_date', + ); + }); }); describe('secret rotation', () => { diff --git a/tests/middleware-bot-gate.test.mts b/tests/middleware-bot-gate.test.mts index 6456baa15..798d946c1 100644 --- a/tests/middleware-bot-gate.test.mts +++ b/tests/middleware-bot-gate.test.mts @@ -27,7 +27,13 @@ const CHROME_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'; -const CAROUSEL_PATH = '/api/brief/carousel/user_abc/2026-04-19/0'; +// Slot format: YYYY-MM-DD-HHMM — per compose run, matches the +// carousel route's ISSUE_DATE_RE and the signer's slot regex. +const CAROUSEL_PATH = '/api/brief/carousel/user_abc/2026-04-19-0800/0'; +// Bare YYYY-MM-DD (the pre-slot shape) must no longer match, so digest +// links that predate the slot rollout naturally fall into the bot gate +// instead of silently leaking the allowlist. +const LEGACY_DATE_ONLY_CAROUSEL_PATH = '/api/brief/carousel/user_abc/2026-04-19/0'; const OTHER_API_PATH = '/api/notifications'; const MALFORMED_CAROUSEL_PATH = '/api/brief/carousel/admin/dashboard'; @@ -103,14 +109,23 @@ describe('middleware bot gate / carousel allowlist', () => { }); it('does not accept page 3+ on the carousel route (pageFromIndex only has 0/1/2)', () => { - const res = call('/api/brief/carousel/user_abc/2026-04-19/3', TELEGRAM_BOT_UA); + const res = call('/api/brief/carousel/user_abc/2026-04-19-0800/3', TELEGRAM_BOT_UA); assert.ok(res instanceof Response, 'out-of-range page must hit the bot gate'); assert.equal(res.status, 403); }); - it('does not accept non-ISO-date segments on the carousel route', () => { + it('does not accept non-slot segments on the carousel route', () => { const res = call('/api/brief/carousel/user_abc/today/0', TELEGRAM_BOT_UA); assert.ok(res instanceof Response); assert.equal(res.status, 403); }); + + it('does not accept the pre-slot YYYY-MM-DD shape (slot rollout parity)', () => { + // Once the composer moves to slot URLs, legacy date-only paths + // should NOT leak the social allowlist — they correspond to + // expired pre-rollout links whose Redis keys no longer exist. + const res = call(LEGACY_DATE_ONLY_CAROUSEL_PATH, TELEGRAM_BOT_UA); + assert.ok(res instanceof Response); + assert.equal(res.status, 403); + }); });