fix(brief): Dockerfile type:module + explicit missing-secret tripwire

Addresses two eighth-round review findings on PR #3157.

1. ESM .js files parse as CommonJS in the container (todo 232).
   Dockerfile.digest-notifications COPYs shared/*.js,
   server/_shared/*.js, api/*.js — all ESM because the repo-root
   package.json has "type":"module". But the image never copies the
   root package.json, so Node's nearest-pjson walk inside /app/
   reaches / without finding one and defaults to CommonJS. First
   `export` statement throws `SyntaxError: Unexpected token 'export'`
   at startup.

   Fix: write a minimal /app/package.json with {"type":"module"}
   early in the build. Avoids dragging the full root package.json
   into the image while still giving Node the ESM hint it needs for
   repo-owned .js files.

2. Missing BRIEF_URL_SIGNING_SECRET silently tolerated (todo 233).
   The old gate folded "operator-disabled" (BRIEF_COMPOSE_ENABLED=0)
   and "required secret missing in rollout" into the same boolean
   via AND. A production deploy that forgot the env var would skip
   brief compose without any failure signal — Railway green, no
   briefs, no CTA in digests, nobody notices.

   Split the two states: BRIEF_COMPOSE_DISABLED_BY_OPERATOR (explicit
   kill switch, silent) and BRIEF_SIGNING_SECRET_MISSING (the misconfig
   we care about). When the secret is missing without the operator
   flag, composeBriefsForRun returns composeFailed=1 on first call
   so the end-of-run exit gate trips and Railway flags the run red.
   Digest send still proceeds — compose-layer issues never block
   notifications.

Tests: 98/98. Syntax + node --check clean.
This commit is contained in:
Elie Habib
2026-04-18 12:02:44 +04:00
parent 35a46aa34b
commit f1d209ea59
2 changed files with 28 additions and 1 deletions

View File

@@ -32,6 +32,15 @@ FROM node:22-alpine
WORKDIR /app
# The repo-root package.json has "type":"module" which tells Node to
# parse .js files under shared/, server/_shared/, api/ as ESM. Inside
# this image we don't ship the full root package.json (it would pull
# in dev-deps metadata we don't need), but the .js files we DO ship
# still need the nearest-pjson walk to resolve to an ESM declaration.
# A minimal /app/package.json avoids "SyntaxError: Unexpected token
# 'export'" at container startup.
RUN printf '{"type":"module","private":true}\n' > /app/package.json
# Install scripts/ runtime dependencies (resend, convex, etc.).
COPY scripts/package.json scripts/package-lock.json ./scripts/
RUN npm ci --prefix scripts --omit=dev

View File

@@ -82,8 +82,15 @@ const WORLDMONITOR_PUBLIC_BASE_URL =
process.env.WORLDMONITOR_PUBLIC_BASE_URL ?? 'https://worldmonitor.app';
const BRIEF_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
const INSIGHTS_KEY = 'news:insights:v1';
// Operator kill switch — used to intentionally silence brief compose
// without surfacing a Railway red flag. Distinguished from "secret
// missing in a production rollout" which IS worth flagging.
const BRIEF_COMPOSE_DISABLED_BY_OPERATOR = process.env.BRIEF_COMPOSE_ENABLED === '0';
const BRIEF_COMPOSE_ENABLED =
process.env.BRIEF_COMPOSE_ENABLED !== '0' && BRIEF_URL_SIGNING_SECRET !== '';
!BRIEF_COMPOSE_DISABLED_BY_OPERATOR && BRIEF_URL_SIGNING_SECRET !== '';
const BRIEF_SIGNING_SECRET_MISSING =
!BRIEF_COMPOSE_DISABLED_BY_OPERATOR && BRIEF_URL_SIGNING_SECRET === '';
// ── Redis helpers ──────────────────────────────────────────────────────────────
@@ -898,6 +905,17 @@ function injectBriefCta(html, magazineUrl) {
*/
async function composeBriefsForRun(rules, nowMs) {
const briefByUser = new Map();
// Missing secret without explicit operator-disable = misconfigured
// rollout. Count it as a compose failure so the end-of-run exit
// gate trips and Railway flags the run red. Digest send still
// proceeds (compose failures must never block notification
// delivery to users).
if (BRIEF_SIGNING_SECRET_MISSING) {
console.error(
'[digest] brief: BRIEF_URL_SIGNING_SECRET not configured. Set BRIEF_COMPOSE_ENABLED=0 to silence intentionally.',
);
return { briefByUser, composeSuccess: 0, composeFailed: 1 };
}
if (!BRIEF_COMPOSE_ENABLED) return { briefByUser, composeSuccess: 0, composeFailed: 0 };
let insightsRaw = null;