Skip to content

fix(brief): unblock whyMatters analyst endpoint (middleware 403) + DIGEST_ONLY_USER filter#3255

Merged
koala73 merged 2 commits into
mainfrom
fix/brief-why-matters-403-and-user-filter
Apr 21, 2026
Merged

fix(brief): unblock whyMatters analyst endpoint (middleware 403) + DIGEST_ONLY_USER filter#3255
koala73 merged 2 commits into
mainfrom
fix/brief-why-matters-403-and-user-filter

Conversation

@koala73

@koala73 koala73 commented Apr 21, 2026

Copy link
Copy Markdown
Owner

Summary

Three operational changes for the brief whyMatters feature shipped in #3248. All scoped to unblocking and validating that one rollout.

🚨 P0: middleware 403 blocking the feature

Railway logs post-#3248 merge showed every cron call to `/api/internal/brief-why-matters` returning 403:

```
[digest] brief-why-matters endpoint HTTP 403
[digest] brief-why-matters endpoint HTTP 403
[digest] brief-why-matters endpoint HTTP 403
... (repeated ~200x per tick)
```

Root cause: middleware.ts's "No user-agent or suspiciously short — likely a script" guard (~L183) rejects Node undici's default UA. The endpoint's own Bearer-auth (`server/_shared/internal-auth.ts`) never runs. The feature shipped but never executed in prod; the three-layer fallback absorbed it silently and the cron kept producing legacy Gemini output.

Same class as `/api/seed-contract-probe` 2026-04-15 (see the existing comment in `PUBLIC_API_PATHS`).

Changes

1. `middleware.ts` — add to `PUBLIC_API_PATHS` allowlist

Endpoint carries its own subtle-crypto HMAC Bearer-auth, so bypassing the generic UA gate is safe. Comment documents the 2026-04-21 incident so the next person understands why it's there.

2. `scripts/seed-digest-notifications.mjs` — explicit UA on fetch

Defense-in-depth. Sets `User-Agent: worldmonitor-digest-notifications/1.0` + `Accept: application/json` so:

  • A future middleware refactor can't re-break the feature by tightening the allowlist.
  • Log grep distinguishes cron traffic from ops curl / UptimeRobot.

3. `DIGEST_ONLY_USER=user_xxx` filter (feature request)

Operator single-user test flag. Set on the Railway digest-notifications service → next cron tick runs compose + send for exactly that user only → unset. Intended for validating new features end-to-end without fanning out to every PRO user. Empty/unset = normal fan-out (production default).

Applied immediately after `rules` is fetched from Convex, so both the compose and dispatch paths respect the filter. Structured log line `[digest] DIGEST_ONLY_USER=user_xxx — filtered N rules → M` makes it visible in Railway output.

Testing

New middleware tests (`tests/middleware-bot-gate.test.mts`)

15 new cases pin every entry of `PUBLIC_API_PATHS` against 3 UA triggers that would normally 403 (empty, short/undici, curl), plus a negative sibling-path suite:

  • `/api/internal/brief-why-matters-v2` → 403 (near-miss suffix)
  • `/api/internal/` → 403 (directory only)
  • `/api/internal/other` → 403 (different leaf)

Catches any future prefix-match refactor that would silently unblock the whole `/api/internal/` subtree.

Gates

  • `npm run test:data` → 6043 pass (was 6022 + 15 new + a few count tweaks).
  • `typecheck` + `typecheck:api` → clean.
  • `biome` on changed files → only the pre-existing `main()` complexity warning (74 → 78 from the filter block; same character as pre-PR).

Deploy + Validation

Immediate (P0 fix)

Merge → Vercel deploys → next digest cron tick (~30 min) runs the analyst path for real. Confirm with Railway logs:
```bash
railway logs --service digest-notifications --lines 400 | grep -E "whyMatters source=|endpoint HTTP"
```
Expected: `whyMatters source=analyst producedBy=analyst hash=...` lines, zero `endpoint HTTP 403`.

Single-user test (validation feature)

