fix(brief): per-run slot URL so same-day digests link to distinct briefs (#3205)

* fix(brief): per-run slot URL so same-day digests link to distinct briefs

Digest emails at 8am and 1pm on the same day pointed to byte-identical
magazine URLs because the URL was keyed on YYYY-MM-DD in the user tz.
Each compose run overwrote the single daily envelope in place, and the
composer rolling 24h story window meant afternoon output often looked
identical to morning. Readers clicking an older email got whatever the
latest cron happened to write.

Slot format is now YYYY-MM-DD-HHMM (local tz, per compose run). The
magazine URL, carousel URLs, and Redis key all carry the slot, and each
digest dispatch gets its own frozen envelope that lives out the 7d TTL.
envelope.data.date stays YYYY-MM-DD for rendering "19 April 2026".

The digest cron also writes a brief:latest:{userId} pointer (7d TTL,
overwritten each compose) so the dashboard panel and share-url endpoint
can locate the most recent brief without knowing the slot. The
previous date-probing strategy does not work once keys carry HHMM.

No back-compat for the old YYYY-MM-DD format: the verifier rejects it,
the composer only ever writes the new shape, and any in-flight
notifications signed under the old format will 403 on click. Acceptable
at the rollout boundary per product decision.

* fix(brief): carve middleware bot allowlist to accept slot-format carousel path

BRIEF_CAROUSEL_PATH_RE in middleware.ts was still matching only the
pre-slot YYYY-MM-DD segment, so every slot-based carousel URL emitted
by the digest cron (YYYY-MM-DD-HHMM) would miss the social allowlist
and fall into the generic bot gate. Telegram/Slack/Discord/LinkedIn
image fetchers would 403 on sendMediaGroup, breaking previews for the
new digest links.

CI missed this because tests/middleware-bot-gate.test.mts still
exercised the old /YYYY-MM-DD/ path shape. Swap the fixture to the
slot format and add a regression asserting the pre-slot shape is now
rejected, so legacy links cannot silently leak the allowlist after
the rollout.

* fix(brief): preserve caller-requested slot + correct no-brief share-url error

Two contract bugs in the slot rollout that silently misled callers:

1. GET /api/latest-brief?slot=X where X has no envelope was returning
   { status: 'composing', issueDate: <today UTC> } — which reads as
   "today's brief is composing" instead of "the specific slot you
   asked about doesn't exist". A caller probing a known historical
   slot would get a completely unrelated "today" signal. Now we echo
   the requested slot back (issueSlot + issueDate derived from its
   date portion) when the caller supplied ?slot=, and keep the
   UTC-today placeholder only for the no-param path.

2. POST /api/brief/share-url with no slot and no latest-pointer was
   falling into the generic invalid_slot_shape 400 branch. That is
   not an input-shape problem; it is "no brief exists yet for this
   user". Return 404 brief_not_found — the same code the
   existing-envelope check returns — so callers get one coherent
   contract: either the brief exists and is shareable, or it doesn't
   and you get 404.
This commit is contained in:
Elie Habib
2026-04-19 14:15:59 +04:00
committed by GitHub
parent 56054bfbc1
commit 38e6892995
16 changed files with 318 additions and 142 deletions

View File

@@ -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,

View File

@@ -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<Response> {
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<Response> {
// 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<Response> {
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<Response> {
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<Response> {
// 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<Response> {
return jsonResponse({ error: 'service_unavailable' }, 503, cors);
}
return jsonResponse({ shareUrl, hash, issueDate }, 200, cors);
return jsonResponse({ shareUrl, hash, issueSlot }, 200, cors);
}

View File

@@ -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<BriefPreview | null> {
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<string | null> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
return jsonResponse(
{
status: 'ready',
issueDate,
issueDate: preview.issueDate,
issueSlot,
dateLong: preview.dateLong,
greeting: preview.greeting,
threadCount: preview.threadCount,

View File

@@ -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/<userId>/YYYY-MM-DD/<0|1|2>
// /api/brief/carousel/<userId>/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<string, string> = {
'tech.worldmonitor.app': 'tech',

View File

@@ -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');
}
}

View File

@@ -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,
});

View File

@@ -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');
}
}

View File

@@ -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!,
* });

View File

@@ -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;

View File

@@ -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}`;
}

View File

@@ -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');
});
});

View File

@@ -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}`,

View File

@@ -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);
});
});

View File

@@ -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 () => {

View File

@@ -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', () => {

View File

@@ -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);
});
});