Skip to content

fix(digest): DIGEST_ONLY_USER self-expiring (mandatory until= suffix, 48h cap)#3271

Merged
koala73 merged 2 commits into
mainfrom
fix/digest-only-user-expiry
Apr 21, 2026
Merged

fix(digest): DIGEST_ONLY_USER self-expiring (mandatory until= suffix, 48h cap)#3271
koala73 merged 2 commits into
mainfrom
fix/digest-only-user-expiry

Conversation

@koala73

@koala73 koala73 commented Apr 21, 2026

Copy link
Copy Markdown
Owner

Summary

Addresses a P2 review finding on #3255: `DIGEST_ONLY_USER` as shipped was a sticky production footgun. If an operator set it for a one-off validation and forgot to unset it, the cron silently filtered out every other user indefinitely while still completing normally and exiting 0 — a prolonged partial outage with "green" runs.

Fix

Mandatory expiry. The env var MUST now be in the form:

```
DIGEST_ONLY_USER=user_xxx|until=2026-04-22T18:00Z
```

Rules:

  • Expiry must be a parseable ISO8601 timestamp.
  • Expiry must be in the future.
  • Expiry must be within 48 hours of now (hard cap prevents the "forever test" mistake).
  • Anything that doesn't match → IGNORED with a loud `console.warn` explaining the new format. Cron proceeds with normal fan-out.
  • When active: loud `console.warn` at run start listing `userId`, expiry, and remaining minutes.

This means a test flag cannot sit in Railway env forever — it expires on its own, and even a misconfigured value fails safe (fan out, don't silently exclude).

Breaking change to the flag format, but the flag shipped ~2h before this finding with no prod usage — cheaper to tighten now than after an incident.

Changes

  • NEW `scripts/lib/digest-only-user.mjs` — pure parser returning `{kind: 'unset'} | {kind: 'active', ...} | {kind: 'reject', reason}`. Extracted so it's testable without importing `seed-digest-notifications.mjs` (no isMain guard there).
  • MODIFY `scripts/seed-digest-notifications.mjs` — use the parser; loud warn on all branches.
  • NEW `tests/digest-only-user.test.mjs` — 17 cases pinning the accept/reject boundary.

Testing

6066 tests pass (was 6049 + 17 new). typecheck + typecheck:api clean.

Coverage:

Post-Deploy Monitoring & Validation

  • When you next want to test single-user delivery, set:
    ```bash
    railway variables --set DIGEST_ONLY_USER=user_3BovQ1tYlaz2YIGYAdDPXGFBgKy\|until=$(date -u -v+2H +%Y-%m-%dT%H:%MZ)
    ```
    (2h from now). Next cron tick runs for you only; 2h later auto-disables.
  • What to see in Railway logs: the `⚠️ [digest] DIGEST_ONLY_USER ACTIVE — filtering to userId=...` warn line at each tick.
  • Failure mode test: set any invalid value (e.g. `DIGEST_ONLY_USER=foo`) and the next tick logs `[digest] DIGEST_ONLY_USER present but IGNORED: ...` and fans out normally. No partial outage possible.

Compound Engineering v2.49.0
🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via Claude Code

… 48h cap)

Review finding on PR #3255: DIGEST_ONLY_USER was a sticky production
footgun. If an operator forgot to unset after a one-off validation,
the cron silently filtered every other user out indefinitely while
still completing normally (exit 0) — prolonged partial outage with
"green" runs.

Fix: mandatory `|until=<ISO8601>` suffix within 48h of now. Otherwise
the flag is IGNORED with a loud warn, fan-out proceeds normally.
Active filter emits a structured console.warn at run start listing
expiry + remaining minutes.

Valid:
  DIGEST_ONLY_USER=user_xxx|until=2026-04-22T18:00Z

Rejected (→ loud warn, normal fan-out):
- Legacy bare `user_xxx` (missing required suffix)
- Unparseable ISO
- Expiry > 48h (forever-test mistake)
- Expiry in past (auto-disable)

Parser extracted to `scripts/lib/digest-only-user.mjs` (testable
without importing seed-digest-notifications.mjs which has no isMain
guard).

Tests: 17 cases covering unset / reject / active branches, ISO
variants, boundaries, and the 48h cap. 6066 total pass. typecheck × 2
clean.

Breaking change on the flag's format, but it shipped 2h before this
finding with no prod usage — tightening now is cheaper than after
an incident.
@vercel

vercel Bot commented Apr 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Apr 21, 2026 6:31pm

Request Review

@greptile-apps

greptile-apps Bot commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces the bare DIGEST_ONLY_USER=<userId> env flag introduced in #3255 with a self-expiring format (<userId>|until=<ISO8601>) enforced by a pure, testable parser. Any value that lacks the suffix, is malformed, expired, or exceeds the 48 h hard cap is silently rejected and the cron fans out normally — closing the sticky partial-outage footgun. The parser is extracted into scripts/lib/digest-only-user.mjs and covered by 17 regression tests pinning every accept/reject boundary.

Confidence Score: 5/5

Safe to merge — all remaining findings are P2 style suggestions with no correctness or reliability impact.

The core logic (parse → filter → warn → fan-out) is correct and well-tested. The 48 h hard cap ensures both P2 findings (lenient Date.parse, misleading multi-pipe message) cannot cause a silent partial outage or incorrect filtering. No P0/P1 issues found.

scripts/lib/digest-only-user.mjs — minor Date.parse leniency and multi-pipe error message (both P2)

Important Files Changed

