mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(brief): consolidate composer into digest cron (retire standalone service)
Merges the Phase 3a standalone Railway composer into the existing
digest cron. End state: one cron (seed-digest-notifications.mjs)
writes brief:{userId}:{issueDate} for every eligible user AND
dispatches the digest to their configured channels with a signed
magazine URL appended. Net -1 Railway service.
User's architectural note: "there is no reason to have 1 digest
preparing all and sending, then another doing a duplicate". This
delivers that — infrastructure consolidation, same send cadence,
single source of truth for brief envelopes.
File moves / deletes:
- scripts/seed-brief-composer.mjs → scripts/lib/brief-compose.mjs
Pure-helpers library: no main(), no env guards, no cron. Exports
composeBriefForRule + groupEligibleRulesByUser + dedupeRulesByUser
(shim) + shouldExitNonZero + date helpers + extractInsights.
- Dockerfile.seed-brief-composer → deleted.
- The seed-brief-composer Railway service is retired (user confirmed
they would delete it manually).
New files:
- scripts/lib/brief-url-sign.mjs — plain .mjs port of the sign path
in server/_shared/brief-url.ts (Web Crypto only, no node:crypto).
- tests/brief-url-sign.test.mjs — parity tests that confirm tokens
minted by the scripts-side signer verify via the edge-side verifier
and produce byte-identical output for identical input.
Digest cron (scripts/seed-digest-notifications.mjs):
- Reads news:insights:v1 once per run, composes per-user brief
envelopes, SETEX brief:{userId}:{issueDate} via body-POST pipeline.
- Signs magazine URL per user (BRIEF_URL_SIGNING_SECRET +
WORLDMONITOR_PUBLIC_BASE_URL new env requirements, see pre-merge).
- Injects magazineUrl into buildChannelBodies for every channel
(email, telegram, slack, discord) as a "📖 Open your WorldMonitor
Brief magazine" footer CTA.
- Email HTML gets a dedicated data-brief-cta-slot near the top of
the body with a styled button.
- Compose failures NEVER block the digest send — the digest cron's
existing behaviour is preserved when the brief pipeline has issues.
- Brief compose extracted to its own functions (composeBriefsForRun
+ composeAndStoreBriefForUser) to keep main's biome complexity at
baseline (64 — was 63 before; inline would have pushed to 117).
Tests: 98/98 across the brief suite. New parity tests confirm cross-
module signer agreement.
PRE-MERGE: add BRIEF_URL_SIGNING_SECRET and WORLDMONITOR_PUBLIC_BASE_URL
to the digest-notifications Railway service env (same values already
set on Vercel for Phase 2). Without them, brief compose is auto-
disabled and the digest falls back to its current behaviour — safe to
deploy before env is set.
* fix(brief): digest Dockerfile + propagate compose failure to exit code
Addresses two seventh-round review findings on PR #3157.
1. Cross-directory imports + current Railway build root (todo 230).
The consolidated digest cron imports from ../api, ../shared, and
(transitively via scripts/lib/brief-compose.mjs) ../server/_shared.
The running digest-notifications Railway service builds from the
scripts/ root — those parent paths are outside the deploy tree
and would 500 on next rebuild with ERR_MODULE_NOT_FOUND.
New Dockerfile.digest-notifications (repo-root build context)
COPYs exactly the modules the cron needs: scripts/ contents,
scripts/lib/, shared/brief-envelope.*, shared/brief-filter.*,
server/_shared/brief-render.*, api/_upstash-json.js,
api/_seed-envelope.js. Tight list to keep the watch surface small.
Pattern matches the retired Dockerfile.seed-brief-composer + the
existing Dockerfile.relay.
2. Silent compose failures (todo 231). composeBriefsForRun logged
counters but never exited non-zero. An Upstash outage or missing
signing secret silently dropped every brief write while Railway
showed the cron green. The retired standalone composer exited 1
on structural failures; that observability was lost in the
consolidation.
Changed the compose fn to return {briefByUser, composeSuccess,
composeFailed}. Main captures the counters, runs the full digest
send loop first (compose-layer breakage must NEVER block user-
visible digest delivery), then calls shouldExitNonZero at the
very end. Exit-on-failure gives ops the Railway-red signal
without touching send behaviour.
Also: a total read failure of news:insights:v1 (catch branch)
now counts as 1 compose failure so the gate trips on insights-
key infra breakage, not just per-user write failures.
Tests unchanged (98/98). Typecheck + node --check clean. Biome
complexity ticks 63→65 — same pre-existing bucket, already tolerated
by CI; no new blocker.
PRE-MERGE Railway work still pending: set BRIEF_URL_SIGNING_SECRET
+ WORLDMONITOR_PUBLIC_BASE_URL on the digest-notifications service,
AND switch its dockerfilePath to /Dockerfile.digest-notifications
before merging. Without the dockerfilePath switch, the next rebuild
fails.
* 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.
* fix(brief): address 2 remaining P2 review comments on PR #3157
Greptile review (2026-04-18T05:04Z) flagged three P2 items. The
first (shouldExitNonZero never wired into cron) was already fixed in
commit 35a46aa34. This commit addresses the other two.
1. composeBriefForRule: issuedAt used Date.now() instead of the
caller-supplied nowMs. Under the digest cron the delta is
milliseconds and harmless, but it broke the function's
determinism contract — same input must produce same output for
tests + retries. Now uses the passed nowMs.
2. buildChannelBodies: magazineUrl embedded raw inside Telegram HTML
<a href="..."> and Slack <URL|text> syntax. The URL is HMAC-
signed and shape-validated upstream (userId regex + YYYY-MM-DD
date), so injection is practically impossible — but the email
CTA (injectBriefCta) escapes per-target and channel footers
should match that discipline. Added:
- Telegram: escape &, <, >, " to HTML entities
- Slack: strip <, >, | (mrkdwn metacharacters)
Discord and plain-text paths unchanged — Discord links tolerate
raw URLs, plain text has no metacharacters to escape.
Tests: 98/98 still pass (deterministic issuedAt change was
transparent to existing assertions because tests already pass nowMs
explicitly via the issuedAt fixture field).
66 lines
3.1 KiB
Docker
66 lines
3.1 KiB
Docker
# =============================================================================
|
|
# Digest notifications cron (consolidated: digest + brief compose + channel send)
|
|
# =============================================================================
|
|
# Runs scripts/seed-digest-notifications.mjs as a Railway cron (every 30 min).
|
|
# The script now also owns the brief envelope write path — per-user
|
|
# brief:{userId}:{issueDate} keys are produced here, and every channel
|
|
# dispatch (email/telegram/slack/discord) gets a signed magazine URL CTA.
|
|
#
|
|
# Historical context: before 2026-04-18 this service built from the
|
|
# scripts/ root with plain `npm ci`. The consolidation PR introduced
|
|
# cross-directory imports (shared/*, server/_shared/*, api/*) so the
|
|
# service now needs repo-root as build context with the specific
|
|
# modules COPY'd in. The retired seed-brief-composer Dockerfile had
|
|
# the same pattern.
|
|
#
|
|
# Required env (Railway service vars):
|
|
# UPSTASH_REDIS_REST_URL
|
|
# UPSTASH_REDIS_REST_TOKEN
|
|
# CONVEX_URL (or CONVEX_SITE_URL)
|
|
# RELAY_SHARED_SECRET
|
|
# RESEND_API_KEY
|
|
# TELEGRAM_BOT_TOKEN
|
|
# BRIEF_URL_SIGNING_SECRET (brief compose disabled without this)
|
|
# WORLDMONITOR_PUBLIC_BASE_URL (defaults to https://worldmonitor.app)
|
|
# Optional:
|
|
# DIGEST_CRON_ENABLED=0 (kill switch for the whole cron)
|
|
# BRIEF_COMPOSE_ENABLED=0 (kill switch for just brief compose)
|
|
# AI_DIGEST_ENABLED=0 (kill switch for AI summary LLM call)
|
|
# =============================================================================
|
|
|
|
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
|
|
|
|
# Digest cron + shared script helpers it imports via createRequire.
|
|
COPY scripts/seed-digest-notifications.mjs ./scripts/
|
|
COPY scripts/_digest-markdown.mjs ./scripts/
|
|
COPY scripts/lib/ ./scripts/lib/
|
|
|
|
# Brief envelope contract + filter + renderer assertion. These live
|
|
# under shared/ and server/_shared/ in the repo and are imported from
|
|
# scripts/lib/brief-compose.mjs. Keep this COPY list tight — adding
|
|
# unrelated shared/* files expands the rebuild watch surface.
|
|
COPY shared/brief-envelope.js shared/brief-envelope.d.ts ./shared/
|
|
COPY shared/brief-filter.js shared/brief-filter.d.ts ./shared/
|
|
COPY server/_shared/brief-render.js server/_shared/brief-render.d.ts ./server/_shared/
|
|
|
|
# Upstash REST helper (brief compose uses redisPipeline + readRawJson).
|
|
COPY api/_upstash-json.js ./api/
|
|
COPY api/_seed-envelope.js ./api/
|
|
|
|
CMD ["node", "scripts/seed-digest-notifications.mjs"]
|