Set `DIGEST_ONLY_USER=user_xxx` on Railway → wait for next cron tick → inspect that user's brief at `/api/brief//`. Unset when done.

Post-Deploy Monitoring & Validation

  • What to monitor: Railway `digest-notifications` logs for `endpoint HTTP 403` (should drop to zero) and the new `whyMatters source=` / `producedBy=` lines (should appear on every cron tick).
  • Cache warmup: `SCAN 0 MATCH brief:llm:whymatters:v3:* COUNT 500` count should start growing after the first successful cron tick post-deploy.
  • Shadow records: `SCAN 0 MATCH brief:llm:whymatters:shadow:v1:* COUNT 500` should start populating (default `BRIEF_WHY_MATTERS_SHADOW=1`, `SAMPLE_PCT=100`).
  • Failure signal: `endpoint HTTP 403` persists after deploy → revert; `parse_reject` rate > 30% → `BRIEF_WHY_MATTERS_PRIMARY=gemini` kill switch.
  • Rollback: revert PR (single commit) OR flip `BRIEF_WHY_MATTERS_PRIMARY=gemini` on Vercel for instant kill without redeploy.
  • Validation window: 2h post-deploy. Owner: koala73.

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

…R filter

Three changes, all operational for PR #3248's brief-why-matters feature.

1. middleware.ts PUBLIC_API_PATHS allowlist
Railway logs post-#3248 merge showed every cron call to
/api/internal/brief-why-matters returning 403 — middleware's "short
UA" guard (~L183) rejects Node undici's default UA before the
endpoint's own Bearer-auth runs. The feature never executed in prod;
three-layer fallback silently shipped legacy Gemini output. Same
class as /api/seed-contract-probe (2026-04-15). Endpoint still
carries its own subtle-crypto HMAC auth, so bypassing the UA gate
is safe.

2. Explicit UA on callAnalystWhyMatters fetch
Defense-in-depth. Explicit 'worldmonitor-digest-notifications/1.0'
keeps the endpoint reachable if PUBLIC_API_PATHS is ever refactored,
and makes cron traffic distinguishable from ops curl in logs.

3. DIGEST_ONLY_USER=user_xxx filter
Operator single-user test flag. Set on Railway to run compose + send
for one user on the next tick (then unset) — validates new features
end-to-end without fanning out. Empty/unset = normal fan-out. Applied
right after rule fetch so both compose and dispatch paths respect it.

Regression tests: 15 new cases in tests/middleware-bot-gate.test.mts
pin every PUBLIC_API_PATHS entry against 3 triggers (empty/short/curl
UA) plus a negative sibling-path suite so a future prefix-match
refactor can't silently unblock /api/internal/.

Tests: 6043 pass. typecheck + typecheck:api clean. biome: pre-existing
main() complexity warning bumped 74→78 by the filter block (unchanged
in character from pre-PR).
@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 3:41pm

Request Review

@greptile-apps

greptile-apps Bot commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR unblocks the brief-why-matters analyst endpoint that shipped in #3248 but never ran in production due to a middleware 403 on Node undici's short default UA. The fix adds the endpoint to PUBLIC_API_PATHS (exact-match, so its own HMAC Bearer auth still enforces access), pairs it with a defense-in-depth explicit UA on the cron fetch, and introduces a DIGEST_ONLY_USER operator flag for single-user validation without a full fan-out. Fifteen new middleware tests pin the entire allowlist against all three UA triggers that would normally 403, plus negative sibling-path cases.

Confidence Score: 5/5

Safe to merge — the P0 fix is minimal and exact-match, the new feature is opt-in via env var, and all remaining findings are P2.

All three changes are well-scoped: the middleware allowlist uses an exact Set lookup so no other /api/internal/ routes are accidentally unblocked, the DIGEST_ONLY_USER filter defaults to no-op (empty env var), and the new tests cover the regression path. The only finding is a P2 coverage gap in the negative sibling-path test suite that doesn't affect correctness.

No files require special attention.

Important Files Changed

Filename Overview
middleware.ts Adds /api/internal/brief-why-matters to the PUBLIC_API_PATHS exact-match allowlist; endpoint retains its own HMAC Bearer auth so bypassing the UA gate is safe. Change is minimal, well-documented, and matched by new tests.
scripts/seed-digest-notifications.mjs Two additions: explicit User-Agent/Accept headers on callAnalystWhyMatters (defense-in-depth) and a new DIGEST_ONLY_USER env-var filter that narrows rules immediately after Convex fetch, so both compose and dispatch paths respect it. Logic is correct and early-exits cleanly when no rules match.
tests/middleware-bot-gate.test.mts 15 new test cases pin every PUBLIC_API_PATHS entry against 3 trigger UAs plus negative sibling-path cases; negative suite only uses empty UA (minor coverage gap, not a correctness issue).

Reviews (1): Last reviewed commit: "fix(brief): unblock whyMatters analyst e..." | Re-trigger Greptile

Comment on lines +186 to +192
for (const path of SIBLING_PATHS) {
it(`${path} does NOT bypass the UA gate (exact-match allowlist)`, () => {
const res = call(path, EMPTY_UA);
assert.ok(res instanceof Response, `${path} must still hit the 403 guard`);
assert.equal(res.status, 403);
});
}

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 Negative-path sibling tests only cover empty UA

The sibling-path negative tests (SIBLING_PATHS) are tested only with EMPTY_UA. The two other trigger UAs (UNDICI_UA and CURL_UA) are exercised only on allowed paths. The PUBLIC_API_PATHS.has(path) check comes before both BOT_UA and the short-UA guard in middleware, so a sibling path with a curl or undici UA would naturally fall through to one of those two checks and still 403 — meaning the current tests aren't wrong. But they don't pin the exact guard that fires, so a future refactor that moves the PUBLIC_API_PATHS check later in the chain could inadvertently pass one of those UAs through without a CI failure on the sibling-path suite.

…r UAs

Greptile flagged: `SIBLING_PATHS` was only tested with `EMPTY_UA`. Under
the current middleware chain this is sufficient (sibling paths hit the
short-UA OR BOT_UA 403 regardless), but it doesn't pin *which* guard
fires. A future refactor that moves `PUBLIC_API_PATHS.has(path)` later
in the chain could let a curl or undici UA pass on a sibling path
without this suite failing.

Fix: iterate the 3 sibling paths against all 3 trigger UAs (empty,
short/undici, curl). Every combination must still 403 regardless of
which guard catches it. 6 new test cases.

Tests: 35 pass in the middleware-bot-gate suite (was 29).
@koala73

koala73 commented Apr 21, 2026

Copy link
Copy Markdown
Owner Author

Addressed Greptile's P2 in 0d215c265: sibling-path negative suite now iterates all 3 trigger UAs (empty, short/undici, curl) — 6 new combinations — so a future middleware refactor that moves the PUBLIC_API_PATHS check later in the chain can't silently let a non-empty UA through on sibling paths. 35 tests pass in the middleware suite (was 29).

1 similar comment
@koala73

koala73 commented Apr 21, 2026

Copy link
Copy Markdown
Owner Author

Addressed Greptile's P2 in 0d215c265: sibling-path negative suite now iterates all 3 trigger UAs (empty, short/undici, curl) — 6 new combinations — so a future middleware refactor that moves the PUBLIC_API_PATHS check later in the chain can't silently let a non-empty UA through on sibling paths. 35 tests pass in the middleware suite (was 29).

@koala73
koala73 merged commit 048bb8b into main Apr 21, 2026
10 checks passed
@koala73
koala73 deleted the fix/brief-why-matters-403-and-user-filter branch April 21, 2026 15:42
koala73 added a commit that referenced this pull request Apr 21, 2026
… 48h cap) (#3271)

* fix(digest): DIGEST_ONLY_USER self-expiring (mandatory until= suffix, 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.

* chore(digest): address /ce:review P2s on DIGEST_ONLY_USER parser

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.
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