Filename Overview
scripts/lib/digest-only-user.mjs New pure parser module: correctly enforces userId
scripts/seed-digest-notifications.mjs Integrates the parser cleanly: nowMs captured once at run start, filter applied before composeBriefsForRun so brief composition is also scoped to the test user, reject path fans out normally with loud warn, unset path is silent — all correct.
tests/digest-only-user.test.mjs 17 tests covering all boundary conditions: unset, reject (bare userId, no suffix, wrong suffix, bad ISO, past, equals-now, >48h, year-out, multi-pipe), and active (30m, 48h boundary, 3 ISO variants, whitespace tolerance). Fixed NOW anchor matches PR description.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[cron tick starts\nnowMs = Date.now] --> B[fetch all digest rules]
    B --> C[parseDigestOnlyUser\nenv var, nowMs]
    C --> D{kind?}
    D -- unset --> E[normal fan-out\nno log]
    D -- reject --> F[console.warn IGNORED\n+ reason + format hint]
    F --> E
    D -- active --> G[console.warn ACTIVE\nuserId + expiry + remainingMin]
    G --> H[filter rules to\nonlyUser.userId]
    H --> I{rules.length === 0?}
    I -- yes --> J[warn no rules matched\nreturn / exit 0]
    I -- no --> K[filtered fan-out]
    E --> L[composeBriefsForRun\nall rules]
    K --> M[composeBriefsForRun\nfiltered rules]
    L --> N[digest send loop\nall users]
    M --> O[digest send loop\ntest user only]
Loading

Reviews (1): Last reviewed commit: "fix(digest): DIGEST_ONLY_USER self-expir..." | Re-trigger Greptile

Comment on lines +45 to +50
const untilMs = Date.parse(untilRaw);
if (!Number.isFinite(untilMs)) {
return {
kind: 'reject',
reason: `expiry "${untilRaw}" is not a parseable ISO8601 timestamp`,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Date.parse accepts far more than ISO 8601

Date.parse is intentionally permissive in V8 — it accepts RFC 2822 strings, locale-formatted dates, and other engine-specific formats (e.g. "April 22, 2026 18:00", "22 Apr 2026 18:00:00 GMT"). The error message says "not a parseable ISO8601 timestamp" but the check doesn't actually enforce ISO 8601 — it only checks for NaN. The 48 h hard cap prevents a non-ISO future timestamp from surviving, so this is not a reliability bug today, but the validation is looser than the documented contract.

Consider using a light regex guard before Date.parse to reject obvious non-ISO inputs:

// Reject anything that doesn't start with a 4-digit year + '-' (rough ISO guard)
if (!/^\d{4}-/.test(untilRaw)) {
  return { kind: 'reject', reason: `expiry "${untilRaw}" is not a parseable ISO8601 timestamp` };
}
const untilMs = Date.parse(untilRaw);

Comment on lines +29 to +34
if (parts.length !== 2) {
return {
kind: 'reject',
reason: 'missing mandatory "|until=<ISO8601>" suffix',
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Misleading rejection reason for multi-pipe input

When parts.length > 2 (e.g. user_xxx|until=2026-04-22T18:00Z|extra), the returned reason is 'missing mandatory "|until=<ISO8601>" suffix' — which points the operator toward adding a suffix that is already present. A confused operator might try user_xxx|until=...|until=....

Suggested change
if (parts.length !== 2) {
return {
kind: 'reject',
reason: 'missing mandatory "|until=<ISO8601>" suffix',
};
}
if (parts.length !== 2) {
return {
kind: 'reject',
reason: parts.length === 1
? 'missing mandatory "|until=<ISO8601>" suffix'
: `expected exactly one "|" separator, got ${parts.length - 1}`,
};
}

Two style fixes flagged by Greptile on PR #3271:

1. Misleading multi-pipe error message.
   `user_xxx|until=<iso>|extra` returned "missing mandatory suffix",
   which points the operator toward adding a suffix that is already
   present (confused operator might try `user_xxx|until=...|until=...`).
   Now distinguishes parts.length===1 ("missing suffix") from >2
   ("expected exactly one '|' separator, got N").

2. Date.parse is lenient — accepts RFC 2822, locale strings, "April 22".
   The documented contract is strict ISO 8601; the 48h cap catches
   accidental-valid dates but the documentation lied. Added a regex
   guard up-front that enforces the ISO 8601 shape
   (YYYY-MM-DD optionally followed by time + TZ). Rejects the 6
   Date-parseable-but-not-ISO fixtures before Date.parse runs.

Both regressions pinned in tests/digest-only-user.test.mjs (18 pass,
was 17). typecheck × 2 clean.
@koala73

koala73 commented Apr 21, 2026

Copy link
Copy Markdown
Owner Author

Addressed both Greptile P2s in 2db3f99c8:

  1. Misleading multi-pipe message — distinguished parts.length === 1 ("missing suffix") from > 2 ("expected exactly one | separator, got N"). A confused operator with user_xxx|until=...|extra no longer gets pointed toward adding a suffix that's already present.

  2. Date.parse leniency — added a shape regex /^\\d{4}-\\d{2}-\\d{2}.../ before Date.parse so "April 22, 2026 18:00" / RFC 2822 / locale dates are rejected up front, not silently tolerated and then caught by the 48h cap.

Tests: +1 new (covers 6 non-ISO shapes that V8 Date.parse would have accepted). 18 pass, was 17.

@koala73
koala73 merged commit e878bae into main Apr 21, 2026
10 checks passed
@koala73
koala73 deleted the fix/digest-only-user-expiry branch April 21, 2026 18:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant