Fetch AIS signals from API instead of hardcoded data#6
Merged
koala73 merged 2 commits intoJan 10, 2026
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
- Rewrote AIS service to use aisstream.io free WebSocket API - Real-time vessel position tracking with in-memory aggregation - Density zone calculation from 2-degree grid cells - Chokepoint congestion detection (Hormuz, Suez, Malacca, etc.) - Dark ship detection via AIS gap analysis - Graceful handling when no API key is configured - Requires VITE_AISSTREAM_API_KEY env var for live data Get free API key at https://aisstream.io
koala73
deleted the
codex/add-ais-disruption-and-traffic-density-feature
branch
January 12, 2026 18:47
EleCor79
added a commit
to EleCor79/BehavioralHealthPulse
that referenced
this pull request
Mar 2, 2026
Distribute all 32 feeds from feeds-health-italy-eu.csv into the FEEDS config consumed by loadNews() / fetchCategoryFeeds(): - ministero-salute: +MinSalute News Specifiche (CSV koala73#18) - iss-epicentro: +ISS Notizie (CSV koala73#3), +Epicentro Coronavirus (CSV koala73#17) - aifa-tracker: +AIFA Feed (CSV koala73#5), +EMA News (CSV koala73#8), +FDA (CSV koala73#12) - agenas-ospedali: +AGENAS RSS (CSV koala73#4), +PNRR (CSV koala73#6/koala73#19), +Lombardia (CSV koala73#20), +Lazio (CSV koala73#21) - ema-europa: +EMA Clinical Trials (CSV koala73#22) - ecdc-sorveglianza:+ECDC Weekly Threats (CSV koala73#23), +WHO DON (CSV koala73#9), +ProMED (CSV koala73#10) - live-news: +Sanitainformazione (CSV koala73#24), +Humanitas (CSV koala73#26) - europe: +EU Core Health Indicators (CSV koala73#31), +GLOBSEC HRI (CSV koala73#32), +ISTAT (CSV koala73#13), +EIN Health Europe (CSV koala73#29) - rare-diseases: NEW category — EURORDIS (CSV koala73#14), Orphanet IT (CSV koala73#15), Telethon (CSV koala73#16), CDC FluView (CSV koala73#11) Panel disabled by default, available in settings Co-Authored-By: Claude Opus 4.6 <[email protected]>
bradleybond512
referenced
this pull request
in bradleybond512/worldmonitor-macos
Mar 2, 2026
Closes task #6 (phase 1 immersion). The cable sine-wave pulse was already wired; this adds position-history trail rendering for military flights and vessels. - createMilitaryFlightTrailsLayer: splits each flight.track into per-segment PathLayer data with alpha decaying from 15 (oldest) to 105 (leading edge) in red; zoom-gated at ≥ 3.5 - createMilitaryVesselTrailsLayer: same pattern, orange tint, max alpha 95; zoom-gated at ≥ 3.5 - Trails inserted before dot-marker layers in buildLayers() so they render beneath current-position markers - pickable: false on both trail layers (no tooltip noise) - Up to 20 historical positions per entity (tracked in military-flights service) → max ~950 segments at 50 active contacts Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
gefa-sc
pushed a commit
to gefa-sc/worldmonitor
that referenced
this pull request
Mar 2, 2026
…timization Add service layer with caching, circuit breakers, and Cloudflare Worker proxy
20 tasks
koala73
added a commit
that referenced
this pull request
Mar 7, 2026
Phase 1 — Client-only fixes: - Remove predictions double getHydratedData read from data-loader.ts; fetchPredictions() handles hydration internally - Fix UCDP delete-on-read race: read hydratedUcdp once in data-loader, pass to both fetchUcdpClassifications() and fetchUcdpEvents() Phase 2 — Batch RPCs (proto + server + client): - Add GetHumanitarianSummaryBatch RPC: replaces 20-request HAPI fanout with single batch call (getCachedJsonBatch + per-key Redis caching) - Add GetFredSeriesBatch RPC: replaces 7-request FRED fanout with single batch call (same pattern) - Both batch RPCs have 404 deploy-skew fallback to per-item calls Phase 3 — Seed gap: - Add seed-service-statuses.mjs standalone seed script - Add 15-min warm-ping loop in AIS relay for service statuses - Remove serviceStatuses from ON_DEMAND_KEYS in health.js Net savings: up to 28 edge calls eliminated on cold miss per page load.
koala73
added a commit
that referenced
this pull request
Mar 7, 2026
* perf(edge): reduce unnecessary Vercel edge invocations (#6 findings) Phase 1 — Client-only fixes: - Remove predictions double getHydratedData read from data-loader.ts; fetchPredictions() handles hydration internally - Fix UCDP delete-on-read race: read hydratedUcdp once in data-loader, pass to both fetchUcdpClassifications() and fetchUcdpEvents() Phase 2 — Batch RPCs (proto + server + client): - Add GetHumanitarianSummaryBatch RPC: replaces 20-request HAPI fanout with single batch call (getCachedJsonBatch + per-key Redis caching) - Add GetFredSeriesBatch RPC: replaces 7-request FRED fanout with single batch call (same pattern) - Both batch RPCs have 404 deploy-skew fallback to per-item calls Phase 3 — Seed gap: - Add seed-service-statuses.mjs standalone seed script - Add 15-min warm-ping loop in AIS relay for service statuses - Remove serviceStatuses from ON_DEMAND_KEYS in health.js Net savings: up to 28 edge calls eliminated on cold miss per page load. * fix(edge): address code review findings (P1–P3) P1: Fix dead 404 deploy-skew fallback — circuit breaker was swallowing the ApiError before the catch block could detect it. Move 404 fallback inside the breaker callback so it executes before the breaker catches. P2: Replace 172-line seed-service-statuses.mjs (duplicated parser logic) with a 60-line warm-ping that triggers the existing RPC handler. P2: Extract shared ISO2_TO_ISO3 mapping to conflict/v1/_shared.ts, eliminating duplication between single and batch HAPI handlers. P3: Remove unnecessary UPSTASH_ENABLED guard from relay warm-ping (it calls Vercel RPC, not Redis directly). P3: Clean up unused per-series FRED breakers and fetchSingleFredSeries (replaced by batch breaker). Update getFredStatus() accordingly. * fix(edge): use concurrent fetches in batch handlers HAPI batch: replace serial loop with groups of 5 concurrent fetches using Promise.allSettled for partial-success resilience. Bump client timeout to 60s (4 rounds × 15s upstream timeout worst case). FRED batch: replace serial loop with fully parallel Promise.allSettled (max 10 series, each hits separate FRED endpoint). Both changes prevent empty-result regression on cold cache that the serial approach caused when upstream latency exceeded the client timeout.
This was referenced Mar 9, 2026
aldoyh
pushed a commit
to aldoyh/worldmonitor
that referenced
this pull request
Mar 15, 2026
* perf(edge): reduce unnecessary Vercel edge invocations (koala73#6 findings) Phase 1 — Client-only fixes: - Remove predictions double getHydratedData read from data-loader.ts; fetchPredictions() handles hydration internally - Fix UCDP delete-on-read race: read hydratedUcdp once in data-loader, pass to both fetchUcdpClassifications() and fetchUcdpEvents() Phase 2 — Batch RPCs (proto + server + client): - Add GetHumanitarianSummaryBatch RPC: replaces 20-request HAPI fanout with single batch call (getCachedJsonBatch + per-key Redis caching) - Add GetFredSeriesBatch RPC: replaces 7-request FRED fanout with single batch call (same pattern) - Both batch RPCs have 404 deploy-skew fallback to per-item calls Phase 3 — Seed gap: - Add seed-service-statuses.mjs standalone seed script - Add 15-min warm-ping loop in AIS relay for service statuses - Remove serviceStatuses from ON_DEMAND_KEYS in health.js Net savings: up to 28 edge calls eliminated on cold miss per page load. * fix(edge): address code review findings (P1–P3) P1: Fix dead 404 deploy-skew fallback — circuit breaker was swallowing the ApiError before the catch block could detect it. Move 404 fallback inside the breaker callback so it executes before the breaker catches. P2: Replace 172-line seed-service-statuses.mjs (duplicated parser logic) with a 60-line warm-ping that triggers the existing RPC handler. P2: Extract shared ISO2_TO_ISO3 mapping to conflict/v1/_shared.ts, eliminating duplication between single and batch HAPI handlers. P3: Remove unnecessary UPSTASH_ENABLED guard from relay warm-ping (it calls Vercel RPC, not Redis directly). P3: Clean up unused per-series FRED breakers and fetchSingleFredSeries (replaced by batch breaker). Update getFredStatus() accordingly. * fix(edge): use concurrent fetches in batch handlers HAPI batch: replace serial loop with groups of 5 concurrent fetches using Promise.allSettled for partial-success resilience. Bump client timeout to 60s (4 rounds × 15s upstream timeout worst case). FRED batch: replace serial loop with fully parallel Promise.allSettled (max 10 series, each hits separate FRED endpoint). Both changes prevent empty-result regression on cold cache that the serial approach caused when upstream latency exceeded the client timeout.
3 tasks
15 tasks
SebastienMelki
added a commit
that referenced
this pull request
Apr 21, 2026
Closes out the remaining @koala73 review findings from #3242 that didn't already land in the HIGH-fix commits, plus the requested CI check that would have caught HIGH #1 (dead-code policy key) at review time. ### MEDIUM #5 — Turnstile missing-secret policy default Flip `verifyTurnstile`'s default `missingSecretPolicy` from `'allow'` to `'allow-in-development'`. Dev with no secret = pass (expected local); prod with no secret = reject + log. submit-contact was already explicitly overriding to `'allow-in-development'`; register-interest was silently getting `'allow'`. Safe default now means a future missing-secret misconfiguration in prod gets caught instead of silently letting bots through. Removed the now-redundant override in submit-contact. ### MEDIUM #6 — Silent enum fallbacks in maritime client `toDisruptionEvent` mapped `AIS_DISRUPTION_TYPE_UNSPECIFIED` / unknown enum values → `gap_spike` / `low` silently. Refactored to return null when either enum is unknown; caller filters nulls out of the array. Handler doesn't produce UNSPECIFIED today, but the `gap_spike` default would have mislabeled the first new enum value the proto ever adds — dropping unknowns is safer than shipping wrong labels. ### LOW — Copy drift in register-interest email Email template hardcoded `435+ Sources`; PR #3241 bumped marketing to `500+`. Bumped in the rewritten file to stay consistent. The `as any` on Convex mutation names carried over from legacy and filed as follow-up #3253. ### Rate-limit-policy coverage lint `scripts/enforce-rate-limit-policies.mjs` validates every key in `ENDPOINT_RATE_POLICIES` resolves to a proto-generated gateway route by cross-referencing `docs/api/*.openapi.yaml`. Fails with the sanctions-entity-search incident referenced in the error message so future drift has a paper trail. Wired into package.json (`lint:rate-limit-policies`) and the pre-push hook alongside `lint:boundaries`. Smoke-tested both directions — clean repo passes (5 policies / 175 routes), seeded drift (the exact HIGH #1 typo) fails with the advertised remedy text. ### Verified - `lint:rate-limit-policies` ✓ - `typecheck` + `typecheck:api` ✓ - `lint:api-contract` ✓ (56 entries) - `lint:boundaries` ✓ - edge-functions + contact-handler tests (147 pass) Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
7 tasks
SebastienMelki
added a commit
that referenced
this pull request
Apr 22, 2026
… (#3242) * chore(api): enforce sebuf contract via exceptions manifest (#3207) Adds api/api-route-exceptions.json as the single source of truth for non-proto /api/ endpoints, with scripts/enforce-sebuf-api-contract.mjs gating every PR via npm run lint:api-contract. Fixes the root-only blind spot in the prior allowlist (tests/edge-functions.test.mjs), which only scanned top-level *.js files and missed nested paths and .ts endpoints — the gap that let api/supply-chain/v1/country-products.ts and friends drift under proto domain URL prefixes unchallenged. Checks both directions: every api/<domain>/v<N>/[rpc].ts must pair with a generated service_server.ts (so a deleted proto fails CI), and every generated service must have an HTTP gateway (no orphaned generated code). Manifest entries require category + reason + owner, with removal_issue mandatory for temporary categories (deferred, migration-pending) and forbidden for permanent ones. .github/CODEOWNERS pins the manifest to @SebastienMelki so new exceptions don't slip through review. The manifest only shrinks: migration-pending entries (19 today) will be removed as subsequent commits in this PR land each migration. * refactor(maritime): migrate /api/ais-snapshot → maritime/v1.GetVesselSnapshot (#3207) The proto VesselSnapshot was carrying density + disruptions but the frontend also needed sequence, relay status, and candidate_reports to drive the position-callback system. Those only lived on the raw relay passthrough, so the client had to keep hitting /api/ais-snapshot whenever callbacks were registered and fall back to the proto RPC only when the relay URL was gone. This commit pushes all three missing fields through the proto contract and collapses the dual-fetch-path into one proto client call. Proto changes (proto/worldmonitor/maritime/v1/): - VesselSnapshot gains sequence, status, candidate_reports. - GetVesselSnapshotRequest gains include_candidates (query: include_candidates). Handler (server/worldmonitor/maritime/v1/get-vessel-snapshot.ts): - Forwards include_candidates to ?candidates=... on the relay. - Separate 5-min in-memory caches for the candidates=on and candidates=off variants; they have very different payload sizes and should not share a slot. - Per-request in-flight dedup preserved per-variant. Frontend (src/services/maritime/index.ts): - fetchSnapshotPayload now calls MaritimeServiceClient.getVesselSnapshot directly with includeCandidates threaded through. The raw-relay path, SNAPSHOT_PROXY_URL, DIRECT_RAILWAY_SNAPSHOT_URL and LOCAL_SNAPSHOT_FALLBACK are gone — production already routed via Vercel, the "direct" branch only ever fired on localhost, and the proto gateway covers both. - New toLegacyCandidateReport helper mirrors toDensityZone/toDisruptionEvent. api/ais-snapshot.js deleted; manifest entry removed. Only reduced the codegen scope to worldmonitor.maritime.v1 (buf generate --path) — regenerating the full tree drops // @ts-nocheck from every client/server file and surfaces pre-existing type errors across 30+ unrelated services, which is not in scope for this PR. Shape-diff vs legacy payload: - disruptions / density: proto carries the same fields, just with the GeoCoordinates wrapper and enum strings (remapped client-side via existing toDisruptionEvent / toDensityZone helpers). - sequence, status.{connected,vessels,messages}: now populated from the proto response — was hardcoded to 0/false in the prior proto fallback. - candidateReports: same shape; optional numeric fields come through as 0 instead of undefined, which the legacy consumer already handled. * refactor(sanctions): migrate /api/sanctions-entity-search → LookupSanctionEntity (#3207) The proto docstring already claimed "OFAC + OpenSanctions" coverage but the handler only fuzzy-matched a local OFAC Redis index — narrower than the legacy /api/sanctions-entity-search, which proxied OpenSanctions live (the source advertised in docs/api-proxies.mdx). Deleting the legacy without expanding the handler would have been a silent coverage regression for external consumers. Handler changes (server/worldmonitor/sanctions/v1/lookup-entity.ts): - Primary path: live search against api.opensanctions.org/search/default with an 8s timeout and the same User-Agent the legacy edge fn used. - Fallback path: the existing OFAC local fuzzy match, kept intact for when OpenSanctions is unreachable / rate-limiting. - Response source field flips between 'opensanctions' (happy path) and 'ofac' (fallback) so clients can tell which index answered. - Query validation tightened: rejects q > 200 chars (matches legacy cap). Rate limiting: - Added /api/sanctions/v1/lookup-entity to ENDPOINT_RATE_POLICIES at 30/min per IP — matches the legacy createIpRateLimiter budget. The gateway already enforces per-endpoint policies via checkEndpointRateLimit. Docs: - docs/api-proxies.mdx — dropped the /api/sanctions-entity-search row (plus the orphaned /api/ais-snapshot row left over from the previous commit in this PR). - docs/panels/sanctions-pressure.mdx — points at the new RPC URL and describes the OpenSanctions-primary / OFAC-fallback semantics. api/sanctions-entity-search.js deleted; manifest entry removed. * refactor(military): migrate /api/military-flights → ListMilitaryFlights (#3207) Legacy /api/military-flights read a pre-baked Redis blob written by the seed-military-flights cron and returned flights in a flat app-friendly shape (lat/lon, lowercase enums, lastSeenMs). The proto RPC takes a bbox, fetches OpenSky live, classifies server-side, and returns nested GeoCoordinates + MILITARY_*_TYPE_* enum strings + lastSeenAt — same data, different contract. fetchFromRedis in src/services/military-flights.ts was doing nothing sebuf-aware. Renamed it to fetchViaProto and rewrote to: - Instantiate MilitaryServiceClient against getRpcBaseUrl(). - Iterate MILITARY_QUERY_REGIONS (PACIFIC + WESTERN) in parallel — same regions the desktop OpenSky path and the seed cron already use, so dashboard coverage tracks the analytic pipeline. - Dedup by hexCode across regions. - Map proto → app shape via new mapProtoFlight helper plus three reverse enum maps (AIRCRAFT_TYPE_REVERSE, OPERATOR_REVERSE, CONFIDENCE_REVERSE). The seed cron (scripts/seed-military-flights.mjs) stays put: it feeds regional-snapshot mobility, cross-source signals, correlation, and the health freshness check (api/health.js: 'military:flights:v1'). None of those read the legacy HTTP endpoint; they read the Redis key directly. The proto handler uses its own per-bbox cache keys under the same prefix, so dashboard traffic no longer races the seed cron's blob — the two paths diverge by a small refresh lag, which is acceptable. Docs: dropped the /api/military-flights row from docs/api-proxies.mdx. api/military-flights.js deleted; manifest entry removed. Shape-diff vs legacy: - f.location.{latitude,longitude} → f.lat, f.lon - f.aircraftType: MILITARY_AIRCRAFT_TYPE_TANKER → 'tanker' via reverse map - f.operator: MILITARY_OPERATOR_USAF → 'usaf' via reverse map - f.confidence: MILITARY_CONFIDENCE_LOW → 'low' via reverse map - f.lastSeenAt (number) → f.lastSeen (Date) - f.enrichment → f.enriched (with field renames) - Extra fields registration / aircraftModel / origin / destination / firstSeenAt now flow through where proto populates them. * fix(supply-chain): thread includeCandidates through chokepoint status (#3207) Caught by tsconfig.api.json typecheck in the pre-push hook (not covered by the plain tsc --noEmit run that ran before I pushed the ais-snapshot commit). The chokepoint status handler calls getVesselSnapshot internally with a static no-auth request — now required to include the new includeCandidates bool from the proto extension. Passing false: server-internal callers don't need per-vessel reports. * test(maritime): update getVesselSnapshot cache assertions (#3207) The ais-snapshot migration replaced the single cachedSnapshot/cacheTimestamp pair with a per-variant cache so candidates-on and candidates-off payloads don't evict each other. Pre-push hook surfaced that tests/server-handlers still asserted the old variable names. Rewriting the assertions to match the new shape while preserving the invariants they actually guard: - Freshness check against slot TTL. - Cache read before relay call. - Per-slot in-flight dedup. - Stale-serve on relay failure (result ?? slot.snapshot). * chore(proto): restore // @ts-nocheck on regenerated maritime files (#3207) I ran 'buf generate --path worldmonitor/maritime/v1' to scope the proto regen to the one service I was changing (to avoid the toolchain drift that drops @ts-nocheck from 60+ unrelated files — separate issue). But the repo convention is the 'make generate' target, which runs buf and then sed-prepends '// @ts-nocheck' to every generated .ts file. My scoped command skipped the sed step. The proto-check CI enforces the sed output, so the two maritime files need the directive restored. * refactor(enrichment): decomm /api/enrichment/{company,signals} legacy edge fns (#3207) Both endpoints were already ported to IntelligenceService: - getCompanyEnrichment (/api/intelligence/v1/get-company-enrichment) - listCompanySignals (/api/intelligence/v1/list-company-signals) No frontend callers of the legacy /api/enrichment/* paths exist. Removes: - api/enrichment/company.js, signals.js, _domain.js - api-route-exceptions.json migration-pending entries (58 remain) - docs/api-proxies.mdx rows for /api/enrichment/{company,signals} - docs/architecture.mdx reference updated to the IntelligenceService RPCs Verified: typecheck, typecheck:api, lint:api-contract (89 files / 58 entries), lint:boundaries, tests/edge-functions.test.mjs (136 pass), tests/enrichment-caching.test.mjs (14 pass — still guards the intelligence/v1 handlers), make generate is zero-diff. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * refactor(leads): migrate /api/{contact,register-interest} → LeadsService (#3207) New leads/v1 sebuf service with two POST RPCs: - SubmitContact → /api/leads/v1/submit-contact - RegisterInterest → /api/leads/v1/register-interest Handler logic ported 1:1 from api/contact.js + api/register-interest.js: - Turnstile verification (desktop sources bypass, preserved) - Honeypot (website field) silently accepts without upstream calls - Free-email-domain gate on SubmitContact (422 ApiError) - validateEmail (disposable/offensive/typo-TLD/MX) on RegisterInterest - Convex writes via ConvexHttpClient (contactMessages:submit, registerInterest:register) - Resend notification + confirmation emails (HTML templates unchanged) Shared helpers moved to server/_shared/: - turnstile.ts (getClientIp + verifyTurnstile) - email-validation.ts (disposable/offensive/MX checks) Rate limits preserved via ENDPOINT_RATE_POLICIES: - submit-contact: 3/hour per IP (was in-memory 3/hr) - register-interest: 5/hour per IP (was in-memory 5/hr; desktop sources previously capped at 2/hr via shared in-memory map — now 5/hr like everyone else, accepting the small regression in exchange for Upstash-backed global limiting) Callers updated: - pro-test/src/App.tsx contact form → new submit-contact path - src-tauri/sidecar/local-api-server.mjs cloud-fallback rewrites /api/register-interest → /api/leads/v1/register-interest when proxying; keeps local path for older desktop builds - src/services/runtime.ts isKeyFreeApiTarget allows both old and new paths through the WORLDMONITOR_API_KEY-optional gate Tests: - tests/contact-handler.test.mjs rewritten to call submitContact handler directly; asserts on ValidationError / ApiError - tests/email-validation.test.mjs + tests/turnstile.test.mjs point at the new server/_shared/ modules Deleted: api/contact.js, api/register-interest.js, api/_ip-rate-limit.js, api/_turnstile.js, api/_email-validation.js, api/_turnstile.test.mjs. Manifest entries removed (58 → 56). Docs updated (api-platform, api-commerce, usage-rate-limits). Verified: npm run typecheck + typecheck:api + lint:api-contract (88 files / 56 entries) + lint:boundaries pass; full test:data (5852 tests) passes; make generate is zero-diff. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * chore(pro-test): rebuild bundle for leads/v1 contact form (#3207) Updates the enterprise contact form to POST to /api/leads/v1/submit-contact (old path /api/contact removed in the previous commit). Bundle is rebuilt from pro-test/src/App.tsx source change in 9ccd309. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix(review): address HIGH review findings 1-3 (#3207) Three review findings from @koala73 on the sebuf-migration PR, all silent bugs that would have shipped to prod: ### 1. Sanctions rate-limit policy was dead code ENDPOINT_RATE_POLICIES keyed the 30/min budget under /api/sanctions/v1/lookup-entity, but the generated route (from the proto RPC LookupSanctionEntity) is /api/sanctions/v1/lookup-sanction-entity. hasEndpointRatePolicy / getEndpointRatelimit are exact-string pathname lookups, so the mismatch meant the endpoint fell through to the generic 600/min global limiter instead of the advertised 30/min. Net effect: the live OpenSanctions proxy endpoint (unauthenticated, external upstream) had 20x the intended rate budget. Fixed by renaming the policy key to match the generated route. ### 2. Lost stale-seed fallback on military-flights Legacy api/military-flights.js cascaded military:flights:v1 → military:flights:stale:v1 before returning empty. The new proto handler went straight to live OpenSky/relay and returned null on miss. Relay or OpenSky hiccup used to serve stale seeded data (24h TTL); under the new handler it showed an empty map. Both keys are still written by scripts/seed-military-flights.mjs on every run — fix just reads the stale key when the live fetch returns null, converts the seed's app-shape flights (flat lat/lon, lowercase enums, lastSeenMs) to the proto shape (nested GeoCoordinates, enum strings, lastSeenAt), and filters to the request bbox. Read via getRawJson (unprefixed) to match the seed cron's writes, which bypass the env-prefix system. ### 3. Hex-code casing mismatch broke getFlightByHex The seed cron writes hexCode: icao24.toUpperCase() (uppercase); src/services/military-flights.ts:getFlightByHex uppercases the lookup input: f.hexCode === hexCode.toUpperCase(). The new proto handler preserved OpenSky's lowercase icao24, and mapProtoFlight is a pass-through. getFlightByHex was silently returning undefined for every call after the migration. Fix: uppercase in the proto handler (live + stale paths), and document the invariant in a comment on MilitaryFlight.hex_code in military_flight.proto so future handlers don't re-break it. ### Verified - typecheck + typecheck:api clean - lint:api-contract (56 entries) / lint:boundaries clean - tests/edge-functions.test.mjs 130 pass - make generate zero-diff (openapi spec regenerated for proto comment) Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix(review): restore desktop 2/hr rate cap on register-interest (#3207) Addresses HIGH review finding #4 from @koala73. The legacy api/register-interest.js applied a nested 2/hr per-IP cap when `source === 'desktop-settings'`, on top of the generic 5/hr endpoint budget. The sebuf migration lost this — desktop-source requests now enjoy the full 5/hr cap. Since `source` is an unsigned client-supplied field, anyone sending `source: 'desktop-settings'` skips Turnstile AND gets 5/hr. Without the tighter cap the Turnstile bypass is cheaper to abuse. Added `checkScopedRateLimit` to `server/_shared/rate-limit.ts` — a reusable second-stage Upstash limiter keyed on an opaque scope string + caller identifier. Fail-open on Redis errors to match existing checkRateLimit / checkEndpointRateLimit semantics. Handlers that need per-subscope caps on top of the gateway-level endpoint budget use this helper. In register-interest: when `isDesktopSource`, call checkScopedRateLimit with scope `/api/leads/v1/register-interest#desktop`, limit=2, window=1h, IP as identifier. On exceeded → throw ApiError(429). ### What this does not fix This caps the blast radius of the Turnstile bypass but does not close it — an attacker sending `source: 'desktop-settings'` still skips Turnstile (just at 2/hr instead of 5/hr). The proper fix is a signed desktop-secret header that authenticates the bypass; filed as follow-up #3252. That requires coordinated Tauri build + Vercel env changes out of scope for #3207. ### Verified - typecheck + typecheck:api clean - lint:api-contract (56 entries) - tests/edge-functions.test.mjs + contact-handler.test.mjs (147 pass) Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix(review): MEDIUM + LOW + rate-limit-policy CI check (#3207) Closes out the remaining @koala73 review findings from #3242 that didn't already land in the HIGH-fix commits, plus the requested CI check that would have caught HIGH #1 (dead-code policy key) at review time. ### MEDIUM #5 — Turnstile missing-secret policy default Flip `verifyTurnstile`'s default `missingSecretPolicy` from `'allow'` to `'allow-in-development'`. Dev with no secret = pass (expected local); prod with no secret = reject + log. submit-contact was already explicitly overriding to `'allow-in-development'`; register-interest was silently getting `'allow'`. Safe default now means a future missing-secret misconfiguration in prod gets caught instead of silently letting bots through. Removed the now-redundant override in submit-contact. ### MEDIUM #6 — Silent enum fallbacks in maritime client `toDisruptionEvent` mapped `AIS_DISRUPTION_TYPE_UNSPECIFIED` / unknown enum values → `gap_spike` / `low` silently. Refactored to return null when either enum is unknown; caller filters nulls out of the array. Handler doesn't produce UNSPECIFIED today, but the `gap_spike` default would have mislabeled the first new enum value the proto ever adds — dropping unknowns is safer than shipping wrong labels. ### LOW — Copy drift in register-interest email Email template hardcoded `435+ Sources`; PR #3241 bumped marketing to `500+`. Bumped in the rewritten file to stay consistent. The `as any` on Convex mutation names carried over from legacy and filed as follow-up #3253. ### Rate-limit-policy coverage lint `scripts/enforce-rate-limit-policies.mjs` validates every key in `ENDPOINT_RATE_POLICIES` resolves to a proto-generated gateway route by cross-referencing `docs/api/*.openapi.yaml`. Fails with the sanctions-entity-search incident referenced in the error message so future drift has a paper trail. Wired into package.json (`lint:rate-limit-policies`) and the pre-push hook alongside `lint:boundaries`. Smoke-tested both directions — clean repo passes (5 policies / 175 routes), seeded drift (the exact HIGH #1 typo) fails with the advertised remedy text. ### Verified - `lint:rate-limit-policies` ✓ - `typecheck` + `typecheck:api` ✓ - `lint:api-contract` ✓ (56 entries) - `lint:boundaries` ✓ - edge-functions + contact-handler tests (147 pass) Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * refactor(commit 5): decomm /api/eia/* + migrate /api/satellites → IntelligenceService (#3207) Both targets turned out to be decomm-not-migration cases. The original plan called for two new services (economic/v1.GetEiaSeries + natural/v1.ListSatellitePositions) but research found neither was needed: ### /api/eia/[[...path]].js — pure decomm, zero consumers The "catch-all" is a misnomer — only two paths actually worked, /api/eia/health and /api/eia/petroleum, both Redis-only readers. Zero frontend callers in src/. Zero server-side readers. Nothing consumes the `energy:eia-petroleum:v1` key that seed-eia-petroleum.mjs writes daily. The EIA data the frontend actually uses goes through existing typed RPCs in economic/v1: GetEnergyPrices, GetCrudeInventories, GetNatGasStorage, GetEnergyCapacity. None of those touch /api/eia/*. Building GetEiaSeries would have been dead code. Deleted the legacy file + its test (tests/api-eia-petroleum.test.mjs — it only covered the legacy endpoint, no behavior to preserve). Empty api/eia/ dir removed. **Note for review:** the Redis seed cron keeps running daily and nothing consumes it. If that stays unused, seed-eia-petroleum.mjs should be retired too (separate PR). Out of scope for sebuf-migration. ### /api/satellites.js — Learning #2 strikes again IntelligenceService.ListSatellites already exists at /api/intelligence/v1/list-satellites, reads the same Redis key (intelligence:satellites:tle:v1), and supports an optional country filter the legacy didn't have. One frontend caller in src/services/satellites.ts needed to switch from `fetch(toApiUrl('/api/satellites'))` to the typed IntelligenceServiceClient.listSatellites. Shape diff was tiny — legacy `noradId` became proto `id` (handler line 36 already picks either), everything else identical. alt/velocity/inclination in the proto are ignored by the caller since it propagates positions client-side via satellite.js. Kept the client-side cache + failure cooldown + 20s timeout (still valid concerns at the caller level). ### Manifest + docs - api-route-exceptions.json: 56 → 54 entries (both removed) - docs/api-proxies.mdx: dropped the two rows from the Raw-data passthroughs table ### Verified - typecheck + typecheck:api ✓ - lint:api-contract (54 entries) / lint:boundaries / lint:rate-limit-policies ✓ - tests/edge-functions.test.mjs 127 pass (down from 130 — 3 tests were for the deleted eia endpoint) - make generate zero-diff (no proto changes) Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * refactor(commit 6): migrate /api/supply-chain/v1/{country-products,multi-sector-cost-shock} → SupplyChainService (#3207) Both endpoints were hand-rolled TS handlers sitting under a proto URL prefix — the exact drift the manifest guardrail flagged. Promoted both to typed RPCs: - GetCountryProducts → /api/supply-chain/v1/get-country-products - GetMultiSectorCostShock → /api/supply-chain/v1/get-multi-sector-cost-shock Handlers preserve the existing semantics: PRO-gate via isCallerPremium(ctx.request), iso2 / chokepointId validation, raw bilateral-hs4 Redis read (skip env-prefix to match seeder writes), CHOKEPOINT_STATUS_KEY for war-risk tier, and the math from _multi-sector-shock.ts unchanged. Empty-data and non-PRO paths return the typed empty payload (no 403 — the sebuf gateway pattern is empty-payload-on-deny). Client wrapper switches from premiumFetch to client.getCountryProducts/ client.getMultiSectorCostShock. Legacy MultiSectorShock / MultiSectorShockResponse / CountryProductsResponse names remain as type aliases of the generated proto types so CountryBriefPanel + CountryDeepDivePanel callsites compile with zero churn. Manifest 54 → 52. Rate-limit gateway routes 175 → 177. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix(gateway): add cache-tier entries for new supply-chain RPCs (#3207) Pre-push tests/route-cache-tier.test.mjs caught the missing entries. Both PRO-gated, request-varying — match the existing supply-chain PRO cohort (get-country-cost-shock, get-bypass-options, etc.) at slow-browser tier. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * refactor(commit 7): migrate /api/scenario/v1/{run,status,templates} → ScenarioService (#3207) Promote the three literal-filename scenario endpoints to a typed sebuf service with three RPCs: POST /api/scenario/v1/run-scenario (RunScenario) GET /api/scenario/v1/get-scenario-status (GetScenarioStatus) GET /api/scenario/v1/list-scenario-templates (ListScenarioTemplates) Preserves all security invariants from the legacy handlers: - 405 for wrong method (sebuf service-config method gate) - scenarioId validation against SCENARIO_TEMPLATES registry - iso2 regex ^[A-Z]{2}$ - JOB_ID_RE path-traversal guard on status - Per-IP 10/min rate limit (moved to gateway ENDPOINT_RATE_POLICIES) - Queue-depth backpressure (>100 → 429) - PRO gating via isCallerPremium - AbortSignal.timeout on every Redis pipeline (runRedisPipeline helper) Wire-level diffs vs legacy: - Per-user RL now enforced at the gateway (same 10/min/IP budget). - Rate-limit response omits Retry-After header; retryAfter is in the body per error-mapper.ts convention. - ListScenarioTemplates emits affectedHs2: [] when the registry entry is null (all-sectors sentinel); proto repeated cannot carry null. - RunScenario returns { jobId, status } (no statusUrl field — unused by SupplyChainPanel, drop from wire). Gateway wiring: - server/gateway.ts RPC_CACHE_TIER: list-scenario-templates → 'daily' (matches legacy max-age=3600); get-scenario-status → 'slow-browser' (premium short-circuit target, explicit entry required by tests/route-cache-tier.test.mjs). - src/shared/premium-paths.ts: swap old run/status for the new run-scenario/get-scenario-status paths. - api/scenario/v1/{run,status,templates}.ts deleted; 3 manifest exceptions removed (63 → 52 → 49 migration-pending). Client: - src/services/scenario/index.ts — typed client wrapper using premiumFetch (injects Clerk bearer / API key). - src/components/SupplyChainPanel.ts — polling loop swapped from premiumFetch strings to runScenario/getScenarioStatus. Hard 20s timeout on run preserved via AbortSignal.any. Tests: - tests/scenario-handler.test.mjs — 18 new handler-level tests covering every security invariant + the worker envelope coercion. - tests/edge-functions.test.mjs — scenario sections removed, replaced with a breadcrumb pointer to the new test file. Docs: api-scenarios.mdx, scenario-engine.mdx, usage-rate-limits.mdx, usage-errors.mdx, supply-chain.mdx refreshed with new paths. Verified: typecheck, typecheck:api, lint:api-contract (49 entries), lint:rate-limit-policies (6/180), lint:boundaries, route-cache-tier (parity), full edge-functions (117) + scenario-handler (18). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * refactor(commit 8): migrate /api/v2/shipping/{route-intelligence,webhooks} → ShippingV2Service (#3207) Partner-facing endpoints promoted to a typed sebuf service. Wire shape preserved byte-for-byte (camelCase field names, ISO-8601 fetchedAt, the same subscriberId/secret formats, the same SET + SADD + EXPIRE 30-day Redis pipeline). Partner URLs /api/v2/shipping/* are unchanged. RPCs landed: - GET /route-intelligence → RouteIntelligence (PRO, slow-browser) - POST /webhooks → RegisterWebhook (PRO) - GET /webhooks → ListWebhooks (PRO, slow-browser) The existing path-parameter URLs remain on the legacy edge-function layout because sebuf's HTTP annotations don't currently model path params (grep proto/**/*.proto for `path: "{…}"` returns zero). Those endpoints are split into two Vercel dynamic-route files under api/v2/shipping/webhooks/, behaviorally identical to the previous hybrid file but cleanly separated: - GET /webhooks/{subscriberId} → [subscriberId].ts - POST /webhooks/{subscriberId}/rotate-secret → [subscriberId]/[action].ts - POST /webhooks/{subscriberId}/reactivate → [subscriberId]/[action].ts Both get manifest entries under `migration-pending` pointing at #3207. Other changes - scripts/enforce-sebuf-api-contract.mjs: extended GATEWAY_RE to accept api/v{N}/{domain}/[rpc].ts (version-first) alongside the canonical api/{domain}/v{N}/[rpc].ts; first-use of the reversed ordering is shipping/v2 because that's the partner contract. - vite.config.ts: dev-server sebuf interceptor regex extended to match both layouts; shipping/v2 import + allRoutes entry added. - server/gateway.ts: RPC_CACHE_TIER entries for /api/v2/shipping/ route-intelligence + /webhooks (slow-browser; premium-gated endpoints short-circuit to slow-browser but the entries are required by tests/route-cache-tier.test.mjs). - src/shared/premium-paths.ts: route-intelligence + webhooks added. - tests/shipping-v2-handler.test.mjs: 18 handler-level tests covering PRO gate, iso2/cargoType/hs2 coercion, SSRF guards (http://, RFC1918, cloud metadata, IMDS), chokepoint whitelist, alertThreshold range, secret/subscriberId format, pipeline shape + 30-day TTL, cross-tenant owner isolation, `secret` omission from list response. Manifest delta - Removed: api/v2/shipping/route-intelligence.ts, api/v2/shipping/webhooks.ts - Added: api/v2/shipping/webhooks/[subscriberId].ts (migration-pending) - Added: api/v2/shipping/webhooks/[subscriberId]/[action].ts (migration-pending) - Added: api/internal/brief-why-matters.ts (internal-helper) — regression surface from the #3248 main merge, which introduced the file without a manifest entry. Filed here to keep the lint green; not strictly in scope for commit 8 but unblocking. Net result: 49 → 47 `migration-pending` entries (one net-removal even though webhook path-params stay pending, because two files collapsed into two dynamic routes). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix(review HIGH 1): SupplyChainServiceClient must use premiumFetch (#3207) Signed-in browser pro users were silently hitting 401 on 8 supply-chain premium endpoints (country-products, multi-sector-cost-shock, country-chokepoint-index, bypass-options, country-cost-shock, sector-dependency, route-explorer-lane, route-impact). The shared client was constructed with globalThis.fetch, so no Clerk bearer or X-WorldMonitor-Key was injected. The gateway's validateApiKey runs with forceKey=true for PREMIUM_RPC_PATHS and 401s before isCallerPremium is consulted. The generated client's try/catch collapses the 401 into an empty-fallback return, leaving panels blank with no visible error. Fix is one line at the client constructor: swap globalThis.fetch for premiumFetch. The same pattern is already in use for insider-transactions, stock-analysis, stock-backtest, scenario, trade (premiumClient) — this was an omission on this client, not a new pattern. premiumFetch no-ops safely when no credentials are available, so the 5 non-premium methods on this client (shippingRates, chokepointStatus, chokepointHistory, criticalMinerals, shippingStress) continue to work unchanged. This also fixes two panels that were pre-existing latently broken on main (chokepoint-index, bypass-options, etc. — predating #3207, not regressions from it). Commit 6 expanded the surface by routing two more methods through the same buggy client; this commit fixes the class. From koala73 review (#3242 second-pass, HIGH new #1): > Exact class PR #3233 fixed for RegionalIntelligenceBoard / > DeductionPanel / trade / country-intel. Supply-chain was not in > #3233's scope. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix(review HIGH 2): restore 400 on input-shape errors for 2 supply-chain handlers (#3207) Commit 6 collapsed all non-happy paths into empty-200 on `get-country-products` and `get-multi-sector-cost-shock`, including caller-bug cases that legacy returned 400 for: - get-country-products: malformed iso2 → empty 200 (was 400) - get-multi-sector-cost-shock: malformed iso2 / missing chokepointId / unknown chokepointId → empty 200 (was 400) The commit message for 6 called out the 403-for-non-pro → empty-200 shift ("sebuf gateway pattern is empty-payload-on-deny") but not the 400 shift. They're different classes: - Empty-payload-200 for PRO-deny: intentional contract change, already documented and applied across the service. Generated clients treat "you lack PRO" as "no data" — fine. - Empty-payload-200 for malformed input: caller bug silently masked. External API consumers can't distinguish "bad wiring" from "genuinely no data", test harnesses lose the signal, bad calling code doesn't surface in Sentry. Fix: `throw new ValidationError(violations)` on the 3 input-shape branches. The generated sebuf server maps ValidationError → HTTP 400 (see src/generated/server/.../service_server.ts and leads/v1 which already uses this pattern). PRO-gate deny stays as empty-200 — that contract shift was intentional and is preserved. Regression tests added at tests/supply-chain-validation.test.mjs (8 cases) pinning the three-way contract: - bad input → 400 (ValidationError) - PRO-gate deny on valid input → 200 empty - valid PRO input, no data in Redis → 200 empty (unchanged) From koala73 review (#3242 second-pass, HIGH new #2). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix(review HIGH 3): restore statusUrl on RunScenarioResponse + document 202→200 wire break (#3207) Commit 7 silently shifted /api/scenario/v1/run-scenario's response contract in two ways that the commit message covered only partially: 1. HTTP 202 Accepted → HTTP 200 OK 2. Dropped `statusUrl` string from the response body The `statusUrl` drop was mentioned as "unused by SupplyChainPanel" but not framed as a contract change. The 202 → 200 shift was not mentioned at all. This is a same-version (v1 → v1) migration, so external callers that key off either signal — `response.status === 202` or `response.body.statusUrl` — silently branch incorrectly. Evaluated options: (a) sebuf per-RPC status-code config — not available. sebuf's HttpConfig only models `path` and `method`; no status annotation. (b) Bump to scenario/v2 — judged heavier than the break itself for a single status-code shift. No in-repo caller uses 202 or statusUrl; the docs-level impact is containable. (c) Accept the break, document explicitly, partially restore. Took option (c): - Restored `statusUrl` in the proto (new field `string status_url = 3` on RunScenarioResponse). Server computes `/api/scenario/v1/get-scenario-status?jobId=<encoded job_id>` and populates it on every successful enqueue. External callers that followed this URL keep working unchanged. - 202 → 200 is not recoverable inside the sebuf generator, so it is called out explicitly in two places: - docs/api-scenarios.mdx now includes a prominent `<Warning>` block documenting the v1→v1 contract shift + the suggested migration (branch on response body shape, not HTTP status). - RunScenarioResponse proto comment explains why 200 is the new success status on enqueue. OpenAPI bundle regenerated to reflect the restored statusUrl field. - Regression test added in tests/scenario-handler.test.mjs pinning `statusUrl` to the exact URL-encoded shape — locks the invariant so a future proto rename or handler refactor can't silently drop it again. From koala73 review (#3242 second-pass, HIGH new #3). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix(review HIGH 1/2): close webhook tenant-isolation gap on shipping/v2 (#3207) Koala flagged this as a merge blocker in PR #3242 review. server/worldmonitor/shipping/v2/{register-webhook,list-webhooks}.ts migrated without reinstating validateApiKey(req, { forceKey: true }), diverging from both the sibling api/v2/shipping/webhooks/[subscriberId] routes and the documented "X-WorldMonitor-Key required" contract in docs/api-shipping-v2.mdx. Attack surface: the gateway accepts Clerk bearer auth as a pro signal. A Clerk-authenticated pro user with no X-WorldMonitor-Key reaches the handler, callerFingerprint() falls back to 'anon', and every such caller collapses into a shared webhook:owner:anon:v1 bucket. The defense-in-depth ownerTag !== ownerHash check in list-webhooks.ts doesn't catch it because both sides equal 'anon' — every Clerk-session holder could enumerate / overwrite every other Clerk-session pro tenant's registered webhook URLs. Fix: reinstate validateApiKey(ctx.request, { forceKey: true }) at the top of each handler, throwing ApiError(401) when absent. Matches the sibling routes exactly and the published partner contract. Tests: - tests/shipping-v2-handler.test.mjs: two existing "non-PRO → 403" tests for register/list were using makeCtx() with no key, which now fails at the 401 layer first. Renamed to "no API key → 401 (tenant-isolation gate)" with a comment explaining the failure mode being tested. 18/18 pass. Verified: typecheck:api, lint:api-contract (no change), lint:boundaries, lint:rate-limit-policies, test:data (6005/6005). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * fix(review HIGH 2/2): restore v1 path aliases on scenario + supply-chain (#3207) Koala flagged this as a merge blocker in PR #3242 review. Commits 6 + 7 of #3207 renamed five documented v1 URLs to the sebuf method-derived paths and deleted the legacy edge-function files: POST /api/scenario/v1/run → run-scenario GET /api/scenario/v1/status → get-scenario-status GET /api/scenario/v1/templates → list-scenario-templates GET /api/supply-chain/v1/country-products → get-country-products GET /api/supply-chain/v1/multi-sector-cost-shock → get-multi-sector-cost-shock server/router.ts is an exact static-match table (Map keyed on `METHOD PATH`), so any external caller — docs, partner scripts, grep-the- internet — hitting the old documented URL would 404 on first request after merge. Commit 8 (shipping/v2) preserved partner URLs byte-for- byte; the scenario + supply-chain renames missed that discipline. Fix: add five thin alias edge functions that rewrite the pathname to the canonical sebuf path and delegate to the domain [rpc].ts gateway via a new server/alias-rewrite.ts helper. Premium gating, rate limits, entitlement checks, and cache-tier lookups all fire on the canonical path — aliases are pure URL rewrites, not a duplicate handler pipeline. api/scenario/v1/{run,status,templates}.ts api/supply-chain/v1/{country-products,multi-sector-cost-shock}.ts Vite dev parity: file-based routing at api/ is a Vercel concern, so the dev middleware (vite.config.ts) gets a matching V1_ALIASES rewrite map before the router dispatch. Manifest: 5 new entries under `deferred` with removal_issue=#3282 (tracking their retirement at the next v1→v2 break). lint:api-contract stays green (89 files checked, 55 manifest entries validated). Docs: - docs/api-scenarios.mdx: migration callout at the top with the full old→new URL table and a link to the retirement issue. - CHANGELOG.md + docs/changelog.mdx: Changed entry documenting the rename + alias compat + the 202→200 shift (from commit 23c821a). Verified: typecheck:api, lint:api-contract, lint:rate-limit-policies, lint:boundaries, test:data (6005/6005). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
koala73
added a commit
that referenced
this pull request
Apr 22, 2026
…tirements + §3.6 coverage gate Methodology-doc update capturing the three §3.5 landings and the §3.6 CI gate. Five edits: 1. **Known construct limitations section (#5 and #6):** strikethrough the original "dead signals" and "no coverage-based weight cap" items, annotate them with "Landed in PR 3 §3.5"/"Landed in PR 3 §3.6" + specifics of what shipped. 2. **Currency & External H4 section:** completely rewritten. Old table (fxVolatility / fxDeviation / fxReservesAdequacy on BIS primary) is replaced by the two-indicator post-PR-3 table (inflationStability at 0.60 + fxReservesAdequacy at 0.40). Coverage ladder spelled out (0.85 / 0.55 / 0.40 / 0.30). Legacy BIS indicators named as experimental-tier drill-downs only. 3. **Fuel Stock Days H4 section:** H4 heading text kept verbatim so the methodology-lint H4-to-dimension mapping does not break; body rewritten to explain that the dimension is retired from core but the seeder still runs for IEA-member drill-downs. 4. **External Debt Coverage table row:** goalpost 5-0 → 2-0, description cites Greenspan-Guidotti reserve-adequacy rule. 5. **New v2.2 changelog entry** — PR 3 dead-signal cleanup, covering §3.5 points 1/2/3 + §3.6 + acceptance gates + construct-audit updates. No scoring or code changes in this commit. Methodology-lint test passes (H4 mapping intact). All 6327 data-tier tests pass.
koala73
added a commit
that referenced
this pull request
Apr 22, 2026
* feat(resilience): PR 3 §3.5 — retire fuelStockDays from core score permanently
First commit in PR 3 of the resilience repair plan. Retires
`fuelStockDays` from the core score with no replacement.
Why permanent, not replaced:
IEA emergency-stockholding rules are defined in days of NET IMPORTS
and do not bind net exporters by design. Norway/Canada/US measured
in days-of-imports are incomparable to Germany/Japan measured the
same way — the construct is fundamentally different across the two
country classes. No globally-comparable recovery-fuel signal can
be built from this source; the pre-repair probe showed 100% imputed
at 50 for every country in the April 2026 freeze.
scoreFuelStockDays:
- Rewritten to return coverage=0 + observedWeight=0 +
imputationClass='source-failure' for every country regardless
of seed content.
- Drops the dimension from the `recovery` domain's coverage-
weighted mean automatically; remaining recovery dimensions
pick up the share via re-normalisation in
`_shared.ts#coverageWeightedMean`.
- No explicit weight transfer needed — the coverage-weighted
blend handles redistribution.
Registry:
- recoveryFuelStockDays re-tagged from tier='enrichment' to
tier='experimental' so the Core coverage gate treats it as
out-of-score.
- Description updated to make the retirement explicit; entry
stays in the registry for structural continuity (the
dimension `fuelStockDays` remains in RESILIENCE_DIMENSION_ORDER
for the 19-dimension tests; removing the dimension entirely is
a PR 4 structural-audit concern).
Housekeeping:
- Removed `RESILIENCE_RECOVERY_FUEL_STOCKS_KEY` constant (no
longer read; noUnusedLocals would reject it).
- Removed `RecoveryFuelStocksCountry` interface for the same
reason. Comment at the removed declaration instructs future
maintainers not to re-add the type as a reservation; when a
new recovery-fuel concept lands, introduce a fresh interface.
Plan reference: §3.5 point 1 of
`docs/plans/2026-04-22-001-fix-resilience-scorer-structural-bias-plan.md`.
51 resilience tests pass, typecheck + biome clean. The
`recovery` domain's published score will shift slightly for every
country because the 0.10 slot that fuelStockDays was imputing to
now redistributes; the compare-harness acceptance-gate rerun at
merge time will quantify the shift per plan §6 gates.
* feat(resilience): PR 3 §3.5 — retire BIS-backed currencyExternal; rebuild on IMF inflation + WB reserves
BIS REER/DSR feeds were load-bearing in currencyExternal (weights 0.35
fxVolatility + 0.35 fxDeviation, ~70% of dimension). They cover ~60
countries max — so every non-BIS country fell through to
curated_list_absent (coverage 0.3) or a thin IMF proxy (coverage 0.45).
Combined with reserveMarginPct already removed in PR 1, currencyExternal
was the clearest "construct absent for most of the world" carrier left
in the scorer.
Changes:
_dimension-scorers.ts
- scoreCurrencyExternal now reads IMF macro (inflationPct) + WB FX
reserves only. Coverage ladder:
inflation + reserves → 0.85 (observed primary + secondary)
inflation only → 0.55
reserves only → 0.40
neither → 0.30 (IMPUTE.bisEer retained for snapshot
continuity; semantics read as
"no IMF + no WB reserves" now)
- Removed dead symbols: RESILIENCE_BIS_EXCHANGE_KEY constant (reserved
via comment only, flagged by noUnusedLocals), stddev() helper,
getCountryBisExchangeRates() loader, BisExchangeRate interface,
dateToSortableNumber() — all were exclusive callers of the retired
BIS path.
_indicator-registry.ts
- New core entry inflationStability (weight 0.60, tier=core,
sourceKey=economic:imf:macro:v2).
- fxReservesAdequacy weight 0.15 → 0.40 (secondary reliability
anchor).
- fxVolatility + fxDeviation demoted tier=enrichment → tier=experimental
(BIS ~60-country coverage; off the core weight sum).
- Non-experimental weights now sum to 1.0 (0.60 + 0.40).
scripts/compare-resilience-current-vs-proposed.mjs
- EXTRACTION_RULES: added inflationStability →
imf-macro-country-field field=inflationPct so the registry-parity
test passes and the correlation harness sees the new construct.
tests/resilience-dimension-scorers.test.mts
- Dropped BIS-era wording ("non-BIS country") and test 266
(BIS-outage coverage 0.35 branch) which collapsed to the inflation-
only path post-retirement.
- Updated coverage assertions: inflation-only 0.45 → 0.55; inflation+
reserves 0.55 → 0.85.
tests/resilience-scorers.test.mts
- domainAverages.economic 68.33 → 66.33 (US currencyExternal score
shifts slightly under IMF+reserves vs old BIS composite).
- stressScore 67.85 → 67.21; stressFactor 0.3215 → 0.3279.
- overallScore 65.82 → 65.52.
- baselineScore unchanged (currencyExternal is stress-only).
All 6324 data-tier tests pass. typecheck:api clean. No change to
seeders or Redis keys; this is a pure scorer + registry rebuild.
* feat(resilience): PR 3 §3.5 point 3 — re-goalpost externalDebtCoverage (0..5 → 0..2)
Plan §2.1 diagnosis table showed externalDebtCoverage saturating at
score=100 across all 9 probe countries — including stressed states.
Signal was collapsed. Root cause: (worst=5, best=0) gave every country
with ratio < 0.5 a score above 90, and mapped Greenspan-Guidotti's
reserve-adequacy threshold (ratio=1.0) to score 80 — well into "no
worry" territory instead of the "mild warning" it should be.
Re-anchored on Greenspan-Guidotti directly: ratio=1.0 now maps to score
50 (mild warning), ratio=2.0 to score 0 (acute rollover-shock exposure).
Ratios above 2.0 clamp to 0, consistent with "beyond this point the
country is already in crisis; exact value stops mattering."
Files changed:
- _indicator-registry.ts: recoveryDebtToReserves goalposts
{worst: 5, best: 0} → {worst: 2, best: 0}. Description updated to
cite Greenspan-Guidotti; inline comment documents anchor + rationale.
- _dimension-scorers.ts: scoreExternalDebtCoverage normalizer bound
changed from (0..5) to (0..2), with inline comment.
- docs/methodology/country-resilience-index.mdx: goalpost table row
5-0 → 2-0, description cites Greenspan-Guidotti.
- docs/methodology/indicator-sources.yaml:
* constructStatus: dead-signal → observed-mechanism (signal is now
discriminating).
* reviewNotes updated to describe the new anchor.
* mechanismTestRationale names the Greenspan-Guidotti rule.
- tests/resilience-dimension-monotonicity.test.mts: updated the
comment + picked values inside the (0..2) discriminating band (0.3
and 1.5). Old values (1 vs 4) had 4 clamping to 0.
- tests/resilience-dimension-scorers.test.mts: NO score threshold
relaxed >90 → >=85 (NO ratio=0.2 now scores 90, was 96).
- tests/resilience-scorers.test.mts: fixture drift:
* domainAverages.recovery 54.83 → 47.33 (US extDebt 70 → 25).
* baselineScore 63.63 → 60.12 (extDebt is baseline type).
* overallScore 65.52 → 63.27.
* stressScore / stressFactor unchanged (extDebt is baseline-only).
All 6324 data-tier tests pass. typecheck:api clean.
* feat(resilience): PR 3 §3.6 — CI gate on indicator coverage and nominal weight
Plan §3.6 adds a new acceptance criterion (also §5 item 5):
> No indicator with observed coverage below 70% may exceed 5% nominal
> weight OR 5% effective influence in the post-change sensitivity run.
This commit enforces the NOMINAL-WEIGHT half as a unit test that runs
on every CI build. The EFFECTIVE-INFLUENCE half is produced by
scripts/validate-resilience-sensitivity.mjs as a committed artifact;
the gate file only asserts that script still exists so a refactor that
removes it breaks the build loudly.
Why the gate exists (plan §3.6):
"A dimension at 30% observed coverage carries the same effective
weight as one at 95%. This contradicts the OECD/JRC handbook on
uncertainty analysis."
Implementation:
tests/resilience-coverage-influence-gate.test.mts — three tests:
1. Nominal-weight gate: for every core indicator with coverage < 137
countries (70% of the ~195-country universe), computes its nominal
overall weight as
indicator.weight × (1/dimensions-in-domain) × domain-weight
and asserts it does not exceed 5%. Equal-share-per-dimension is
the *upper bound* on runtime weight (coverage-weighted mean gives
a lower share when a dimension drops out), so this is a strict
bound: if the nominal number passes, the runtime number also
passes for every country.
2. Effective-influence contract: asserts the sensitivity script
exists at its expected path. Removing it (intentionally or by
refactor) breaks the build.
3. Audit visibility: prints the top 10 core indicators by nominal
overall weight. No assertion beyond "ran" — the list lets
reviewers spot outliers that pass the gate but are near the cap.
Current state (observed from audit output):
recoveryReserveMonths: nominal=4.17% coverage=188
recoveryDebtToReserves: nominal=4.17% coverage=185
recoveryImportHhi: nominal=4.17% coverage=190
inflationStability: nominal=3.40% coverage=185
electricityConsumption: nominal=3.30% coverage=217
ucdpConflict: nominal=3.09% coverage=193
Every core indicator has coverage ≥ 180 (already enforced by the
pre-existing indicator-tiering test), so the nominal-weight gate has
no current violators — its purpose is catching future drift, not
flagging today's state.
All 6327 data-tier tests pass. typecheck:api clean.
* docs(resilience): PR 3 methodology doc — document §3.5 dead-signal retirements + §3.6 coverage gate
Methodology-doc update capturing the three §3.5 landings and the §3.6 CI
gate. Five edits:
1. **Known construct limitations section (#5 and #6):** strikethrough the
original "dead signals" and "no coverage-based weight cap" items,
annotate them with "Landed in PR 3 §3.5"/"Landed in PR 3 §3.6" +
specifics of what shipped.
2. **Currency & External H4 section:** completely rewritten. Old table
(fxVolatility / fxDeviation / fxReservesAdequacy on BIS primary) is
replaced by the two-indicator post-PR-3 table (inflationStability at
0.60 + fxReservesAdequacy at 0.40). Coverage ladder spelled out
(0.85 / 0.55 / 0.40 / 0.30). Legacy BIS indicators named as
experimental-tier drill-downs only.
3. **Fuel Stock Days H4 section:** H4 heading text kept verbatim so the
methodology-lint H4-to-dimension mapping does not break; body
rewritten to explain that the dimension is retired from core but the
seeder still runs for IEA-member drill-downs.
4. **External Debt Coverage table row:** goalpost 5-0 → 2-0, description
cites Greenspan-Guidotti reserve-adequacy rule.
5. **New v2.2 changelog entry** — PR 3 dead-signal cleanup, covering
§3.5 points 1/2/3 + §3.6 + acceptance gates + construct-audit
updates.
No scoring or code changes in this commit. Methodology-lint test passes
(H4 mapping intact). All 6327 data-tier tests pass.
* fix(resilience): PR 3 §3.6 gate — correct share-denominator for coverage-weighted aggregation
Reviewer catch (thanks). The previous gate computed each indicator's
nominal overall weight as
indicator.weight × (1 / N_total_dimensions_in_domain) × domain_weight
and claimed this was an upper bound ("actual runtime weight is ≤ this
when some dimensions drop out on coverage"). That is BACKWARDS for
this scorer.
The domain aggregation is coverage-weighted
(server/worldmonitor/resilience/v1/_shared.ts coverageWeightedMean),
so when a dimension pins at coverage=0 it is EXCLUDED from the
denominator and the surviving dimensions' shares go UP, not down.
PR 3 commit 1 retires fuelStockDays by hard-coding its scorer to
coverage=0 for every country — so in the current live state the
recovery domain has 5 contributing dimensions (not 6), and each core
recovery indicator's nominal share is
1.0 × 1/5 × 0.25 = 5.00% (was mis-reported as 4.17%)
The old gate therefore under-estimated nominal influence and could
silently pass exactly the kind of low-coverage overweight regression
it is meant to block.
Fix:
- Added `coreBearingDimensions(domainId)` helper that counts only
dimensions that have ≥1 core indicator in the registry. A dimension
with only experimental/enrichment entries (post-retirement
fuelStockDays) has no core contribution → does not dilute shares.
- Updated `nominalOverallWeight` to divide by the core-bearing count,
not the raw dimension count.
- Rewrote the helper's doc comment to stop claiming this is a strict
upper bound — explicitly calls out the dynamic case (source failure
raising surviving dim shares further) as the sensitivity script's
responsibility.
- Added a new regression test: asserts (a) at least one recovery
dimension is all-non-core (fuelStockDays post-retirement),
(b) fuelStockDays has zero core indicators, and (c) recoveryDebt
ToReserves nominal = 0.05 exactly (not 0.0417) — any reversion
of the retirement or regression to N_total-denominator will fail
loudly.
Top-10 audit output now correctly shows:
recoveryReserveMonths: nominal=5% coverage=188
recoveryDebtToReserves: nominal=5% coverage=185
recoveryImportHhi: nominal=5% coverage=190
(was 4.17% each under the old math)
All 486 resilience tests pass. typecheck:api clean.
Note: the 5% figure is exactly AT the cap, not over it. "exceed" means
strictly > 5%, so it still passes. But now the reviewer / audit log
reflects reality.
* fix(resilience): PR 3 review — retired-dim confidence drag + false source-failure label
Addresses the Codex review P1 + P2 on PR #3297.
P1 — retired-dim drag on confidence averages
--------------------------------------------
scoreFuelStockDays returns coverage=0 by design (retired construct),
but computeLowConfidence, computeOverallCoverage, and the widget's
formatResilienceConfidence averaged across all 19 dimensions. That
dragged every country's reported averageCoverage down — US went from
0.8556 (active dims only) to 0.8105 (all dims) — enough drift to
misclassify edge countries as lowConfidence and to shift the ranking
widget's overallCoverage pill for every country.
Fix: introduce an authoritative RESILIENCE_RETIRED_DIMENSIONS set in
_dimension-scorers.ts and filter it out of all three averages. The
filter is keyed on the retired-dim REGISTRY, not on coverage === 0,
because a non-retired dim can legitimately emit coverage=0 on a
genuinely sparse-data country via weightedBlend fall-through — those
entries MUST keep dragging confidence down (that is the sparse-data
signal lowConfidence exists to surface). Verified: sparse-country
release-gate test (marks sparse WHO/FAO countries as low confidence)
still passes with the registry-keyed filter; would have failed with
a naive coverage=0 filter.
Server-client parity: widget-utils cannot import server code, so
RESILIENCE_RETIRED_DIMENSION_IDS is a hand-mirrored constant, kept
in lockstep by tests/resilience-retired-dimensions-parity.test.mts
(parses the widget file as text, same pattern as existing widget-util
tests that can't import the widget module directly).
P2 — false "Source down" label on retired dim
---------------------------------------------
scoreFuelStockDays hard-coded imputationClass: 'source-failure',
which the widget maps to "Source down: upstream seeder failed" with
a `!` icon for every country. That is semantically wrong for an
intentional retirement. Flipped to null so the widget's absent-path
renders a neutral cell without a false outage label. null is already
a legal value of ResilienceDimensionScore.imputationClass; no type
change needed.
Tests
-----
- tests/resilience-confidence-averaging.test.mts (new): pins the
registry-keyed filter semantic for computeOverallCoverage +
computeLowConfidence. Includes a negative-control test proving
non-retired coverage=0 dims still flip lowConfidence.
- tests/resilience-retired-dimensions-parity.test.mts (new):
lockstep gate between server and client retired-dim lists.
- Widget test adds a registry-keyed exclusion test with a non-retired
coverage=0 dim in the fixture to lock in the correct semantic.
- Existing tests asserting imputationClass: 'source-failure' for
fuelStockDays flipped to null.
All 494 resilience tests + full 6336/6336 data-tier suite pass.
Typecheck clean for both tsconfig.json and tsconfig.api.json.
* docs(resilience): align methodology + registry metadata with shipped imputationClass=null
Follow-up to the previous PR 3 review commit that flipped
scoreFuelStockDays's imputationClass from 'source-failure' to null to
avoid a false "Source down" widget label on every country. The code
changed; the doc and registry metadata did not, leaving three sites
in the methodology mdx and two comment/description sites in the
registry still claiming imputationClass='source-failure'. Any future
reviewer (or tooling that treats the registry description as
authoritative) would be misled.
This commit rewrites those sites to describe the shipped behavior:
- imputationClass=null (not 'source-failure'), with the rationale
- exclusion from confidence/coverage averages via the
RESILIENCE_RETIRED_DIMENSIONS registry filter
- the distinction between structural retirement (filtered) and
runtime coverage=0 (kept so sparse-data countries still flag
lowConfidence)
Touched:
- docs/methodology/country-resilience-index.mdx (lines ~33, ~268, ~590)
- server/worldmonitor/resilience/v1/_indicator-registry.ts
(recoveryFuelStockDays comment block + description field)
No code-behavior change. Docs-only.
Tests: 157 targeted resilience tests pass (incl. methodology-lint +
widget + release-gate + confidence-averaging). Typecheck clean on
both tsconfig.json and tsconfig.api.json.
koala73
added a commit
that referenced
this pull request
Apr 25, 2026
…lead divergence (#3396) * feat(brief-llm): canonical synthesis prompt + v3 cache key Extends generateDigestProse to be the single source of truth for brief executive-summary synthesis (canonicalises what was previously split between brief-llm's generateDigestProse and seed-digest- notifications.mjs's generateAISummary). Ports Brain B's prompt features into buildDigestPrompt: - ctx={profile, greeting, isPublic} parameter (back-compat: 4-arg callers behave like today) - per-story severity uppercased + short-hash prefix [h:XXXX] so the model can emit rankedStoryHashes for stable re-ranking - profile lines + greeting opener appear only when ctx.isPublic !== true validateDigestProseShape gains optional rankedStoryHashes (≥4-char strings, capped to MAX_STORIES_PER_USER × 2). v2-shaped rows still pass — field defaults to []. hashDigestInput v3: - material includes profile-SHA, greeting bucket, isPublic flag, per-story hash - isPublic=true substitutes literal 'public' for userId in the cache key so all share-URL readers of the same (date, sensitivity, pool) hit ONE cache row (no PII in public cache key) Adds generateDigestProsePublic(stories, sensitivity, deps) wrapper — no userId param by design — for the share-URL surface. Cache prefix bumped brief:llm:digest:v2 → v3. v2 rows expire on TTL. Per the v1→v2 precedent (see hashDigestInput comment), one-tick cost on rollout is acceptable for cache-key correctness. Tests: 72/72 passing in tests/brief-llm.test.mjs (8 new for the v3 behaviors), full data suite 6952/6952. Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md Step 1, Codex-approved (5 rounds). * feat(brief): envelope v3 — adds digest.publicLead for share-URL surface Bumps BRIEF_ENVELOPE_VERSION 2 → 3. Adds optional BriefDigest.publicLead — non-personalised executive lead generated by generateDigestProsePublic (already in this branch from the previous commit) for the public share-URL surface. Personalised `lead` is the canonical synthesis for authenticated channels; publicLead is its profile-stripped sibling so api/brief/public/* never serves user-specific content (watched assets/regions). SUPPORTED_ENVELOPE_VERSIONS = [1, 2, 3] keeps v1 + v2 envelopes in the 7-day TTL window readable through the rollout — the composer only ever writes the current version, but readers must tolerate older shapes that haven't expired yet. Same rollout pattern used at the v1 → v2 bump. Renderer changes (server/_shared/brief-render.js): - ALLOWED_DIGEST_KEYS gains 'publicLead' (closed-key-set still enforced; v2 envelopes pass because publicLead === undefined is the v2 shape). - assertBriefEnvelope: new isNonEmptyString check on publicLead when present. Type contract enforced; absence is OK. Tests (tests/brief-magazine-render.test.mjs): - New describe block "v3 publicLead field": v3 envelope renders; malformed publicLead rejected; v2 envelope still passes; ad-hoc digest keys (e.g. synthesisLevel) still rejected — confirming the closed-key-set defense holds for the cron-local-only fields the orchestrator must NOT persist. - BRIEF_ENVELOPE_VERSION pin updated 2 → 3 with rollout-rationale comment. Test results: 182 brief-related tests pass; full data suite 6956/6956. Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md Step 2, Codex Round-3 Medium #2. * feat(brief): synthesis splice + rankedStoryHashes pre-cap re-order Plumbs the canonical synthesis output (lead, threads, signals, publicLead, rankedStoryHashes from generateDigestProse) through the pure composer so the orchestration layer can hand pre-resolved data into envelope.digest. Composer stays sync / no I/O — Codex Round-2 High #2 honored. Changes: scripts/lib/brief-compose.mjs: - digestStoryToUpstreamTopStory now emits `hash` (the digest story's stable identifier, falls back to titleHash when absent). Without this, rankedStoryHashes from the LLM has nothing to match against. - composeBriefFromDigestStories accepts opts.synthesis = {lead, threads, signals, rankedStoryHashes?, publicLead?}. When passed, splices into envelope.digest after the stub is built. Partial synthesis (e.g. only `lead` populated) keeps stub defaults for the other fields — graceful degradation when L2 fallback fires. shared/brief-filter.js: - filterTopStories accepts optional rankedStoryHashes. New helper applyRankedOrder re-orders stories by short-hash prefix match BEFORE the cap is applied, so the model's editorial judgment of importance survives MAX_STORIES_PER_USER. Stable for ties; stories not in the ranking come after in original order. Empty/missing ranking is a no-op (legacy callers unchanged). shared/brief-filter.d.ts: - filterTopStories signature gains rankedStoryHashes?: string[]. - UpstreamTopStory gains hash?: unknown (carried through from digestStoryToUpstreamTopStory). Tests added (tests/brief-from-digest-stories.test.mjs): - synthesis substitutes lead/threads/signals/publicLead. - legacy 4-arg callers (no synthesis) keep stub lead. - partial synthesis (only lead) keeps stub threads/signals. - rankedStoryHashes re-orders pool before cap. - short-hash prefix match (model emits 8 chars; story carries full). - unranked stories go after in original order. Test results: 33/33 in brief-from-digest-stories; 182/182 across all brief tests; full data suite 6956/6956. Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md Step 3, Codex Round-2 Low + Round-2 High #2. * feat(brief): single canonical synthesis per user; rewire all channels Restructures the digest cron's per-user compose + send loops to produce ONE canonical synthesis per user per issueSlot — the lead text every channel (email HTML, plain-text, Telegram, Slack, Discord, webhook) and the magazine show is byte-identical. This eliminates the "two-brain" divergence that was producing different exec summaries on different surfaces (observed 2026-04-25 0802). Architecture: composeBriefsForRun (orchestration): - Pre-annotates every eligible rule with lastSentAt + isDue once, before the per-user pass. Same getLastSentAt helper the send loop uses so compose + send agree on lastSentAt for every rule. composeAndStoreBriefForUser (per-user): - Two-pass winner walk: try DUE rules first (sortedDue), fall back to ALL eligible rules (sortedAll) for compose-only ticks. Preserves today's dashboard refresh contract for weekly / twice_daily users on non-due ticks (Codex Round-4 High #1). - Within each pass, walk by compareRules priority and pick the FIRST candidate with a non-empty pool — mirrors today's behavior at scripts/seed-digest-notifications.mjs:1044 and prevents the "highest-priority but empty pool" edge case (Codex Round-4 Medium #2). - Three-level synthesis fallback chain: L1: generateDigestProse(fullPool, ctx={profile,greeting,!public}) L2: generateDigestProse(envelope-sized slice, ctx={}) L3: stub from assembleStubbedBriefEnvelope Distinct log lines per fallback level so ops can quantify failure-mode distribution. - Generates publicLead in parallel via generateDigestProsePublic (no userId param; cache-shared across all share-URL readers). - Splices synthesis into envelope via composer's optional `synthesis` arg (Step 3); rankedStoryHashes re-orders the pool BEFORE the cap so editorial importance survives MAX_STORIES. - synthesisLevel stored in the cron-local briefByUser entry — NOT persisted in the envelope (renderer's assertNoExtraKeys would reject; Codex Round-2 Medium #5). Send loop: - Reads lastSentAt via shared getLastSentAt helper (single source of truth with compose flow). - briefLead = brief?.envelope?.data?.digest?.lead — the canonical lead. Passed to buildChannelBodies (text/Telegram/Slack/Discord), injectEmailSummary (HTML email), and sendWebhook (webhook payload's `summary` field). All-channel parity (Codex Round-1 Medium #6). - Subject ternary reads cron-local synthesisLevel: 1 or 2 → "Intelligence Brief", 3 → "Digest" (preserves today's UX for fallback paths; Codex Round-1 Missing #5). Removed: - generateAISummary() — the second LLM call that produced the divergent email lead. ~85 lines. - AI_SUMMARY_CACHE_TTL constant — no longer referenced. The digest:ai-summary:v1:* cache rows expire on their existing 1h TTL (no cleanup pass). Helpers added: - getLastSentAt(rule) — extracted Upstash GET for digest:last-sent so compose + send both call one source of truth. - buildSynthesisCtx(rule, nowMs) — formats profile + greeting for the canonical synthesis call. Preserves all today's prefs-fetch failure-mode behavior. Composer: - compareRules now exported from scripts/lib/brief-compose.mjs so the cron can sort each pass identically to groupEligibleRulesByUser. Test results: full data suite 6962/6962 (was 6956 pre-Step 4; +6 new compose-synthesis tests from Step 3). Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md Steps 4 + 4b. Codex-approved (5 rounds). * fix(brief-render): public-share lead fail-safe — never leak personalised lead Public-share render path (api/brief/public/[hash].ts → renderer publicMode=true) MUST NEVER serve the personalised digest.lead because that string can carry profile context — watched assets, saved-region names, etc. — written by generateDigestProse with ctx.profile populated. Previously: redactForPublic redacted user.name and stories.whyMatters but passed digest.lead through unchanged. Codex Round-2 High (security finding). Now (v3 envelope contract): - redactForPublic substitutes digest.lead = digest.publicLead when the v3 envelope carries one (generated by generateDigestProsePublic with profile=null, cache-shared across all public readers). - When publicLead is absent (v2 envelope still in TTL window OR v3 envelope where publicLead generation failed), redactForPublic sets digest.lead to empty string. - renderDigestGreeting: when lead is empty, OMIT the <blockquote> pull-quote entirely. Page still renders complete (greeting + horizontal rule), just without the italic lead block. - NEVER falls back to the original personalised lead. assertBriefEnvelope still validates publicLead's contract (when present, must be a non-empty string) BEFORE redactForPublic runs, so a malformed publicLead throws before any leak risk. Tests added (tests/brief-magazine-render.test.mjs): - v3 envelope renders publicLead in pull-quote, personalised lead text never appears. - v2 envelope (no publicLead) omits pull-quote; rest of page intact. - empty-string publicLead rejected by validator (defensive). - private render still uses personalised lead. Test results: 68 brief-magazine-render tests pass; full data suite remains green from prior commit. Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md Step 5, Codex Round-2 High (security). * feat(digest): brief lead parity log + extra acceptance tests Adds the parity-contract observability line and supplementary acceptance tests for the canonical synthesis path. Parity log (per send, after successful delivery): [digest] brief lead parity user=<id> rule=<v>:<s>:<lang> synthesis_level=<1|2|3> exec_len=<n> brief_lead_len=<n> channels_equal=<bool> public_lead_len=<n> When channels_equal=false an extra WARN line fires — "PARITY REGRESSION user=… — email lead != envelope lead." Sentry's existing console-breadcrumb hook lifts this without an explicit captureMessage call. Plan acceptance criterion A5. Tests added (tests/brief-llm.test.mjs, +9): - generateDigestProsePublic: two distinct callers with identical (sensitivity, story-pool) hit the SAME cache row (per Codex Round-2 Medium #4 — "no PII in public cache key"). - public + private writes never collide on cache key (defensive). - greeting bucket change re-keys the personalised cache (Brain B parity). - profile change re-keys the personalised cache. - v3 cache prefix used (no v2 writes). Test results: 77/77 in brief-llm; full data suite 6971/6971 (was 6962 pre-Step-7; +9 new public-cache tests). Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md Steps 6 (partial) + 7. Acceptance A5, A6.g, A6.f. * test(digest): backfill A6.h/i/l/m acceptance tests via helper extraction * fix(brief): close two correctness regressions on multi-rule + public surface Two findings from human review of the canonical-synthesis PR: 1. Public-share redaction leaked personalised signals + threads. The new prompt explicitly personalises both `lead` and `signals` ("personalise lead and signals"), but redactForPublic only substituted `lead` — leaving `signals` and `threads` intact. Public renderer's hasSignals gate would emit the signals page whenever `digest.signals.length > 0`, exposing watched-asset / region phrasing to anonymous readers. Same privacy bug class the original PR was meant to close, just on different fields. 2. Multi-rule users got cross-pool lead/storyList mismatch. composeAndStoreBriefForUser picks ONE winning rule for the canonical envelope. The send loop then injected that ONE `briefLead` into every due rule's channel body — even though each rule's storyList came from its own (per-rule) digest pool. Multi-rule users (e.g. `full` + `finance`) ended up with email bodies leading on geopolitics while listing finance stories. Cross-rule editorial mismatch reintroduced after the cross- surface fix. Fix 1 — public signals + threads: - Envelope shape: BriefDigest gains `publicSignals?: string[]` + `publicThreads?: BriefThread[]` (sibling fields to publicLead). Renderer's ALLOWED_DIGEST_KEYS extended; assertBriefEnvelope validates them when present. - generateDigestProsePublic already returned a full prose object (lead + signals + threads) — orchestration now captures all three instead of just `.lead`. Composer splices each into its envelope slot. - redactForPublic substitutes: digest.lead ← publicLead (or empty → omits pull-quote) digest.signals ← publicSignals (or empty → omits signals page) digest.threads ← publicThreads (or category-derived stub via new derivePublicThreadsStub helper — never falls back to the personalised threads) - New tests cover all three substitutions + their fail-safes. Fix 2 — per-rule synthesis in send loop: - Each due rule independently calls runSynthesisWithFallback over ITS OWN pool + ctx. Channel body lead is internally consistent with the storyList (both from the same pool). - Cache absorbs the cost: when this is the winner rule, the synthesis hits the cache row written during the compose pass (same userId/sensitivity/pool/ctx) — no extra LLM call. Only multi-rule users with non-overlapping pools incur additional LLM calls. - magazineUrl still points at the winner's envelope (single brief per user per slot — `(userId, issueSlot)` URL contract). Channel lead vs magazine lead may differ for non-winner rule sends; documented as acceptable trade-off (URL/key shape change to support per-rule magazines is out of scope for this PR). - Parity log refined: adds `winner_match=<bool>` field. The PARITY REGRESSION warning now fires only when winner_match=true AND the channel lead differs from the envelope lead (the actual contract regression). Non-winner sends with legitimately different leads no longer spam the alert. Test results: - tests/brief-magazine-render.test.mjs: 75/75 (+7 new for public signals/threads + validator + private-mode-ignores-public-fields) - Full data suite: 6995/6995 (was 6988; +7 net) - typecheck + typecheck:api: clean Plan: docs/plans/2026-04-25-002-fix-brief-email-two-brain-divergence-plan.md Addresses 2 review findings on PR #3396 not anticipated in the 5-round Codex review. * fix(brief): unify compose+send window, fall through filter-rejection Address two residual risks in PR #3396 (single-canonical-brain refactor): Risk 1 — canonical lead synthesized from a fixed 24h pool while the send loop ships stories from `lastSentAt ?? 24h`. For weekly users that meant a 24h-pool lead bolted onto a 7d email body — the same cross-surface divergence the refactor was meant to eliminate, just in a different shape. Twice-daily users hit a 12h-vs-24h variant. Fix: extract the window formula to `digestWindowStartMs(lastSentAt, nowMs, defaultLookbackMs)` in digest-orchestration-helpers.mjs and call it from BOTH the compose path's digestFor closure AND the send loop. The compose path now derives windowStart per-candidate from `cand.lastSentAt`, identical to what the send loop will use for that rule. Removed the now-unused BRIEF_STORY_WINDOW_MS constant. Side-effect: digestFor now receives the full annotated candidate (`cand`) instead of just the rule, so it can reach `cand.lastSentAt`. Backwards-compatible at the helper level — pickWinningCandidateWithPool forwards `cand` instead of `cand.rule`. Cache memo hit rate drops since lastSentAt varies per-rule, but correctness > a few extra Upstash GETs. Risk 2 — pickWinningCandidateWithPool returned the first candidate with a non-empty raw pool as winner. If composeBriefFromDigestStories then dropped every story (URL/headline/shape filters), the caller bailed without trying lower-priority candidates. Pre-PR behaviour was to keep walking. This regressed multi-rule users whose top-priority rule's pool happens to be entirely filter-rejected. Fix: optional `tryCompose(cand, stories)` callback on pickWinningCandidateWithPool. When provided, the helper calls it after the non-empty pool check; falsy return → log filter-rejected and walk to the next candidate; truthy → returns `{winner, stories, composeResult}` so the caller can reuse the result. Without the callback, legacy semantics preserved (existing tests + callers unaffected). Caller composeAndStoreBriefForUser passes a no-synthesis compose call as tryCompose — cheap pure-JS, no I/O. Synthesis only runs once after the winner is locked in, so the perf cost is one extra compose per filter-rejected candidate, no extra LLM round-trips. Tests: - 10 new cases in tests/digest-orchestration-helpers.test.mjs covering: digestFor receiving full candidate; tryCompose fall-through to lower-priority; all-rejected returns null; composeResult forwarded; legacy semantics without tryCompose; digestWindowStartMs lastSentAt-vs-default branches; weekly + twice-daily window parity assertions; epoch-zero ?? guard. - Updated tests/digest-cache-key-sensitivity.test.mjs static-shape regex to match the new `cand.rule.sensitivity` cache-key shape (intent unchanged: cache key MUST include sensitivity). Stacked on PR #3396 — targets feat/brief-two-brain-divergence.
4 tasks
koala73
added a commit
that referenced
this pull request
May 10, 2026
* feat(convex): add mcpProTokens table + issue/validate/revoke + http routes (U1)
Non-key Pro MCP identity layer for the Pro-tier MCP access plan. Mirrors
apiKeys.ts shape but stores no key material — the row's _id IS the
bearer identifier (referenced from OAuth code/token records as
mcpTokenId).
- mcpProTokens table: {userId, clientId?, name?, createdAt, lastUsedAt?,
revokedAt?} indexed by_userId; userApiKeys untouched.
- Internal mutations: issueProMcpToken (tier ≥ 1 gate, 5-row cap with
silent oldest-revoke rotation, audit-preserving), validateProMcpToken,
internalRevokeProMcpToken (server-side rollback path for U5 — no Clerk
context needed), touchProMcpTokenLastUsed (debounced 5min).
- Public mutations: revokeProMcpToken + listProMcpTokens (Clerk-auth).
- HTTP internal routes: /api/internal-{issue,validate,revoke}-pro-mcp-token
with x-convex-shared-secret guard. Validate route schedules touch via
ctx.scheduler.runAfter, matching apiKeys pattern (http.ts:839).
28 new tests; full convex suite 245/245.
Plan unit U1: docs/plans/2026-05-10-001-feat-pro-mcp-clerk-auth-quota-plan.md
* feat(server): pro-mcp-token edge helper — issue/validate/revoke (U2)
Edge-runtime-safe wrappers around U1's Convex internal HTTP routes. No
positive cache on validate (revoke takes effect on next request);
negative-cache only at pro-mcp-token-neg:<tokenId> (60s TTL) for
known-bad bearers.
- issueProMcpTokenForUser: typed ProMcpIssueFailed (pro-required /
invalid-user-id / config / network), 3s timeout.
- validateProMcpToken: neg-cache short-circuit BEFORE Convex hit; fail-soft
on 5xx/timeout (returns null but does NOT write neg-cache sentinel —
prevents transient-blip poisoning of legitimate tokens). Convex's
validate route schedules touchProMcpTokenLastUsed internally; no
edge-side waitUntil needed.
- revokeProMcpToken: returns result object (no throw) so rollback callers
don't have their original error masked. Sets neg-cache sentinel after
successful revoke.
- invalidateProMcpTokenCache: public sentinel writer for U9.
25 tests including a load-bearing integration test for revoke → next
validate short-circuits without Convex round-trip.
Plan unit U2.
* feat(oauth): apex Clerk grant page + signed-grant mint API (U3)
Bridge between the apex Clerk session and the api-subdomain Pro MCP
consent flow. Apex /mcp-grant SPA reads the OAuth nonce + registered
client metadata so users see the real client_name + redirect host
(anti-phishing). On Authorize the page POSTs to mcp-grant-mint, gets
back a fixed redirect URL with a HMAC-signed grant token, and navigates.
- mcp-grant.html + src/mcp-grant-main.ts: Vite multi-page entry (matches
existing settings.html / live-channels.html convention).
- api/internal/mcp-grant-mint.ts: Clerk-auth POST. Re-checks tier ≥ 1,
re-validates redirect_uri allowlist (defense-in-depth), HMAC-signs
{userId, nonce, exp+5min}, SETEX mcp-grant:<nonce>, returns FIXED
redirect URL (https://api.worldmonitor.app/oauth/authorize-pro).
- api/internal/mcp-grant-context.ts: GET companion for the SPA to fetch
the real client_name + redirect_host. Same Pro-tier gate as mint.
- api/_mcp-grant-hmac.ts: shared sign/verify helper. Signature is over
exact post-base64url-decode bytes (key-order/whitespace independent).
U5 imports verifyGrant from this module.
- vercel.json: /mcp-grant → /mcp-grant.html rewrite + no-store header
rule. Catch-all SPA fallback adjusted to exclude the new page.
- api/oauth/register.js: export isAllowedRedirectUri so DCR allowlist is
reused (no parallel impl).
- 35 tests; full deploy-config + edge-functions + mcp + pro-mcp-token
suites green.
Plan unit U3.
* feat(oauth): consent page Pro-CTA-default + 'Use API key instead' (U4)
R1 of the plan: a logged-in Pro user authorising MCP from claude.ai
never sees the API-key input field by default. The consent page now
leads with a brand-green "Sign in with WorldMonitor Pro" CTA pointing
at the apex /mcp-grant page; the existing API-key form is hidden
behind a "Use API key instead" disclosure for Starter+ holders.
- Default state: form display:none; Pro CTA dominant.
- Error state: existing errorMsg path still works — when ke.textContent
is non-empty (or XHR-retry returns invalid_key), inline script auto-
reveals the form so the user doesn't lose context after a bad key.
- Deep-link: /oauth/authorize?...#api-key shows the form on initial
load, so Starter+ users can bookmark.
- Pro CTA href: https://worldmonitor.app/mcp-grant?nonce=<URL-encoded n>.
Apex page reads the OAuth nonce server-side (no client_name forwarded
via URL — already covered by U3's mcp-grant-context).
- nonce shared with U5: the same oauth:nonce:<n> row that U5's
/oauth/authorize-pro will GETDEL. Handler still mints exactly one
nonce per GET — no double-issue.
- XSS-escape preserved on client_name + redirect_host + nonce + errorMsg.
- 18 new tests; existing handler logic untouched.
Plan unit U4.
* feat(oauth): /oauth/authorize-pro bounce-back endpoint (U5)
Receives the apex grant bounce-back, validates the HMAC-signed grant,
atomically consumes both Redis nonces (mcp-grant + oauth:nonce), issues
a non-key Pro identity row in Convex via U2, and writes the OAuth code
with {kind:'pro', userId, mcpTokenId, ...} for U6 to read at exchange
time.
Security ordering (HMAC-first to avoid burning nonces on forged tokens):
1. verifyGrant (U3's _mcp-grant-hmac, no Redis)
2. URL-nonce vs payload-nonce match
3. GETDEL mcp-grant:<n>
4. strict {userId, exp} tuple match vs grant payload (forge defense)
5. GETDEL oauth:nonce:<n>
6. GET oauth:client:<client_id> + redirect_uri allowlist re-check
7. getEntitlements(userId) tier ≥ 1 re-check (lapse defense)
8. issueProMcpTokenForUser (U2)
9. SETEX oauth:code:<code> (kind:'pro', 600s)
10. 302 redirect_uri?code=<code>[&state=...] with Cache-Control:no-store
oauth:code shape (load-bearing for U6):
{kind:'pro', userId, mcpTokenId, client_id, redirect_uri,
code_challenge, scope:'mcp_pro'}
Best-effort revoke when SETEX fails after issueProMcpTokenForUser
succeeds — calls U2's no-throw revokeProMcpToken; logs orphan-row id
on revoke failure but never masks the original 500.
35 tests; vercel rewrite + api-route-exceptions registered as
external-protocol.
Plan unit U5.
* feat(oauth): McpAuthContext discriminated union + Pro token shape (U6)
Token endpoint and bearer resolver learn about Pro-shape tokens without
breaking Starter+ legacy paths.
api/_oauth-token.js:
- New resolveBearerToContext(token) returns a discriminated union:
{kind:'env_key', apiKey} -- legacy WORLDMONITOR_VALID_KEYS resolution
{kind:'pro', userId, mcpTokenId} -- new Pro identity
- resolveApiKeyFromBearer kept as backward-compat wrapper. Returns
apiKey for env_key kind, null for pro kind (so legacy callers can't
mis-handle a Pro bearer).
api/oauth/token.js → api/oauth/token.ts:
- Converted to TypeScript so it can import validateProMcpToken from
server/_shared/pro-mcp-token cleanly. Vercel routes by basename;
/oauth/token rewrite unaffected.
- Default handler wires injected deps via tokenHandler(req, deps) for
testability (mirrors U3/U5 pattern).
- authorization_code branch: dispatches on codeData.kind. Pro path
validates client_id + redirect_uri bind, PKCE-verifies, mints tokens
via storeProTokens (object shape). Legacy path unchanged.
- refresh_token branch: Pro refreshes call validateProMcpToken
(per-request Convex hit, no positive cache); revoked row → invalid_grant.
Cross-user defensive guard: Convex-returned userId must match bearer's.
family_id, mcpTokenId, scope ('mcp_pro') preserved across rotation.
- client_credentials grant untouched.
oauth:token:<uuid> shapes:
- Legacy (preserved): JSON.stringify("<sha256-hex-64>") or "<fingerprint-16>"
- Pro (new): JSON.stringify({kind:'pro', userId, mcpTokenId})
- oauth:refresh:<uuid> Pro shape includes client_id, scope, family_id
for rotation discipline (matches legacy semantics).
26 new tests; sibling suites (oauth-authorize, oauth-authorize-pro, mcp,
pro-mcp-token — 101 tests) regress green. tsc -p tsconfig.api.json clean.
Plan unit U6.
* feat(mcp): Pro identity, atomic INCR-first quota, internal HMAC fetch (U7)
Behavioral heart of the Pro MCP plan. api/mcp.ts now resolves bearers
as McpAuthContext (env_key | pro), runs Pro-specific pre-checks, and
enforces a hard 50/UTC-day cap via INCR-first reservation with
DECR-rollback on cap-exceed and on tool-dispatch failure.
Pre-dispatch chain for Pro context (synchronous, in order):
1. validateProMcpToken(mcpTokenId) — null = 401 revoked
2. defensive userId match — 401 cross-user
3. getEntitlements(userId) tier ≥ 1, mcpAccess: true, validUntil — fail-closed
4. per-minute slidingWindow(60, 60s) keyed pro-user:<userId> — fail-open
5. for tools/call ONLY: pipeline INCR + EXPIRE 172800
newCount > 50 → DECR rollback + -32029 + 429 + Retry-After
INCR Redis transient → -32603 + 503 + Retry-After:5 (hard-cap correctness)
6. dispatch tool; on throw → DECR rollback + -32603
Internal-HMAC tool fetches (replaces X-WorldMonitor-Key for Pro):
payload = ${ts}:${METHOD}:${pathname}:${queryHash}:${bodyHash}:${userId}
queryHash = SHA-256(canonicalQueryString) sorted keys, URL-encoded
bodyHash = SHA-256(bodyBytes) SHA-256("") for empty
sig = HMAC-SHA-256(MCP_INTERNAL_HMAC_SECRET, payload)
headers = X-WM-MCP-Internal: ${ts}.${b64url(sig)}, X-WM-MCP-User-Id
Replay defense: payload binds method+path+queryHash+bodyHash, so a
captured signature for /api/news/v1/list-feed-digest cannot be reused
on /api/intelligence/v1/deduct-situation. ±30s window.
server/_shared/mcp-internal-hmac.ts: single source of truth for sign
helpers. U8 verify imports the same canonicalisation primitives.
server/_shared/pro-mcp-token.ts: dailyCounterKey(userId), 50/172800s
constants exported for U9 to read the SAME Redis key the enforcement
writes.
_execute signatures changed (params, base, context) — option A from
the plan. Cache-only tools unchanged.
waitUntil unused: Convex internal-validate-pro-mcp-token schedules
touchProMcpTokenLastUsed itself (verified at convex/http.ts:1035-1040
from U1's commit 4530bdf).
22 new tests + 23 Starter+ regression tests in tests/mcp.test.mjs;
sibling chain (oauth-token, pro-mcp-token, oauth-authorize-pro,
mcp-grant-mint, mcp-proxy) all green. tsc + biome clean.
Plan unit U7.
* feat(gateway): HMAC-verify internal-MCP requests + sanitised propagation (U8)
Verifier counterpart to U7's signer. Gateway accepts X-WM-MCP-Internal
HMAC headers in lieu of an API key for Pro internal-MCP traffic, then
hands a freshly-constructed Request with sanitised trusted markers to
the downstream handler. isCallerPremium learns to honour those markers
so Pro framework/systemAppend semantics survive in summarize-article,
get-country-intel-brief, deduct-situation.
Gateway flow at the top of createDomainGateway's handler:
1. STRIP inbound x-wm-mcp-internal-verified + x-user-id (anti-injection)
2. If X-WM-MCP-Internal present:
verifyInternalMcpRequest re-canonicalises and HMAC-verifies the
request shape (method+pathname+queryHash+bodyHash+userId), ±30s
window, timing-safe compare. Failure → 401 invalid_internal_mcp_
signature (no fall-through to validateApiKey — present-but-bad is
a deliberate forge attempt).
getEntitlements(userId): tier ≥ 1 + mcpAccess === true (fail-closed).
Construct NEW Request via new Request(url, {method, body, headers})
with x-wm-mcp-internal-verified=<per-process nonce> +
x-user-id=<verified userId>. Skip validateApiKey + IP rate limit.
isCallerPremium adds a NEW first branch: timing-safe compare of
x-wm-mcp-internal-verified against the per-process nonce + non-empty
x-user-id + defensive getEntitlements re-fetch. Catches direct-edge-
function consumers (api/widget-agent.ts, chat-analyst.ts, me/entitlement.ts,
v2/shipping/webhooks/...) that don't run the gateway strip step.
DEFENSE-IN-DEPTH BEYOND PLAN: trusted marker is a per-process random
16-byte nonce, NOT the literal '1'. Sweep found direct edge functions
calling isCallerPremium without gateway-strip protection — a constant
marker would be spoofable from outside. The nonce is born once at
process startup, only the gateway knows it, comparison is timing-safe.
verifyInternalMcpRequest lives in server/_shared/mcp-internal-hmac.ts
next to U7's sign helpers — single source of truth for canonical form
+ payload + ±30s window. Drift between sign and verify is structurally
impossible.
26 new tests + 100 sibling regression tests (gateway-cdn-origin-policy,
premium-stock-gateway, premium-fetch, pro-mcp-token, mcp). tsc clean.
Plan unit U8.
* feat(catalog): mcpAccess feature flag + env vars + Pro-MCP docs (U10)
Catalog, schema, type, env, and docs scaffolding to make the Pro MCP
flow deployable end-to-end. U9 unblocks on this — settings UI gates on
hasFeature('mcpAccess') (distinct from existing apiAccess paywall).
- convex/config/productCatalog.ts: PlanFeatures.mcpAccess on every tier.
Free=false. Pro_monthly/Pro_annual=true. API_starter / API_starter_annual
/ API_business=true. Enterprise=true. Pro plan marketing copy mentions
"MCP access for Claude Desktop & other AI clients (50 calls/day)".
- convex/schema.ts + convex/payments/cacheActions.ts: mcpAccess optional
field on the entitlements validator; legacy rows pass.
- server/_shared/entitlement-check.ts: CachedEntitlements.features
mcpAccess?: boolean (consumed by U7 + U8).
- src/services/entitlements.ts: EntitlementState.features.mcpAccess?:
boolean — unblocks hasFeature('mcpAccess') for U9's settings tab gate.
- .env.example: new "Pro MCP" section with MCP_PRO_GRANT_HMAC_SECRET
(apex grant bridge, U3/U5) and MCP_INTERNAL_HMAC_SECRET (gateway
service-auth, U7/U8). Both 32-byte base64; both required.
- docs/mcp-server.mdx: "Pro sign-in flow" section + 50/day quota
callout + "Connected MCP clients" management pointer.
Bundled fixups for U7/U8 strict-mode TS errors:
- mcp-internal-hmac.ts: bufferToBase64Url uses for-of (avoids
noUncheckedIndexedAccess error on bytes[i]).
- gateway.ts: drop unused INTERNAL_MCP_USER_ID_HEADER import.
tsc -p tsconfig.api.json + tsc -p tsconfig.json clean.
9 entitlements tests + 71 gateway-internal-mcp + mcp tests pass.
Plan unit U10.
* feat(settings): Connected MCP clients tab + quota endpoint + revoke (U9)
User-facing surface that makes the Pro MCP plan visible. Settings UI
gets a new "Connected MCP clients" tab gated on hasFeature('mcpAccess')
(distinct from the existing apiAccess-gated 'API Keys' tab — Pro users
without apiAccess see only the new tab).
Endpoints:
- GET /api/user/mcp-quota → {used, limit:50, resetsAt}. Reads the
SAME Redis key (dailyCounterKey from U2) that U7 enforcement writes.
Single source of truth.
- POST /api/user/mcp-revoke {tokenId} → forwards Clerk-derived userId
(NOT client-supplied) to Convex internal-revoke-pro-mcp-token, then
calls invalidateProMcpTokenCache so any in-flight bearer with this
tokenId 401s within 60s. 404 on NOT_FOUND, 409 on ALREADY_REVOKED.
Tenancy enforced inside Convex (row.userId === userId).
UI (in src/components/UnifiedSettings.ts, no child component split —
matches existing API Keys tab pattern):
- Tab gated on hasFeature('mcpAccess') so Pro users see it without
needing apiAccess.
- Quota header auto-refreshes every 30s; interval cleared on tab-
switch / close / destroy.
- Each row: name, createdAt, lastUsedAt (relative), revokedAt
(struck-through + badge); active rows show Revoke button with
confirm() dialog (matches existing pattern).
- Empty state: click-to-copy https://api.worldmonitor.app/mcp.
Frontend service in src/services/mcp-clients.ts: listMcpClients (Convex
query), revokeMcpClient (POST /api/user/mcp-revoke), fetchMcpQuota.
11 + 14 = 25 new tests; api-route-exceptions registered as
internal-helper.
Plan unit U9.
---
PLAN COMPLETE — 10/10 units shipped. Follow-up polish noted in U9
report: CSS rules for .mcp-clients-* classes (suggest copy from
.api-keys-* rules); empty-state URL is hardcoded to
api.worldmonitor.app/mcp.
* style(settings): mcp-clients CSS rules mirroring api-keys (U9 polish)
* fix(pro-mcp): apply Tier-2 code-review residuals — security + correctness
Review pass after U10 (3 reviewers: code-reviewer, ce-security-reviewer,
ce-adversarial-reviewer). Findings BLOCKING + HIGH + MEDIUM + LOW
applied per user direction.
BLOCKING (gateway):
- Add validUntil check to gateway's HMAC-verify entitlement re-check.
Defense-in-depth against Convex-fallback paths returning a stale row
past its expiry.
HIGH — adv-001: cross-user OAuth nonce hijack:
- mcp-grant-mint now claims oauth nonces atomically via Upstash SET NX
semantics. If the nonce is already claimed by a DIFFERENT userId,
return 403 NONCE_CLAIMED_BY_OTHER_USER. Same userId multi-tab retry
is idempotent: re-sign the grant using the existing claim's exp so
authorize-pro's strict tuple equality still matches.
- mcp-grant-context performs the same cross-user check so the apex SPA
refuses to render consent context for a hijacked nonce.
MEDIUM — adv-008: refresh-token loss during Convex transient:
- validateProMcpToken returns a discriminated union {ok:'valid',userId}
| {ok:'revoked'} | {ok:'transient'}. Negative-cache writes only on
'revoked'. validateProMcpTokenOrNull wrapper preserves backward
compat for callers that don't need the distinction (per-request MCP
edge stays fail-closed).
- token.ts refresh path: on 'transient', best-effort restore the
consumed refresh token to Redis with the original TTL and return
503 server_error + Retry-After:5. Client retries; if Convex recovers,
refresh succeeds. No more permanent session loss on a Convex blip.
MEDIUM — adv-002: counter overshoot lockout:
- mcp.ts: on cap-exceeded after DECR rollback, if newCount is still
far above PRO_DAILY_QUOTA_LIMIT (overshoot from prior transient),
pipelined INCR+DECR probe + bounded DECR-sweep to converge the
counter back near the limit. Prevents one Redis hiccup from locking
out a paying Pro user for the rest of the UTC day.
MEDIUM — code-reviewer #4: 5-row cap race in Convex:
- mcpProTokens.issueProMcpToken now revokes ALL active rows beyond
MAX-1 (sorted by createdAt) on each issue, so concurrent inserts
that briefly produce 6 active rows converge back to 5 on the next
call.
LOW:
- adv-003: executeTool throws cache_all_null when every cache read
returns null AND there were keys configured — triggers DECR rollback
in dispatchToolsCall instead of silently burning quota on a degenerate
empty result.
- code-reviewer #10: gateway strips X-WM-MCP-Internal + X-WM-MCP-User-Id
from the trusted Request before forwarding to handler (no info leak).
- adv-004: 256 KB body cap (Content-Length + post-buffer count) on
both gateway strip and HMAC-verify paths — bounds memory amplification.
- adv-006: dailyCounterKey now env-prefixes (preview deploys can't
collide with production traffic on the same Upstash instance).
Reader (mcp-quota.ts) and writer (mcp.ts) share byte-identical keys
via the helper.
- adv-007 / code-reviewer #5: signInternalMcpRequest throws on
Blob/FormData/ReadableStream/plain-object bodies instead of silently
JSON.stringify'ing them (which would produce a hash that can't match
the wire bytes).
- code-reviewer #9: timestamp regex tightened to ^[0-9]{1,15}$ so
future ms-precision timestamps don't silently truncate through Number().
- code-reviewer #8: MCP_INTERNAL_HMAC_SECRET preflight in runProPreChecks
surfaces config errors at auth-resolution rather than mid-tool-fetch.
NITPICK:
- code-reviewer #6: rewritten readNegCache comment.
- code-reviewer #7: malformed-body error now reports reason
'malformed_request' instead of misleading 'auth_401'. New
RequestReason value added to usage.ts enum.
30 new tests + ~14 adapted; full convex suite 247/247, edge/server
suites 254/254. tsc clean across both tsconfig.json and tsconfig.api.json.
Plan: docs/plans/2026-05-10-001-feat-pro-mcp-clerk-auth-quota-plan.md
* chore(pro-marketing): regenerate /pro bundle for U10 catalog copy
U10's productCatalog.ts added an mcpAccess feature flag and updated the
Pro plan description copy to "MCP access for Claude Desktop & other AI
clients (50 calls/day)". scripts/generate-product-config.mjs cascades
that copy into pro-test/src/generated/tiers.json, which the /pro
marketing app builds into public/pro/assets/.
Vercel does NOT rebuild pro-test on deploy — public/pro/ ships whatever
is committed. Pre-push hook auto-ran the build and produced a new bundle
hash; this commit lands the regenerated artifacts so deploy picks them up.
Plan unit U10 (follow-on artifact).
* fix(pro-mcp): apply external review round-2 findings (P1+P2+P3)
Round-2 review on PR #3646 surfaced 5 must-fix issues:
P1 — vercel.json `(?:...)` rejected by Vercel CI:
- Replace `mcp-grant(?:\\.html)?` with explicit `mcp-grant\\.html|mcp-grant`
inside the SPA catch-all negative-lookahead (lines 18 + 134).
- Split the headers `/mcp-grant(?:\\.html)?` source rule into two explicit
entries — Vercel's path-to-regexp `source` field doesn't accept `(?:...)`
with `?` quantifier as a standalone source.
- Update tests/deploy-config.test.mjs expectations to match.
P1 — api/api-route-exceptions.json points at deleted file:
- Update the entry path from `api/oauth/token.js` (deleted in U6) to
`api/oauth/token.ts`. `node scripts/enforce-sebuf-api-contract.mjs`
was failing pre-push CI.
P1 — mcpAccess migration gap on existing entitlement rows:
- convex/entitlements.ts now read-time merges `getFeaturesForPlan(planKey)`
defaults under stored features. Pre-U10 rows lacking `mcpAccess` see the
catalog default surfaced immediately, with NO wait for the next Dodo
webhook to rewrite the row. Stored features still win on conflict
(preserves per-user overrides). Mirrored explicitly in
convex/mcpProTokens.ts:55 (issueProMcpToken's direct ctx.db.query path
computes the same merge inline since it bypasses the query handler).
P2 — grant/issue path missing mcpAccess gate (let tier-1-without-mcpAccess
users complete OAuth then fail every tools/call at the gateway):
- mcp-grant-context.ts:95, mcp-grant-mint.ts:236, authorize-pro.ts:356,
convex/mcpProTokens.ts:55 now ALL gate on `tier ≥ 1 && mcpAccess === true`,
matching the downstream MCP-edge runProPreChecks check. Widens the
`getEntitlements` deps return type at all three handlers.
P3 — inline theme script blocked by global CSP:
- mcp-grant.html:34 inline `<script>` is removed (global CSP at vercel.json:92
is hash-allowlist-based and we don't allowlist this page's hashes).
- Theme application moved into the bundled module at src/mcp-grant-main.ts
(top of file, runs on module evaluation). Brief default-theme flash on
light-preference users is acceptable for a transient consent UI.
10 new tests covering: mcpAccess: false at each of the 4 gates,
mcpAccess: undefined (legacy row) at each of the 4 gates, and the
read-time catalog merge in both directions (legacy gets default,
override preserved).
Full gate post-fix:
- Convex 249/249 (+2 new merge tests).
- Edge/server 280/280 (+10 new gate tests).
- tsc both configs clean.
- sebuf contract clean (was failing before P1.2).
- Sentry coverage clean.
- deploy-config tests assert new explicit-source rules.
Plan: docs/plans/2026-05-10-001-feat-pro-mcp-clerk-auth-quota-plan.md
* fix(entitlement-cache): treat pre-U10 cache entries lacking mcpAccess as stale
Reviewer round-2 P2 (cache layer): _getEntitlementsImpl returned hot
Redis cache entries as-is, bypassing the read-time catalog merge that
convex/entitlements.ts:50 applies on the Convex read path. Paying users
with cache entries written before plan 2026-05-10-001 U10 deployed see
mcpAccess !== true at the grant/MCP gates and are blocked for up to
the 15-min cache TTL.
Cache predicate now also requires `typeof features.mcpAccess === 'boolean'`.
Legacy entries (mcpAccess: undefined) fall through to Convex, which
returns the merged shape and the cache is rewritten with the post-U10
layout. Self-healing, bounded to one extra Convex round-trip per
affected user during the migration window.
Targeted choice over alternatives:
- Importing convex/config/productCatalog into server/_shared to apply
the merge at the cache layer would pull non-edge-safe deps. Rejected.
- Treating ALL cached entries as stale would defeat the cache layer.
Rejected.
- typeof check on the new field is the minimum delta that fixes the
migration window without restructuring.
Test fixture in server/__tests__/entitlement-check.test.ts updated:
makeEntitlements now includes mcpAccess (tier ≥ 1 → true). Two new
focused tests:
1. Legacy cache entry without mcpAccess → cache rejected → Convex
round-trip → result returned (verifies fetch called once).
2. Post-U10 cache entry WITH mcpAccess → cache honored → no Convex
round-trip (verifies fetch called zero times).
Convex 251/251 (+2 new cache tests).
Plan: docs/plans/2026-05-10-001-feat-pro-mcp-clerk-auth-quota-plan.md
5 tasks
koala73
added a commit
that referenced
this pull request
May 19, 2026
…9 regression) The 2026-05-19 Pro brief shipped "How nuclear war would impact the global food system" from Bulletin of Atomic Scientists as CRITICAL story #6, sitting alongside breaking news about the Iran-Israel war and the WHO Ebola declaration. The brief promises event-driven intelligence — a Bulletin analysis essay is not an event. The existing classifier missed it on all three current signals: - STRONG #1 (URL section /opinion/): Bulletin URLs have no opinion- style path because the WHOLE SITE is commentary, no hard-news section to distinguish from. - STRONG #2 (headline prefix "Opinion:"): hard-news-shaped title. - CORROBORATING (quote-wrap + columnist framing): description reads like a news lede. The signal IS the publisher. Add STRONG #3: a hand-curated allowlist of commentary-only publishers, matched by hostname (suffix-anchored to permit `newsletter.<host>` / `m.<host>` while rejecting typo-domains like `evilthebulletin.org`). Initial list (per docs/plans/2026-05-19-001 U1): - thebulletin.org — Bulletin of Atomic Scientists - project-syndicate.org — op-eds from world leaders / academics - foreignaffairs.com — CFR's analysis quarterly - foreignpolicy.com — Foreign Policy magazine - warontherocks.com — defense analysis blog Maintenance commitment: quarterly review against `droppedOpinion` telemetry to catch (a) new commentary publishers to add, (b) listed publishers that launched a hard-news section. Rollback path is remove-from-Set, never per-URL exceptions (cruft). Pattern mirrors the existing safePathname/STRONG_URL_SEGMENTS shape; new safeHostname helper parses URL().hostname so the match is on the parsed host (not raw .includes() on the link string — closes the tracking-param injection vector documented in PR #3748). 7 new test cases cover the May 19 regression, the 4 other publishers, subdomain matching, typo-domain rejection, tracking-param / fragment spoofing, malformed URLs, and the "allowlisted host PLUS hard-news content → still opinion" rule. This is PR-1 of the 4-PR Phase 1 wave from docs/plans/2026-05-19-001-fix-brief-content-quality-regressions-plan.md.
koala73
added a commit
that referenced
this pull request
May 19, 2026
…9 regression) (#3835) The 2026-05-19 Pro brief shipped "How nuclear war would impact the global food system" from Bulletin of Atomic Scientists as CRITICAL story #6, sitting alongside breaking news about the Iran-Israel war and the WHO Ebola declaration. The brief promises event-driven intelligence — a Bulletin analysis essay is not an event. The existing classifier missed it on all three current signals: - STRONG #1 (URL section /opinion/): Bulletin URLs have no opinion- style path because the WHOLE SITE is commentary, no hard-news section to distinguish from. - STRONG #2 (headline prefix "Opinion:"): hard-news-shaped title. - CORROBORATING (quote-wrap + columnist framing): description reads like a news lede. The signal IS the publisher. Add STRONG #3: a hand-curated allowlist of commentary-only publishers, matched by hostname (suffix-anchored to permit `newsletter.<host>` / `m.<host>` while rejecting typo-domains like `evilthebulletin.org`). Initial list (per docs/plans/2026-05-19-001 U1): - thebulletin.org — Bulletin of Atomic Scientists - project-syndicate.org — op-eds from world leaders / academics - foreignaffairs.com — CFR's analysis quarterly - foreignpolicy.com — Foreign Policy magazine - warontherocks.com — defense analysis blog Maintenance commitment: quarterly review against `droppedOpinion` telemetry to catch (a) new commentary publishers to add, (b) listed publishers that launched a hard-news section. Rollback path is remove-from-Set, never per-URL exceptions (cruft). Pattern mirrors the existing safePathname/STRONG_URL_SEGMENTS shape; new safeHostname helper parses URL().hostname so the match is on the parsed host (not raw .includes() on the link string — closes the tracking-param injection vector documented in PR #3748). 7 new test cases cover the May 19 regression, the 4 other publishers, subdomain matching, typo-domain rejection, tracking-param / fragment spoofing, malformed URLs, and the "allowlisted host PLUS hard-news content → still opinion" rule. This is PR-1 of the 4-PR Phase 1 wave from docs/plans/2026-05-19-001-fix-brief-content-quality-regressions-plan.md.
This was referenced May 19, 2026
shubham1206agra
added a commit
to shubham1206agra/worldmonitor
that referenced
this pull request
May 21, 2026
The unrest fan-out previously hardcoded a 5.5s setTimeout between theme calls, which forced fan-out tests to wait real-time (~11s each). Pull _sleep and _jitter out of _proxyOpts (same defaults pattern fetchGdelt- ViaProxy uses), so tests can pass noSleep/noJitter and run instantly. Production pacing is now 1.5-3s jittered between themes. Also clean up test koala73#6's stale assertion messages — calls === 4 now documents the breakdown ("theme 1: 1 throw + 1 retry; themes 2,3: 1 call each"), and "Fifteen mentions" is corrected to 11 (composite dedup blocks the two shared URLs across themes: 5 + 3 + 3). Adds a /11 reports/ match to lock the post-dedup count.
koala73
added a commit
that referenced
this pull request
May 26, 2026
…Loc dedup (#3852) (#3853) * fix(seeders): switch GDELT GKG queries to single-theme fan-out + URL dedup v1 GKG GeoJSON only accepts one theme tag per call, and the previous free-text keyword queries weren't matching GDELT's tag-based index, returning non-filtered events which is useless for the usecase. Fan out both seeders (ais-relay positive events, seed-unrest-events) across themes, merge into a shared locationMap, and dedup by article URL across calls so multi-theme articles don't inflate counts. Also fix param casing (QUERY/MAXROWS) and add a urltone > 5 gate for the positive-events path. The unrest seeder now tolerates partial theme failures and only throws if all calls fail. * Update variable name Update const POSITIVE_TONE_MIN to POSITIVE_TONE_THRESHOLD to better reflect the usage of the constant Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Apply suggestion from @shubham1206agra * Fixed out-of-scope variable bug * fix(seeders): composite (url, location) dedup + multi-field URL resolution GKG v1 emits one feature per (article, location), so URL-only dedup collapses multi-location articles into a single location. Key dedup on (url, lat/lon bucket) instead, and resolve the URL via the same multi-field walk (extractGdeltSourceUrls / gkgFeatureUrl) used for source attribution — features carrying only source_url were bypassing dedup entirely. Lower POSITIVE_TONE_THRESHOLD from 5 → 2. Live volume check showed > 5 yielded ~1 positive event vs ~9 at > 2; the panel was going effectively empty. Add test coverage for the fan-out contract introduced in this PR: counts merge across themes at the same location, partial-failure tolerance (one theme dies, others still aggregate), all-themes-fail throws lastError, composite-key dedup preserves multi-location articles, and source_url-only features are deduped across themes. * refactor(seeders): inject _sleep/_jitter for fan-out pacing The unrest fan-out previously hardcoded a 5.5s setTimeout between theme calls, which forced fan-out tests to wait real-time (~11s each). Pull _sleep and _jitter out of _proxyOpts (same defaults pattern fetchGdelt- ViaProxy uses), so tests can pass noSleep/noJitter and run instantly. Production pacing is now 1.5-3s jittered between themes. Also clean up test #6's stale assertion messages — calls === 4 now documents the breakdown ("theme 1: 1 throw + 1 retry; themes 2,3: 1 call each"), and "Fifteen mentions" is corrected to 11 (composite dedup blocks the two shared URLs across themes: 5 + 3 + 3). Adds a /11 reports/ match to lock the post-dedup count. * fix(seeders): preserve GDELT cache on all-failed fanout --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Elie Habib <[email protected]>
koala73
added a commit
that referenced
this pull request
May 26, 2026
…ns + queries + service + UI (#3621) * feat(country-codes): add toIso2 normalizer for alpha-2/3/name input (U1) Foundation for the followed-countries watchlist primitive. Normalizes any country input form (ISO-3166 alpha-2, alpha-3, lowercase, common country names) to canonical alpha-2 uppercase. Returns null on unrecognized input. Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U1 * feat(convex): add followedCountries + aggregate counter tables (U12) Two new Convex tables for the followed-countries watchlist primitive: - followedCountries: { userId, country, addedAt } indexed by_user, by_country, by_user_country. Per-country reverse lookup via by_country enables future fan-out (digest, breaking-news relay). - followedCountriesCounts: { country, count, updatedAt } indexed by_country. Aggregate counter maintained atomically by U13 mutations so public countFollowers query is O(1) per call rather than O(n) on by_country.collect(). Constants in convex/constants.ts: FREE_TIER_FOLLOW_LIMIT=3 (server- authoritative cap), MAX_MERGE_INPUT=100 (anti-abuse ceiling), COUNTRY_COUNT_PRIVACY_FLOOR=5 (returned-as-zero threshold for public counts). No migration; both tables empty on creation. Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U12 * feat(convex): add followedCountries mutations + ISO-2 registry validator (U13) Three server-authoritative mutations on the new followedCountries table, with atomic counter maintenance for the aggregate followedCountriesCounts table: - followCountry({country}): auth + ISO-2 registry validate + idempotent on (userId, country) + free-tier cap (server-enforced via ConvexError({kind:'FREE_CAP'})) + atomic counter +1. - unfollowCountry({country}): auth + validate + idempotent + atomic counter -1 (max(0, ...) defensive). - mergeAnonymousLocal({countries}): auth + MAX_MERGE_INPUT ceiling (anti-abuse) + ISO-2 registry filter + first-seen dedupe + bounded accept for free users (cap-fitting; over-cap → droppedDueToCap[]) + atomic counter +N. ISO-2 registry validator at convex/lib/iso2.ts mirrors the canonical alpha-2 set from src/utils/country-codes.ts. Both registries must stay in lockstep (documented inline). All errors typed ConvexError({kind, ...}) with object data per the convex-error-string-data-strips-errordata-on-wire memory. 32 new tests covering: auth/validation, free-tier cap (under, at, exceeded), idempotency for both follow + unfollow, counter correctness across all paths (never goes negative), mergeAnonymous with grandfather rejection / partial accept / oversized input / duplicate inputs / mixed valid+invalid. Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U13 * feat(convex): followedCountries queries + relay endpoint (U14) Three queries + one relay HTTP action over the followedCountries + followedCountriesCounts tables: - listFollowed = query({}): auth'd reactive query for current user; returns string[] sorted by addedAt asc; [] when no auth identity. Drives the client-side reactive subscription (U2/U3). - countFollowers = query({country}): public no-auth query backed by the aggregate counter table — O(1) per call, not O(n) on by_country.collect(). Privacy floor (COUNTRY_COUNT_PRIVACY_FLOOR=5) returns 0 below threshold to limit follower-set inference. Drives future 'X people watching' social-proof UI. - listFollowersPage = internalQuery({country, cursor?, limit}): internal-only paginated cursor on by_country index, limit clamped [1, 500]. NEVER exposed publicly (declared as internalQuery, NOT query — the typecheck-level privacy boundary). Drives future per-country fan-out (digest, breaking-news relay). - internalListFollowedForUser = internalQuery({userId}): internal helper used by the relay endpoint (which has no Clerk identity). POST /relay/followed-countries HTTP action mirrors the existing /relay/user-preferences pattern: shared-secret auth via timingSafeEqualStrings, body {userId}, returns {countries: string[]}. Used by PR C's brief composer to read followed-countries server-side. 29 new tests covering: per-user reads, sort order, no-auth empty, counter-table-backed counts, privacy floor edges (4 vs 5), cursor pagination across multi-page result, limit clamp [1,500], @ts-expect-error privacy assertion that listFollowersPage is NOT on api.* (only internal.*), relay 200/400/401 paths. Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U14 * feat(followed-countries): client service — anonymous mode + entitlement gating (U2) Single client-side owner of watchlist semantics for the followed- countries primitive. U2 ships the anonymous (localStorage) path fully working; signed-in mode plumbing is stubbed with explicit TODO(U3) markers for the next unit to fill in. Public API: - getFollowed(): string[] - isFollowed(code: string): boolean - addCountry(input): Promise<FollowMutationResult> - removeCountry(input): Promise<FollowMutationResult> - subscribe(handler): unsubscribe - serviceEntitlementState(): 'pro' | 'free' | 'loading' - WM_FOLLOWED_COUNTRIES_CHANGED custom event Discriminated-union return (memory: discriminated-union-over-sentinel- boolean): { ok: true } | { ok: false, reason: 'DISABLED' | 'INVALID_INPUT' | 'FREE_CAP' | 'ENTITLEMENT_LOADING' | 'HANDOFF_PENDING' | 'STORAGE_FULL' }. Never throws. Anonymous-vs-loading distinction (Codex deepening round-1 P1): serviceEntitlementState() returns 'free' when getCurrentClerkUser() is null, regardless of entitlement state — anonymous users never block on entitlement loading. Only signed-in users with null entitlement state enter 'loading'. Storage: localStorage 'wm-followed-countries-v1' = JSON.stringify( { countries: string[] }). NOT enrolled in CLOUD_SYNC_KEYS — the dedicated Convex table replaces that path for this feature. Feature flag VITE_FOLLOW_COUNTRIES_ENABLED gates all mutations at the service layer (refusal at top of addCountry/removeCountry). Default ON; only '0' disables. 25 tests covering: happy paths, normalization (alpha-3 → alpha-2), idempotency, FREE_CAP cap enforcement, PRO unlimited, ENTITLEMENT_ LOADING (signed-in only), anonymous-never-loads, feature-flag DISABLED, corrupt/wrong-shape localStorage, STORAGE_FULL on quota throw. Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U2 * feat(followed-countries): sign-in handoff + Convex bridge (U3) Wires the signed-in mode of the followed-countries service: - Auth-state listener installed once at app boot. On every Clerk user transition, increments _handoffGeneration and captures userIdAtStart for any in-flight handoff. Post-await callbacks verify both BEFORE clearing localStorage / subscribing — resolves the in-flight auth race (Codex deepening round-1 P1). - Sign-in handoff orchestrator reads localStorage, calls api.followedCountries.mergeAnonymousLocal, clears localStorage on success, subscribes to listFollowed reactive query. - handoffPending UX: addCountry/removeCountry return HANDOFF_PENDING so FollowButton can show a syncing tooltip; getFollowed unions localStorage with the user-scoped subscription snapshot for stable display, BUT only if snapshot.userId matches current Clerk userId (Codex deepening round-2 P1 — no cross-user leak). - Sign-out / user-A → user-B: increments _handoffGeneration, unsubscribes, CLEARS _lastKnownSubscriptionSnapshot = null, resets _handoffState. localStorage retained for next anon session. - Network failure: _handoffState = 'failed', visibilitychange retry. Idempotency means safe to re-run mergeAnonymousLocal. - Convex error → reason mapping via err.data.kind (memory: convex-error-string-data-strips-errordata-on-wire). FREE_CAP preserves currentCount + limit. - Cap-drop event: when mergeAnonymousLocal returns droppedDueToCap[], dispatch WM_FOLLOWED_COUNTRIES_CAP_DROP for the upgrade-CTA toast (consumed by U4 FollowButton). 24 new sign-in handoff tests covering: empty/corrupt localStorage skip + clean, free-tier bounded accept with cap-drop event, network failure → visibilitychange retry, in-flight auth-race sign-out (gen guard drops result), in-flight user-swap (userIdAtStart guard drops result), HANDOFF_PENDING blocks writes, getFollowed user-scoped union, sign-out clears snapshot, sign-in→sign-out→different-user flow, reactive snapshot updates. Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U3 * feat(follow-button): reusable star button helper for 3 surfaces (U4) Single mountable factory used by CountryDeepDivePanel, CountryIntelModal, and CIIPanel rows in U5. Owns: - Visual states: outlined-star (unfollowed), filled-star (followed), spinner (entitlement loading), hidden (feature flag off). At-cap state shows 'Upgrade to follow more' tooltip pre-click. - Click handler: addCountry/removeCountry. FREE_CAP triggers the existing upgrade flow (mirroring notifications-settings.ts lazy- import pattern: openSignIn for anon, startCheckout for signed-in, fallback to /pro#pricing). HANDOFF_PENDING / DISABLED / loading → defensive no-op. - Reactivity: subscribes to WM_FOLLOWED_COUNTRIES_CHANGED and onEntitlementChange; teardown unsubscribes both. Idempotent teardown. - Anonymous-vs-loading distinction (Codex round-2 P1): driven by serviceEntitlementState() helper, NOT raw getEntitlementState(). Anonymous users render interactive (state a/b), only signed-in- awaiting-snapshot enters spinner state. Adds isFollowFeatureEnabled() exported helper to the service so the button gates on the same source of truth as the service. 18 tests covering visual states, anonymous click flow, entitlement- loading window with PRO/FREE resolution, subscription + teardown. Cap-drop toast (WM_FOLLOWED_COUNTRIES_CAP_DROP from U3) is NOT wired here — left as TODO for App-level toast service. CSS is semantic class names only; styling lands in PR B. Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U4 * feat(panels): mount FollowButton on Deep Dive, Intel Modal, CII rows (U5) Surfaces the followed-countries primitive on the three PR-A entry points. No other behavior changes (pin-to-top, filter chips, brief weighting are PR B / PR C). CountryDeepDivePanel: - FollowButton (size: md) inserted in header next to title - Teardown wired into resetPanelContent() + hide(); idempotent CountryIntelModal: - FollowButton (size: md) in header between country name and level - Mount on show(); teardown on showLoading()/hide(); idempotent CIIPanel: - FollowButton (size: sm) as first child of every .cii-country row - Map<countryCode, teardown> tracks per-row mounts; cleared on every wholesale rebuild + on destroy() to prevent listener leaks - Click stopPropagation so star toggle does NOT also trigger row-level onCountryClick (mirrors the existing cii-share-btn pattern) Semantic class names only (.wm-follow-btn + per-host wrappers); CSS lands in PR B's UX polish. Verification: npm run typecheck + typecheck:api clean; full test:data suite still green (7898/7898). Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U5 * fix(followed-countries): convex server-side hardening pass (Phase 1) P3 #21: followCountry now reads entitlement tier BEFORE collecting all user rows; PRO callers skip the O(N) `.collect()` of followedCountries since they have no cap to check. Free users are unchanged. P2 #12: countFollowers privacy-floor doc/code alignment — comment now matches the `<` comparator (1-4 followers → 0; 5+ → exact count). P2 #19: /relay/followed-countries userId validation tightened to mirror /relay/user-preferences rigor — non-empty string with bounded length (<=256 chars) instead of just truthy. Mitigates oversized / non-string abuse vectors. P2 #13: ISO-2 dual-registry parity test upgraded from size-only (`.size === 239`) to set-equality. Catches drift where one side has, e.g., 'XK' and the other has 'EU' with the same total count. `ISO2_TO_ISO3` is now exported from `src/utils/country-codes.ts`. Tests: 278 → 283 (added 1 set-equality, 1 PRO-skip-collect, 3 relay userId validation tests). Plan/Review reference: ce-code-review run 20260502-195816-dae403d7 * fix(followed-countries): service-core hardening pass (Phase 2) P1 #3: _runHandoff catch now uses _extractConvexErrorKind. Permanent ConvexError kinds (INPUT_TOO_LARGE, EMPTY_INPUT, UNAUTHENTICATED) skip the visibilitychange retry path: clear localStorage, transition to new 'failed-permanent' state, install the reactive subscription so signed-in reads still work. Transient errors (network / undefined kind) still retry. P1 #4: max-retry counter (5) + exponential backoff (1, 2, 4, 8, 16 seconds) gate the visibilitychange retry path. After exhaustion the state flips to 'failed-permanent' and no further retries are scheduled. _clearFailedHandoffForTests() exposed as the test recovery hook. Production has no equivalent today; sign-out / sign-in starts a fresh generation. Test seam _setHandoffBackoffForTests collapses the backoff schedule so tests don't have to wait seconds. P1 #5: signed-in addCountry/removeCountry now return HANDOFF_PENDING when the convex client is null instead of falling back to localStorage. Stale partial-writes that never reconcile with the authoritative table are no longer possible in signed-in mode. _setDepsForTests gains a 'force-null' literal so test injection can return null without falling through to the production importer. P1 #6: dropped the `if (existing.includes(code)) return {ok:true}` short-circuit on the SIGNED-IN branch of addCountry/removeCountry. The Convex mutation is itself idempotent and authoritative; the client-side snapshot is eventually consistent and could lie (e.g., another tab just unfollowed). Anonymous-mode short-circuit retained because localStorage IS the source of truth there. P1 #8: replaced unknown-typed ConvexClientLike/ConvexApiLike with FunctionReference<...> generics from convex/server. Mutation arg/result shapes (e.g., {country: code} vs {countries: code}) are now checked at the call site, eliminating the entire typo class. P1 #9: imports MergeAnonymousLocalResult from convex/followedCountries instead of hand-rolling an inline subset. P1 #10: cross-tab `storage` event listener installed alongside the auth-state listener. Filters on key === FOLLOWED_COUNTRIES_STORAGE_KEY and re-dispatches as WM_FOLLOWED_COUNTRIES_CHANGED so FollowButtons in other tabs re-render after a Tab-A mutation. P1 #11: signed-in addCountry/removeCountry capture {userIdAtStart, genAtStart} BEFORE the await, then call _authStillMatches() after the await. A sign-out / user-swap mid-mutation surfaces as HANDOFF_PENDING instead of letting user-A's success "land" while we're already user-B. P2 #20: empty-handoff path defers dispatchChanged until the first reactive snapshot lands. Tracks _initialSnapshotReceived; getFollowed() falls back to localStorage during the gap between 'complete' being set and the first onUpdate callback firing. Avoids a brief flash of empty-list rendering during the subscription warm-up window. Tests: data 7898 → 7911 (added 13 — INPUT_TOO_LARGE/EMPTY_INPUT/ UNAUTHENTICATED permanent-kind handling, plain-error transient guard, max-retry exhaustion + recovery hook, client-null HANDOFF_PENDING for both add and remove, P1 #6 stale-snapshot non-short-circuit, cross-tab storage event re-dispatch x2, post-await auth re-check, empty-handoff deferred dispatch). Convex 283/283 unchanged. Plan/Review reference: ce-code-review run 20260502-195816-dae403d7 * fix(follow-button): UI hardening — exhaustive switch + inFlight guard (Phase 3) P2 #16: added `assertNever(result)` default branch to the FollowButton onClick switch on `FollowMutationResult.reason`. When every variant is handled by a `case`, `result` narrows to `never` at the default; adding a new reason to `FollowMutationResult` will widen the residual type and produce a TS2345 ('not assignable to never') at typecheck time. Catches the future-variant bug class at compile time, with a runtime fallback for malformed test fakes. P2 #17: introduced an `inFlight` boolean closed over by the click handler. When a mutation is already pending, additional clicks are dropped silently (no duplicate addCountry/removeCountry fires). Cleared in finally{} so a thrown service-layer error doesn't latch the button. Without this, a rapid double-click on an unfollowed button produced TWO follow mutations — the service is idempotent on (user, country) but the second add was wasted network + counter-increment work and a toggle pattern (click on, click off) could land in the unintended state. Tests: data 7911 → 7913 (added inFlight rapid-double-click suppression test + assertNever runtime-guard sanity test). Plan/Review reference: ce-code-review run 20260502-195816-dae403d7 * chore(env): document VITE_FOLLOW_COUNTRIES_ENABLED in .env.example (Phase 4) P2 #18: adds the missing .env.example entry for the followed-countries feature flag. Mirrors the format of sibling flags (VITE_CLOUD_PREFS_ENABLED) and clarifies the default-on-unless-'0' semantics enforced by `isFeatureFlagEnabled()` in src/services/followed-countries.ts. Plan/Review reference: ce-code-review run 20260502-195816-dae403d7 * fix(followed-countries): per-user serialization doc — close TOCTOU cap-bypass (P0) Codex round-3 review run 20260502-195816-dae403d7 (adv-001 / adv-002) flagged a P0 cap-bypass on `followCountry` and `mergeAnonymousLocal`. Convex per-document OCC tracks reads at the DOCUMENT level, not at the index-range level — so two parallel `followCountry` mutations from the same user can both read empty/ under-cap from `followedCountries` (an index range), both pass the cap check, and both insert. Cap bypass + potential duplicate (userId, country) rows. The same shape applies to `mergeAnonymousLocal` from N tabs at sign-in. Mitigation: a per-user serialization document (new table `followedCountriesUserMeta`) that every mutation reads AND writes. Convex's real OCC then forces concurrent same-user mutations to serialize on this row: the loser of the race retries, re-reads the post-winner state (which now contains the winner's `(userId, country)` row + bumped count), and either passes correctly (still under cap), throws `FREE_CAP`, or returns idempotent. The denormalized `count` field also makes the cap check O(1) — happy side effect that closes P3 #21 (`.collect()` for cap purposes is gone). Schema: new table `followedCountriesUserMeta` keyed by userId, with a denormalized `count` and `updatedAt`. Convex auto-deploys schema before handlers in the same push, so single-PR shipping is safe. Mutations: - `followCountry`: read meta first, use `count` for cap check (free tier only), patch/insert meta at the END after row insert + counter +1. Idempotent (already-followed) path skips meta write — no observable change, no race to lose. - `unfollowCountry`: read meta first, decrement to `Math.max(0, count - 1)` at the end. Idempotent (no-row) path skips meta write. - `mergeAnonymousLocal`: read meta for the cap denominator, `existingRows` still required for the dedup set. Patch meta with `existingCount + accepted.length` at the end. Skip meta write when `accepted.length === 0`. Tests (+9, total 283 → 292): - Concurrent same-user same-country `Promise.all` → exactly 1 row + 1 idempotent response. - Concurrent same-user cap-boundary (2 seeded + 2 attempts on cap=3) → exactly 1 fulfilled, 1 rejected with FREE_CAP, final ≤ cap. - Concurrent mixed follow/unfollow → consistent end state, parity invariant. - Concurrent `mergeAnonymousLocal` from 5 tabs (free user) → final ≤ cap, no duplicate (userId, country) rows. - Concurrent `mergeAnonymousLocal` from 5 tabs (PRO user) → exactly the deduped union, all per-country counters at 1. - Meta-count parity invariant after mixed mutation sequence. - Idempotent paths (followCountry on existing, unfollowCountry on absent) don't bump/decrement meta count. - FREE_CAP throw rolls back all writes (transaction atomicity). Concurrency-test caveat documented in the test file: convex-test 0.0.43's TransactionManager (node_modules/convex-test/dist/index.js:1268) takes a single `_waitOnCurrentFunction` lock at top-level mutation begin, so `Promise.all` of mutations runs strictly sequentially in the mock. There is NO real OCC retry simulator. Tests therefore prove the FINAL-STATE INVARIANT — even when the second mutation runs back-to-back against the post-winner state, cap/idempotency/meta-parity hold. In production the Convex platform's OCC layer turns the same final-state invariant into the cap-bypass guarantee. See memory `convex-occ-retry-vs-app-cas-conflict- different-layers` for the layer separation. Test helper `seedFollowedCountries(t, userId, codes)` added to seed both the rows AND the user-meta row in parity for tests that bypass the mutation API. Files: - convex/schema.ts: +`followedCountriesUserMeta` table + index. - convex/followedCountries.ts: +`readUserMeta` / `writeUserMeta` helpers, meta read/write inserted into all 3 mutation paths, expanded inline docs on the OCC mechanism. - convex/__tests__/followed-countries-mutations.test.ts: +9 concurrent / parity tests + `seedFollowedCountries` helper + `readUserMetaCount` helper + caveat block on convex-test's serialized mock. Verification: `npm run typecheck` ✅, `npm run typecheck:api` ✅, `npm run test:convex` 292 / 292 ✅. * fix(followed-countries): pre-seeded sharded lock — close nested TOCTOU on user-meta create (P0 v2) Codex round-4 review of PR #3621 caught that the round-3 P0 fix (followedCountriesUserMeta per-user lock) did not actually close the cap-bypass: the meta document is created LAZILY on first mutation, and Convex per-document OCC tracks reads at the document level, not at the empty-index-range level. Two parallel first-ever mutations from the same brand-new user could both read meta=undefined and both INSERT, producing duplicate meta rows that break the next .unique() read AND re-open cap-bypass / counter double-increment. Fix: Approach B (pre-seeded sharded lock). - New table followedCountriesShards with one row per shard id in [0, SHARD_COUNT) (SHARD_COUNT=64 in convex/constants.ts), pre-seeded by _seedShards. - convex/lib/shards.ts::userIdToShard(userId) — deterministic djb2 hash. Frozen contract; changing it would silently remap users. - Every followCountry / unfollowCountry / mergeAnonymousLocal mutation reads its shard at the top (throws SHARDS_NOT_SEEDED loud if missing — operator error, never silent), then patches lastTouchedAt at the end of any non-idempotent path. The read+write pair on an ALREADY-EXISTING document is what triggers Convex OCC to serialize concurrent same-user mutations. - Tier 2 (existing user-meta row) kept additionally for the O(1) cap-check denominator and parity invariant — but its lazy create is now race-free under the shard lock. - Daily cron followed-countries-shards-seed at 03:00 UTC re-runs _seedShards (idempotent) so a missed deploy-seed step self-heals. - Public seedShards mutation for operator CLI: npx convex run --prod followedCountries:seedShards after a fresh deploy without waiting for the cron. Tests added (mutations test file, +6 tests, 292 -> 298): - first-ever follow on a brand-new user creates exactly 1 meta row - two back-to-back mergeAnonymousLocal calls on a brand-new user -> one meta row, no duplicates - operator running _seedShards after partial seed completes idempotently (0 steady-state, plugs holes only) - SHARDS_NOT_SEEDED throws when shards table is empty (operator error path, all three mutations) - public seedShards mutation reachable via operator CLI surface - userIdToShard determinism + range invariant Test fixtures (makeT() helper) call _seedShards before any mutation runs, mirroring the production deploy + cron post-condition. Files changed: - convex/constants.ts: SHARD_COUNT=64 - convex/schema.ts: followedCountriesShards table + index - convex/lib/shards.ts (new): userIdToShard djb2 - convex/followedCountries.ts: shard read/write at every mutation, _seedShards (internal) + seedShards (public operator) mutations - convex/crons.ts: daily _seedShards cron at 03:00 UTC - convex/__tests__/followed-countries-mutations.test.ts: makeT() helper, 6 new tests - convex/__tests__/followed-countries-queries.test.ts: makeT() helper (queries-test invokes followCountry, also needs shards) References: Codex review run /private/tmp/worldmonitor-pr3621-review/ findings_round4.md (P0 v2). Memory: convex-occ-retry-vs-app-cas-conflict-different-layers (layer separation: this is the app-side serialization layer that lets Convex OCC do its job). * fix(followed-countries): treat UNAUTHENTICATED as transient in handoff retry (P1) Codex round-4 P1: subscribeAuthState emits the current signed-in state IMMEDIATELY on subscribe, but Convex auth is not yet ready (the JWT has not been attached to the Convex client at that tick). mergeAnonymousLocal fires before Convex sees the auth -> throws ConvexError({kind:'UNAUTHENTICATED'}). The previous classification (Phase-2 P1 #3) put UNAUTHENTICATED in the PERMANENT-error list alongside INPUT_TOO_LARGE / EMPTY_INPUT, so every transient auth lag cleared localStorage and lost the anonymous follows. Two-part fix: (a) Treat UNAUTHENTICATED as TRANSIENT, not permanent. _runHandoff catch path no longer routes UNAUTHENTICATED to failed-permanent + removeLocalStorage. It falls through to _markFailedAndScheduleRetry, which arms the visibilitychange retry and counts toward MAX_HANDOFF_RETRIES (5). A genuinely-stuck auth mismatch eventually flips to failed-permanent after the budget is exhausted, same as the network-failure path. (b) Defer the merge until Convex auth is ready. waitForConvexAuth() exists at src/services/convex-client.ts:79 — it resolves when Convex's setAuth callback confirms the client is authenticated, with a 10s timeout. We import it and await it BEFORE the mergeAnonymousLocal call so the typical race never fires at all. On timeout we still attempt the call; the catch from (a) treats any resulting UNAUTHENTICATED as transient and the visibilitychange retry wins once Convex catches up. Test seam: _waitForConvexAuthFn module-level binding + new _setDepsForTests({waitForConvexAuth}) override so tests can drive the deferred-by-auth flow without going through the real Convex client. Tests added (tests/followed-countries-sign-in-handoff.test.mjs, +3 new tests, 1 modified — 37 -> 40 in this file): - first call throws UNAUTHENTICATED, visibility retry succeeds -> final state has merged data, localStorage cleared, no follows lost (the canonical scenario this fix targets) - UNAUTHENTICATED IS counted toward MAX_HANDOFF_RETRIES — 5 consecutive UNAUTHENTICATED throws -> failed-permanent (proves runaway-retry guard intact for genuinely-stuck auth) - waitForConvexAuth is awaited BEFORE the merge call (proves deferred-by-auth path is wired correctly) Modified: the original "UNAUTHENTICATED -> 'failed-permanent'; localStorage cleared" test is rewritten to assert the new behavior (state='failed', retry armed, localStorage retained) with an inline comment explaining the previous behavior was wrong. Files changed: - src/services/followed-countries.ts: import waitForConvexAuth from convex-client, _waitForConvexAuthFn seam, await before merge, drop UNAUTHENTICATED from permanent-error branch - tests/followed-countries-sign-in-handoff.test.mjs: 1 modified + 3 new tests References: Codex review run /private/tmp/worldmonitor-pr3621-review/ findings_round4.md (P1). waitForConvexAuth helper found at src/services/convex-client.ts:79 — exists today and is used by the existing entitlement subscription path; this fix wires it into the followed-countries handoff for the same reason. * fix(ci): seed followedCountries shards on Convex deploy (P1) The followCountry / unfollowCountry / mergeAnonymousLocal mutations throw SHARDS_NOT_SEEDED if followedCountriesShards is empty. Today the seed runs only via the 03:00 UTC daily cron, so a deploy landing at 04:00 UTC would leave the feature broken for ~23h until the next cron tick. Run npx convex run --prod followedCountries:_seedShards inline after npx convex deploy --yes so the table is populated before any traffic hits the new mutations. Idempotent: existing shard rows are skipped, only missing ids in [0, SHARD_COUNT) are inserted. * fix(followed-countries): race-tolerant shard seed + dedupe cron + remove public seed (P1) Two stacked P1 issues from Codex round-3 review of PR #3621: 1. Public seedShards mutation was unauthenticated — any browser ConvexHttpClient could call it. Removed; the post-deploy CI step now targets the internal _seedShards directly via `npx convex run --prod followedCountries:_seedShards` (npx convex run resolves internal functions by file:export path). 2. _seedShards has a TOCTOU race: two simultaneous calls against an empty table both read empty, both insert the full range, producing 2 rows per shardId. Previously readShardOrThrow used .unique() which throws on duplicates → bricks the affected shard for all users hashing to it. Approach D (real-world correct): make readShardOrThrow tolerant of duplicates via .first() (returns oldest by _creationTime tiebreaker, so OCC contention is preserved across all in-flight mutations on that shard during the duplicate window) AND add a daily _dedupeShards cron that deletes extras keeping the oldest row per shardId. Tests: - duplicate shard rows: mutation succeeds, counter parity holds - _dedupeShards: zero dups → no-op; N dups → reduces to 1 row per shardId, oldest survives - _seedShards idempotent under back-to-back concurrent re-run - public seedShards no longer exported (source-text negative assertion) Net: 298 → 302 tests, all green. * feat(follow-button): minimal CSS for star button + host layouts (PR A foundation) Third-pass review P2: PR A's src/utils/follow-button.ts:220 emits .wm-follow-btn* markup mounted on three live surfaces (CIIPanel, CountryDeepDivePanel, CountryIntelModal) but no CSS shipped — buttons rendered as native browser controls and the CIIPanel host (a span inside a block-layout .cii-country row) put the star on its own line. Visual analogue: .cii-share-btn (transparent + 1px var(--border) + var(--text-muted) text + var(--semantic-info) hover). Same border-radius family, same micro-padding scale. CSS variables used: --border, --border-strong, --text-muted, --semantic-info. Reuses the existing @Keyframes spin. Critical layout fix (CIIPanel): added position:relative to .cii-country and absolute-positioned .cii-follow-btn-host at top:6px left:6px. The :not(:empty) gate keeps the rule a no-op when the feature flag is off (handle.html === ''), and the sibling-combinator rule .cii-follow-btn-host:not(:empty) ~ .cii-header { padding-left:26px } shifts header content right ONLY when the star is mounted — flag-off rows render identically to today. CDP + CountryIntelModal hosts are simple display:inline-flex since both parent containers (.cdp-header-left, .country-intel-title) are already flex with gap. Polish (color tuning, hover transitions, animation curves, empty/cap nudge styling) intentionally deferred to PR B per the third-pass review. +156 lines (single appended block in src/styles/main.css). * fix(followed-countries): serialize country counters * fix(followed-countries): register picker global
5 tasks
nichm
pushed a commit
to nichm/worldmonitor-private
that referenced
this pull request
Jul 1, 2026
* perf(edge): reduce unnecessary Vercel edge invocations (koala73#6 findings) Phase 1 — Client-only fixes: - Remove predictions double getHydratedData read from data-loader.ts; fetchPredictions() handles hydration internally - Fix UCDP delete-on-read race: read hydratedUcdp once in data-loader, pass to both fetchUcdpClassifications() and fetchUcdpEvents() Phase 2 — Batch RPCs (proto + server + client): - Add GetHumanitarianSummaryBatch RPC: replaces 20-request HAPI fanout with single batch call (getCachedJsonBatch + per-key Redis caching) - Add GetFredSeriesBatch RPC: replaces 7-request FRED fanout with single batch call (same pattern) - Both batch RPCs have 404 deploy-skew fallback to per-item calls Phase 3 — Seed gap: - Add seed-service-statuses.mjs standalone seed script - Add 15-min warm-ping loop in AIS relay for service statuses - Remove serviceStatuses from ON_DEMAND_KEYS in health.js Net savings: up to 28 edge calls eliminated on cold miss per page load. * fix(edge): address code review findings (P1–P3) P1: Fix dead 404 deploy-skew fallback — circuit breaker was swallowing the ApiError before the catch block could detect it. Move 404 fallback inside the breaker callback so it executes before the breaker catches. P2: Replace 172-line seed-service-statuses.mjs (duplicated parser logic) with a 60-line warm-ping that triggers the existing RPC handler. P2: Extract shared ISO2_TO_ISO3 mapping to conflict/v1/_shared.ts, eliminating duplication between single and batch HAPI handlers. P3: Remove unnecessary UPSTASH_ENABLED guard from relay warm-ping (it calls Vercel RPC, not Redis directly). P3: Clean up unused per-series FRED breakers and fetchSingleFredSeries (replaced by batch breaker). Update getFredStatus() accordingly. * fix(edge): use concurrent fetches in batch handlers HAPI batch: replace serial loop with groups of 5 concurrent fetches using Promise.allSettled for partial-success resilience. Bump client timeout to 60s (4 rounds × 15s upstream timeout worst case). FRED batch: replace serial loop with fully parallel Promise.allSettled (max 10 series, each hits separate FRED endpoint). Both changes prevent empty-result regression on cold cache that the serial approach caused when upstream latency exceeded the client timeout.
This was referenced Jul 1, 2026
koala73
pushed a commit
that referenced
this pull request
Jul 6, 2026
…vider timeout + harden classification (#4980 review) Addresses the ce-code-review findings on #4980: - #1/#4: raise MARKET_IMPLICATIONS_MIN_RUN_BUDGET_MS 20_000 -> 30_000 (= max provider.timeout 25s + FORECAST_LLM_STAGE_BUDGET_GUARD_MS 5s). At 20s the pre-call guard admitted calls that were then timeout-CAPPED below the provider's own timeout, which is indistinguishable from a genuinely hung provider: a real openrouter timeout in the [20s,30s) band was misclassified as a benign budget starve and its SEED_ERROR suppressed, and 20-25s calls were guaranteed to abort mid-flight (~15s wasted). Admitting only at >=30s gives the provider its full window, so any timeout is attributable and a genuine failure still surfaces SEED_ERROR. - #2: getRemainingForecastLlmBudgetMs now delegates the run-remaining calc to getRemainingForecastLlmRunBudgetMs (was a verbatim duplicate formula). - #3: document the failure-reason classification invariant on the sawProviderFailure / sawBudgetCappedTimeout declarations and the budget-capped-timeout heuristic (comments only, no behavior change). - #5: add tests for the 20-30s guard skip (locks the 30s threshold), the retained mid-call budget_exhausted preserve branch, and its provider_failed symmetry (via the __setForecastLlmCallOverrideForTests seam). - #6: test 1 now sets a real provider key + transport counter so its llmCalls===0 tripwire exercises the guard instead of the !apiKey short-circuit; removed the now-unused budgetStarveFetch helper. test 4 deadline bumped 25s -> 35s to keep passing the new 30s guard. Tests: 605 pass across all seed-forecasts.mjs consumers; biome + unicode clean. Claude-Session: https://claude.ai/code/session_017odw3Pf9ue8RZzxYAQ37P8
koala73
added a commit
that referenced
this pull request
Jul 6, 2026
…ved of the shared LLM run budget (#4978) (#4980) * fix(forecast): stop false SEED_ERROR when market_implications is starved of the shared LLM run budget (#4978) market_implications is the LAST forecast LLM stage (afterPublish) and shares the single 150s run budget with every upstream stage. When upstream stages are slow (e.g. deepseek-v4-flash breaching its 25s call timeout on combined/ scenario, #4944), they drain that budget before this tail stage runs; callForecastLLM then throws a budget error and returns null. Pre-fix the caller treated that starve identically to a real LLM failure and wrote a status:'error' seed-meta, so /api/health flipped to SEED_ERROR for benign, self-healing resource contention (observed 2026-07-06 14:03; self-healed 14:15). Two changes: - Distinguish a budget-starve from a real failure. On starve (pre-call guard, or a null result with the run budget now <=0), preserve last-good WITHOUT rewriting seed-meta.fetchedAt, so age-based STALE_SEED (maxStaleMin=120) still escalates if the starve persists past 2h (gpsjam preserve-last-good design). Genuine provider failures with budget remaining still surface SEED_ERROR. - Reorder: run market_implications BEFORE the best-effort telemetry in afterPublish (history + deep-forecast snapshots, ~20s R2 trace export) so that wall-clock can't push the tail stage past the run deadline. This recovers the ~20s that pushed it over the 150s deadline in the failing run. Root trigger (deepseek-v4-flash latency draining the shared budget) is #4944 territory; a complementary combined-stage groq fallback is a 1-line Railway env change (FORECAST_LLM_COMBINED_PROVIDER_ORDER=openrouter,groq), left out of code to respect the intentional #4944 pin. Tests: tests/market-implications-budget-starve.test.mjs (starve preserves last-good + no error meta; genuine failure still errors) + an afterPublish ordering guard in market-implications-seed-health.test.mjs. Failing-test-first per Bug Fix Protocol. Claude-Session: https://claude.ai/code/session_019uhnqAKEHTws3QMaUvq58N * fix(forecast): preserve market implication failure signals Address PR #4980 review feedback by keeping provider failures distinct from run-budget starvation and restoring stale OK market-implications meta from last-good payloads during starved runs. Also updates the budget-starve tests so the provider-failure regression performs a real provider request. * fix(forecast): raise market_implications budget guard to the full provider timeout + harden classification (#4980 review) Addresses the ce-code-review findings on #4980: - #1/#4: raise MARKET_IMPLICATIONS_MIN_RUN_BUDGET_MS 20_000 -> 30_000 (= max provider.timeout 25s + FORECAST_LLM_STAGE_BUDGET_GUARD_MS 5s). At 20s the pre-call guard admitted calls that were then timeout-CAPPED below the provider's own timeout, which is indistinguishable from a genuinely hung provider: a real openrouter timeout in the [20s,30s) band was misclassified as a benign budget starve and its SEED_ERROR suppressed, and 20-25s calls were guaranteed to abort mid-flight (~15s wasted). Admitting only at >=30s gives the provider its full window, so any timeout is attributable and a genuine failure still surfaces SEED_ERROR. - #2: getRemainingForecastLlmBudgetMs now delegates the run-remaining calc to getRemainingForecastLlmRunBudgetMs (was a verbatim duplicate formula). - #3: document the failure-reason classification invariant on the sawProviderFailure / sawBudgetCappedTimeout declarations and the budget-capped-timeout heuristic (comments only, no behavior change). - #5: add tests for the 20-30s guard skip (locks the 30s threshold), the retained mid-call budget_exhausted preserve branch, and its provider_failed symmetry (via the __setForecastLlmCallOverrideForTests seam). - #6: test 1 now sets a real provider key + transport counter so its llmCalls===0 tripwire exercises the guard instead of the !apiKey short-circuit; removed the now-unused budgetStarveFetch helper. test 4 deadline bumped 25s -> 35s to keep passing the new 30s guard. Tests: 605 pass across all seed-forecasts.mjs consumers; biome + unicode clean. Claude-Session: https://claude.ai/code/session_017odw3Pf9ue8RZzxYAQ37P8 --------- Co-authored-by: Elie Habib <[email protected]>
koala73
added a commit
that referenced
this pull request
Jul 24, 2026
* feat(activation): pure pro-activation state core — mount decision, step model, fire-once keying (U1) Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * feat(activation): interstitial shell — overlay, step chrome, focus trap, exit summary, en copy (U3) Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * feat(activation): brief/alerts/power step wiring + finish-setup chip (U4-U6) Brief: atomic setNotificationConfig with explicit hour+IANA tz, insights world-brief preview, inline hour select. Alerts: pre-denied blocked state, patch-not-clobber channels, cadence-honest copy. Power: injected deep links + R8 settings pointer. Chip: versioned-key dismissal. Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * feat(activation): checkout-return marker + post-reload boot mount hook (U2) Marker written before clearCheckoutAttempt on the success branch only; mount decision evaluated off the boot critical path with bounded snapshot-retry. Surfaces subscriptionId/currentPeriodStart on getSubscriptionForUser (additive, existing columns) for the fire-once key — accepted plan deviation. Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * fix(activation): re-arm mount retry on subscription snapshot changes too With the real subscription snapshot as the fire-once key input, a boot with live entitlement but a not-yet-loaded subscription snapshot would stall in 'keep' forever watching entitlement only. Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * feat(activation): proActivation locale fan-out — 24 locales, register-calibrated (fa per its file convention) Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * feat(activation): funnel telemetry + end-to-end spec (U7) Typed Umami events with whitelisted minimized payloads (planKey/step/exit counts — never billing identifiers); single entered fire site at mount; 7 Playwright scenarios green against the dev server. Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * refactor(activation): simplify pass — shared focus-trap util, leaf record parsers, type reuse, idle-handle cleanup Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * fix(review): apply findings #1-4, #8-12, #14 — hour-capture ref, seeded alerts fallback, preview tri-state, coverage locks P0 #1: digest hour captured in a closure ref so the in-flight re-render can't wipe the user's pick. P1 #2: alerts catch-path seeds from flow context (+email when brief confirmed) instead of empty — convex channels field is full-replace. P1 #3 + P2 #8-12: failed-state e2e, chip assertion, expired-Pro branch, payload combo, catalog-derived drift guard, focus-trap unit tests. P2 #4 tri-state preview guard. P3 #14 unexported helper. Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * fix(review): apply findings #6, #7, #13 — account-scoped records, cross-tab mount claim Marker/fire-once/chip records carry the Clerk userId; foreign-user markers never mount (and are left for the buyer, TTL-reaped); unscoped markers are bound to the first resolved session that observes them. Multi-tab mounts serialize through a nonce claim with a 10s TTL. 92/92 unit, 8/8 e2e. Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * docs: refresh component/service counts for pro-activation modules (docs-stats gate) Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * fix(activation): address greptile review — preserve state on partial failure + product-id guard - Brief digest cadence: buildBriefDigestPayload no longer forces 'daily' when an enabled weekly/twice_daily rule exists without a verified email channel; it omits cadence fields so the relay's field-guarded write preserves the schedule. - Failed channel reads: readActivationContext now flags channelsKnown; confirm writes omit the channels field on an untrusted (failed) read so the relay preserves existing Telegram/Slack/etc. instead of replacing with an empty set. - Fire-once ordering: persist fire-once + clear the marker only AFTER the interstitial opens; an import/init failure leaves the marker for a later retry (double-mount still prevented by the in-session latch + cross-tab claim). - Product-id guard: derive PRO_PRODUCT_IDS from DODO_PRODUCTS (no raw pdt_ literal); exclude the e2e test dir like tests/. Addresses greptile P1 threads on #5534. Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * fix(activation): keep pro-activation leaf import-free; exclude it from product-id guard Importing DODO_PRODUCTS into the leaf dragged the checkout-only product catalog into the eager dashboard entry chunk (the leaf is statically imported by panel-layout), failing the eager-chunk budget test. Revert to mirrored literals kept in sync by the drift-guard test, and exclude the leaf from the raw-pdt_ guard instead (the drift-guard provides the catalog-sync the guard wants). Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * fix(activation): make onboarding account-safe
mitchross
added a commit
to mitchross/worldmonitor
that referenced
this pull request
Jul 24, 2026
* docs(solutions): capture two reusable learnings from the KV cutover (#5338) (#5383) * fix(mcp): make GET /mcp crawler-readable and fix the discovery cache key (#5382) * fix(mcp): serve the human guide on GET /mcp and key discovery caches correctly A plain GET to /mcp returned the transport's spec-correct 405, which Google Search Console reports as "cannot access" — on www, on the apex, and on every variant subdomain. It now returns the mcp-server.md guide as text/markdown, and variant hosts 308 crawler GETs to the apex canonical. The discovery response is where the real hazard is. /mcp and /.well-known/mcp branch on Accept and Last-Event-ID, but the server card shipped `public, max-age=3600` with no Vary. Vercel's edge keys on URL alone, so a warmed discovery 200 was served back (x-vercel-cache: HIT) to a GET carrying `Accept: text/event-stream` — handing an MCP SDK client a JSON body where the transport contract requires 405. Reproduced on production against /.well-known/mcp before this change; that is #4937's hang class arriving through the CDN instead of the handler. The card keeps its cacheability and gains Vary: Accept, Last-Event-ID. The transport URL goes further and stays no-store, so its correctness never depends on an intermediary honoring Vary. The variant 308 is built by hand rather than via Response.redirect() so it can carry Vary too — a 308 is cacheable by default (RFC 9110 15.4.9). Canonical stays apex per ARCHITECTURE.md:72 — /mcp is on the Cloudflare apex→www exemption list and the server card advertises the apex endpoint. POST and OPTIONS are never redirected (#4938). mcp-live-smoke.mjs gains the probes that can actually see this: warm the cache with a plain GET, then fail if the SSE GET is anything but 405. Run against un-fixed production it reproduced all three defects independently. Claude-Session: https://claude.ai/code/session_01HHHP2TMJZUAYuzp6iGvYHD * docs(solutions): MCP crawler GET and the CDN discovery-cache replay Records the finding behind the /mcp fix: a cacheable discovery 200 on a URL that content-negotiates on request headers gets replayed by a URL-keyed edge cache to transport clients. Includes the production reproduction, the Vary-vs-no-store reasoning, and the landmine that the first version of the regression check (/\bAccept\b/i) passed against the un-fixed origin because `-` is a word boundary and it matched `accept-encoding`. Adds the Discovery Read vs. Transport Operation concept to CONCEPTS.md. Claude-Session: https://claude.ai/code/session_01HHHP2TMJZUAYuzp6iGvYHD * fix(mcp): harden discovery negotiation * fix(review): preserve MCP discovery contracts * fix(mcp): align HEAD discovery metadata * fix(deps): clear high-severity DoS advisories failing the security-audit gate (#5395) * fix(analytics): swallow Umami beacon rejection leaking to Sentry (#5393) * chore(lint): appease biome 2.4.9's promoted rules across scripts/ (unblocks all PRs) (#5400) PR #5395's lockfile refresh floated biome 2.4.7→2.4.9 inside the caret range; 2.4.9 promotes rules (useIndexOf, noAdjacentSpacesInRegex, noUselessContinue, noUselessStringRaw, useDefaultParameterLast, noUnusedFunctionParameters) that flag 20 pre-existing spots in scripts/. Main's path-filtered biome job hasn't re-linted scripts/ since, so every PR triggering a full lint now fails (first: #5397). All fixes are behavior-neutral: indexOf/regex/String.raw rewrites are equivalence-preserving, unused params dropped, and selectTopStories keeps its maxCount=8 default under an explicit biome-ignore (the auto-fix would have silently changed the signature contract). * test(rss-proxy): wire test file into CI, lock the SSRF/auth/rate-limit guards, fix relay www-tolerance (#5378) (#5399) * test(rss-proxy): wire test file into CI and lock the pre-fetch guards (#5378) api/rss-proxy.test.mjs was in neither test:data nor test:sidecar, so its 5 tests never ran in CI. Add it to test:sidecar and close the adversary-reachable coverage gaps the sweep found (5 -> 28 tests). The sweep flagged "SSRF hostname/userinfo confusion" as Critical. Probing the real predicate shows it is NOT a bypass: WHATWG new URL() strips userinfo into username/password, and isAllowedDomain only strips a leading "www." — so techcrunch.com.attacker.example, [email protected] and the trailing-dot FQDN form all resolve to a non-allowlisted hostname and 403. The real finding is that the guard had zero coverage; deleting it left the suite green while the handler fetched the attacker host. These tests close that. New coverage, each mutation-proven (14 mutations, every one killing exactly its target test and no others): - initial-host allowlist: suffix/userinfo/trailing-dot confusion + link-local metadata, asserting the attacker host is never fetched - auth: missing key -> 401, invalid key -> 401, both before any upstream call - rate limit: exhausted -> 429 with no feed fetch, plus the headroom case so the 429 is attributable to the limiter verdict, not to Upstash being set - protocol: file:// -> 400 (not the 403 domain verdict) - request shape: missing/malformed url -> 400, OPTIONS -> 204, non-GET -> 405, disallowed Origin -> 403 without echoing the attacker origin - response policy: relay-only routing + long cache TTLs, short TTLs on success, no CDN-Cache-Control on a failed upstream, non-2xx relay retry, content-type fallback, AbortError -> 504, and the Google News 20s vs default 12s deadline Drift fix surfaced by the new invariant test: RELAY_ONLY_DOMAINS still listed www.arabnews.com, which #1626 removed from the RSS allowlist as a dead feed domain. The allowlist runs first, so the entry could only ever 403 before the relay routing it exists for was consulted. Arab News is sourced via news.google.com, not a direct arabnews.com feed. RELAY_ONLY_DOMAINS is exposed via the repo's `export const __testing__ = {...}` test-only convention (matching api/health.js, api/bootstrap.js, api/mcp.ts) so the test can assert every relay-only host is also allowlisted, preventing the same drift from recurring — without widening the module's public surface. Claude-Session: https://claude.ai/code/session_018RkPSPXZKPpBtvAhNZx3au * fix(rss-proxy): www-tolerant relay routing + collapse the drifted dev allowlist (#5378) Two fixes surfaced by the #5378 review, both approved for this PR. 1. Relay-only routing www/apex asymmetry (was: exact-match). `isAllowedDomain` is www-tolerant (strips/adds a leading `www.`) but the relay-only check was `RELAY_ONLY_DOMAINS.has(hostname)` — exact. Result: all 17 relay-only hosts were reachable via their alternate form (e.g. `cisa.gov` for the registered `www.cisa.gov`), which passes the allowlist but misses relay routing and gets direct-fetched from a Vercel edge IP these hosts block — paying the full 12s/20s timeout before the error fallback recovers. Dormant today (every registered feed uses the exact form), but structural. Fix: extract `hostMatchForms()` into api/_rss-allowed-domain-match.js and use it for BOTH the allowlist predicate and the relay-only check, so the invariant is structural rather than dependent on data-entry symmetry. New test (apex `cisa.gov` routes to the relay) is mutation-proven: reverting to `.has(hostname)` turns it red. 2. vite.config.ts held a THIRD copy of the RSS allowlist for the dev-server proxy that had drifted ~138 domains from prod (134 prod hosts 403'd in dev; 4 dev-only entries including the dead `www.arabnews.com`), while scripts/validate-rss-feeds.mjs claimed it was a kept-in-sync mirror. Replace the hand-maintained Set with a direct import of `isAllowedDomain` so dev and prod share one www-tolerant allowlist. Validator's mirror list drops 5 -> 4. Verified `vite build` succeeds with the edge-module import. Claude-Session: https://claude.ai/code/session_018RkPSPXZKPpBtvAhNZx3au * test(rss-proxy): add negative-space + failure-branch coverage from review (#5378) Multi-lens review (security, correctness, adversarial, testing) confirmed the SSRF false-positive call and the [-1, 600] Upstash mock shape, but found the guard tests only rejected LOOKALIKES of allowlisted names, never a plain stranger — plus a few weak spots. 29 -> 33 tests, each new one mutation-proven. - Plain non-allowlisted stranger host rejected on BOTH the initial-host and the redirect-hop allowlist. Every prior negative case was a lookalike (techcrunch.com.attacker.example, [email protected], trailing-dot) or a raw IP, so loosening the guard to `!isAllowedDomain(h) && !h.endsWith('.com')` (admit any .com) stayed green. Now killed on both code paths. - Generic 502 'Failed to fetch feed' branch + its lone captureSilentError call site — previously untested — covered both reachable ways: a non-Abort direct-fetch throw with no relay, and a relay-only host with no relay. - Rate-limit headroom positive control given teeth: it returned 200 whether the limiter granted headroom OR threw and failed open. Now asserts the `[rate-limit] redis-error` degraded log never fired (proven: a garbage Upstash reply now fails the test instead of passing). - Google News deadline test's unbounded `while (!signal)` spin bounded + given a per-test timeout: a regression that stops the handler reaching fetch now fails in ~110ms with "handler never reached fetch" instead of hanging the CI runner forever (verified). - Guard-ordering test now fails all three early gates at once (bad Origin + no key + non-GET) so only ordering explains the 403 'Origin not allowed'. - CORS Allow-Methods pinned to exact 'GET, OPTIONS' (was substring /GET/); 429 now asserts it still carries CORS headers. Both mutation-proven. - AbortError test comment corrected to state the Sentry-suppression gate is NOT asserted (captureSilentError no-ops under NODE_TEST_CONTEXT), instead of implying coverage it lacks. Claude-Session: https://claude.ai/code/session_018RkPSPXZKPpBtvAhNZx3au * test(pro): critical-path budget guard for the committed /pro build (#5396) (#5397) * test(pro): critical-path budget guard for the committed /pro build (#5396) Any PR that rebuilds the pro app (a #5374-class change) can silently regress the page's critical path; the only tripwire was the weekly DebugBear email, days later and averaged. This guard runs at PR time against the committed artifacts: 700KB critical-path budget (entry + modulepreloads + stylesheets; currently ~625KB), Clerk must never be referenced from the page HTML and must stay a dynamic import in the entry chunk (the 3MB parse behind the lab score of 63), and a 6MB whole-assets cap. Checkers are pure and teeth-tested against bad fixtures so the guard itself is proven able to fail. * fix(test): drop exports from the pro budget guard — biome noExportsInTest (error severity in 2.4.9) * fix(seed-research): isolate arXiv categories + retry + TTL so a single blip can't empty prod (#5409) (#5413) * fix(deps): clear fresh high advisories redding audit-lockfile on all PRs (#5417) (#5418) * feat(content): 13 blog posts — undocumented features (tenders, sanctions, aviation, radiation, energy dashboard…) + 'not Palantir' positioning (#5403) * fix(pricing): call out 'License / API key included' on the API Starter tier (#5419) * fix(rss-proxy): remove dead rsshub.app from allowlists (#5414) * fix(deps): clear sharp + fast-xml-parser high advisories redding audit-lockfile on all PRs (#5423) (#5424) * fix(feed-digest): decode & last so RSS entities aren't decoded twice (#5432) * fix(feed-digest): decode & last so RSS entities aren't decoded twice `decodeXmlEntities` decoded `&` first. That turns the escaped ampersand of `&lt;` into a live `&`, which the very next `.replace` immediately consumes as `<` — so a single pass decodes two levels. `&` has to go last. The visible damage differs by call site: - `extractTag` (titles): a headline whose literal text is `<script>` arrives escaped as `&lt;script&gt;` and comes back as `<script>` — raw markup injected into `ParsedItem.title` and emitted on the wire instead of the text the publisher wrote. - `extractDescription`: it strips tags *after* decoding, so the same input loses the words entirely — "XSS triggered by <script> tags in profile bios" becomes "XSS triggered by tags in profile bios". Also switch the numeric-reference branches from `String.fromCharCode` to `String.fromCodePoint`. `fromCharCode` truncates to 16 bits, so `😀` decoded to U+F600 (private use) instead of 😀. Out-of-range values are dropped rather than allowed to throw `RangeError`, which would fail the whole feed parse over one malformed reference. Adds `tests/news-feed-digest-entity-decode.test.mts`, including a produce-then-consume round-trip (escape a plain string, decode it, assert the original comes back). 5 of its 6 cases fail on the previous implementation. * test(feed-digest): cover the ' branch in the round-trip helper Review follow-up. The local escapeXml helper didn't escape apostrophes, so the round-trip assertion never produced ''' and never exercised that decode branch. Escapes it now, and adds an original containing apostrophes — escaping alone would not have reached the branch, since no existing case contained one. This case is coverage, not a second regression case: it round-trips on the old implementation too. The double-decode cases above it are what fail on main. --------- Co-authored-by: thejesh23 <[email protected]> Co-authored-by: Elie Habib <[email protected]> * fix(seed-utils): retry transient Redis blips on the read path + skip-path meta ops (#5437) (#5438) The gdelt-intel cache-merge fallback loads the previous canonical snapshot via verifySeedKey -> redisGet: 5s abort, no retry, and any non-OK status silently returned null. During the 2026-07-21 GDELT brownout one blip per run at the soft-budget boundary read as "no previous snapshot" - the merge no-op'd, validation failed, runs skipped without writing, and seed-meta aged 21h until the freshness gate fired, while the canonical key was perfectly healthy. Sibling unretried ops on the same skip path produced two exit-1 FATAL crashes (writeFreshnessMetadata SET abort). - redisGet: withRetry with the redisCommand tagging contract (permanent 4xx fail fast, 429 honors Retry-After, timeout/5xx backoff). External contract unchanged: HTTP failures still degrade to null (now loudly), thrown failures still propagate - both only after retries. - writeFreshnessMetadata: wrap the SET in withRetry so a single Upstash abort can't escape as FATAL on the validate-skip path. - readCanonicalEnvelopeMeta: retry before degrading - a blip here writes recordCount=0 with fetchedAt=NOW, resetting the freshness clock over real staleness. - seed-gdelt-intel _loadPrevious: replace the silent catch with a loud cache-merge warning so a dead fallback is visible in run logs. Tests: 13 new (red-first) covering retry/degrade/propagate contracts for all three helpers plus the seeder's default wiring; seeder suite 794 green. Closes #5437 Claude-Session: https://claude.ai/code/session_01AKbRZXV6kKe8MTYpKZSLBM * Update blog post to 6 dashboards, fix variants count, and add Energy … (#5408) * Update blog post to 6 dashboards, fix variants count, and add Energy Atlas section * Address PR review: fix panel count to 26, update stale 'five' reference, fix heading capitalization, and add trailing newline * review fixes: registry-true panel counts, energy deep-dive link, modifiedDate Panel counts refreshed against src/config/panels.ts (102/41/60/32/10/26 — four of the old numbers had drifted). Link the new energy post shipped in #5403, pin pipeline claim to the 88 mapped in code, fix "All Six" casing, add modifiedDate + energy keyword. Claude-Session: https://claude.ai/code/session_01JEGono85MnwW7F9mm4rQEF --------- Co-authored-by: Elie Habib <[email protected]> * feat(content): receipts round — rewrite Palantir post, print the real scorecard, live radiation + tender records (#5443) * docs(solutions): three-layer cache eviction runbook for the live product catalog (#5422) * feat(blog): pinned-post support; pin the Palantir post to the top of the index (#5446) * fix(content): replace blog title cards with product imagery (#5452) * feat(agent-readiness): sandbox, docs-MCP JSON-RPC errors, schemamap, modular llms.txt (orank round) (#5469) * feat(agent-readiness): advertise SDKs in the agent view + homepage rel=alternate pointer (orank round 2) (#5476) * docs(blog): clarify WorldMonitor and Palantir positioning (#5480) * fix(payments): re-check Dodo before stale-subscription denial (#5447) * feat(blog): strengthen SEO and AI discoverability (#5475) * fix(payments): distinguish transient entitlement-lookup failure from confirmed denial (#5483) * ci(deploy-gate): retry the check-runs poll before posting a terminal 'pending' (#5479) (#5482) * fix(seed-gdelt-intel): degrade bookkeeping failures instead of crashing; brownout-scale timeline TTL; content-age opt-in (#5478) (#5481) * fix(auth): honor current API access during renewal checks (#5490) * feat(homepage): link Palantir positioning article (#5491) * ux(payments): billing-aware renewal/lapsed states instead of generic Upgrade CTA (#4771) (#5494) * feat(billing): pure billing UX state derivation for #4771 * feat(billing): expose renewalVerificationState from getSubscriptionForUser * feat(billing): billing-aware panel gating copy instead of generic Upgrade CTA * feat(billing): renewal-verification banner variants; gating follows subscription changes * fix(widget-agent): structured billing-verification denial before generic 403 * refactor(billing): localize banner via shared i18n keys; per-pass gating derivation; skip no-op CTA rebuilds * fix(review): cancelled-in-period coverage, billing-denial on-call log, behavioral + wiring test locks * docs: bump service-module count for billing-state.ts Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * docs: regenerate stats.json for billing-state service module Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * docs(solutions): compound #4771 learnings — coverage-semantics bug + i18n shell convention (#5495) Two learnings from PR #5494 plus a Billing & Entitlements vocabulary seed and an English Shell glossary entry in CONCEPTS.md. Claims grounding-validated against source and live GitHub state (28 claims, 2 corrected). Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * fix(notification-channels): bound convexRelay() with a 15s timeout (#5485) * fix(mcp): route sibling fetches through canonical API (#5517) * fix(python-sdk): avoid secret-scan literals (#5486) * fix: avoid Python SDK secret-scan literals * test(python-sdk): pin auth header contract --------- Co-authored-by: Elie Habib <[email protected]> * fix(auth): keep Pro brief denials out of session recovery (#5516) * fix(auth): keep Pro brief denials out of session recovery * Address PR review feedback (#5516) - Preserve successful premiumFetch route matches in the AST guard * fix(ci): update pro-test PostCSS lockfile Upgrade PostCSS past GHSA-6g55-p6wh-862q and refresh its Nanoid dependency. * fix(auth): close the #5379 adversarial sweep — resource exhaustion, state corruption, MCP entitlement gaps, and the inert live suite (#5385) * fix(auth): bound the Clerk plan lookup with AbortSignal.timeout (#5379) server/auth-session.ts lookupPlanFromClerk() fetched api.clerk.com with no timeout. validateBearerToken awaits it on every standard (non-template) session token — the ones without a `plan` claim — so a stalled Clerk let an authenticated caller pin gateway invocations open indefinitely. Adds signal: AbortSignal.timeout(3s), matching the budget already used by the other external auth lookups (server/_shared/user-api-key.ts and api/_user-api-key.js VALIDATION_TIMEOUT_MS). The existing catch already fail-softs, so a timeout degrades to 'free' exactly like an HTTP error. Regression test drives the real seam (signed RS256 JWT against a local JWKS server -> validateBearerToken -> lookupPlanFromClerk) with a never-settling Clerk stub, and additionally pins that a timed-out lookup does NOT poison the 5-minute plan cache with a 'free' verdict — the next request retries. Mutation-proved: removing the signal line turns the test red (actual: 'still-pending'). * fix(auth): validate user-API-key shape and canonical format before trusting it (#5379) Two gaps in server/_shared/user-api-key.ts, both on the live gateway auth path. 1. State corruption / EoP. cachedFetchJson<UserKeyResult> only CASTS its payload — a poisoned cache entry or upstream shape drift (e.g. {}) reached callers as a truthy 'authenticated principal' whose .userId read undefined. Adds isUserKeyResult(), an own-property runtime guard (hasOwnProperty, so a polluted Object.prototype.userId cannot authenticate a bare {}). null still passes through untouched as the legitimate negative-cache answer. The warn logs the type only — payload and key hash are credential material. 2. Malformed-key amplification. startsWith('wm_') let wm_x burn a SHA-256, a Redis round-trip and a Convex lookup per attempt. Now gated on /^wm_[a-f0-9]{40}$/ BEFORE hashing, matching the sibling api/_user-api-key.js. The regex is deliberately duplicated rather than imported (that module evaluates env at load and pulls redisPipeline + client-ip into the edge bundle for one regex); a test asserts the two literals stay byte-identical so drift fails CI. Also corrects the UserKeyResult interface, which was lying: it declared keyId/name required, but fetchFromConvex returns Convex's row verbatim ({id,userId,name}) so keyId is undefined on that path — only api/_user-api-key.js maps id->keyId. Requiring keyId in the guard would have 401'd every fresh Convex validation. Verified all three callers (server/gateway.ts, server/_shared/premium-check.ts, api/mcp/auth.ts) read ONLY .userId; api/mcp/types.ts already types the dep as {userId: string}|null. Mutation-proved: reverting the format guard reds 9 tests (including the 'backend never invoked' amplification assertions); neutering the shape guard reds 10. 28/28 green restored. * test(mcp): give the entitlement gate and both rate limiters teeth (#5379) Gaps 4, 9 and 10. api/mcp/auth.ts is unchanged — this is coverage only. Gap 4: checkMcpEntitlementGate enforces tier>=1, mcpAccess===true and validUntil>=now, but every existing test used ONE fixture violating all three at once, so any single predicate could be deleted with 144/144 still green. Replaced with a one-predicate-at-a-time matrix (each case violates exactly one predicate and satisfies the rest), plus strict-truthiness cases (mcpAccess: 'true' and 1 must still 401) and a getEntitlements-throws case. Covers both paths that reach the gate — pro and user_key — since user_key is the credential class that would otherwise silently skip it (#4859). Mutation-proved, re-verified independently by the orchestrator: drop tier<1 -> 4 tests red drop !mcpAccess -> 8 tests red drop validUntil<now-> 4 tests red drop !ent -> SURVIVES, and cannot be killed: the following lines read ent?.features?.tier ?? 0 etc., so a null ent already collapses to tier=0 and is rejected by tier<1. !ent is redundant defence-in-depth and is an equivalent mutant by construction — documented here rather than papered over with a test that fakes it. Gaps 9/10: applyPerMinuteLimit and applyAnonDiscoveryLimit were never exercised. Now pinned for all three limiters: absent limiter -> pass-through, under/over limit, the -32029 message text, emitMcpRateLimitHit payload, and the deliberate fail-OPEN on limiter throw. Bucket keys are asserted explicitly — key:<apiKey> for env_key, and pro AND user_key both mapping to pro-user:<userId>, which is the security-relevant claim that the two share one 60/min budget rather than stacking two. Anon discovery additionally pins the client-IP trust model: a spoofed x-forwarded-for cannot rotate the bucket, cf-connecting-ip is only trusted with matching CF_EDGE_PROOF_SECRET, and a missing IP falls back to a shared bucket rather than an empty key. 73 tests, all green. * test(auth): mutation-proof the bootstrap user-key cache, coalescing and timeout (#5379) Gaps 6, 7 and 8. api/_user-api-key.js is unchanged — this is coverage only. Gap 6: the cache-hit guard (truthy && object && typeof userId==='string' && userId.length>0) was correct but untested, so any conjunct could be deleted silently. The issue filed this as 'empty cached userId accepted'; that framing is wrong — the guard already rejects it. Each conjunct is now individually load-bearing: '' / 123 / null userId, and non-object hits (string, array, number, null) must all decline to trust the cache entry. Gap 7: request coalescing collapses N concurrent lookups of the same key hash onto ONE Convex round-trip. Deleting the coalesce() wrapper broke nothing, so a burst with one key could amplify 1:1 onto Convex. Now asserted three ways — 5 concurrent calls with the same key hit the backend exactly once and all resolve identically; 5 concurrent calls with DIFFERENT keys hit it 5 times (proving we measure coalescing, not caching); and a call after the first settles hits the backend again, proving the in-flight map's finally-delete cleanup does not leak entries. Gap 8: postConvexJson's AbortSignal.timeout(VALIDATION_TIMEOUT_MS) was unasserted, so its deletion would have reintroduced an unbounded auth fetch. Mutation-proved, re-verified independently by the orchestrator — each of the three deletions reds exactly one test: drop .length>0 -> 1 red drop coalesce() wrapper -> 1 red drop signal: AbortSignal... -> 1 red 38 tests, all green. * ci(auth): actually run the live cache/auth sweep, and fix the stale assertion it was hiding (#5379) Gap 5. tests/live-api-cache-auth-regression.test.mjs is wrapped in describe(..., { skip: !LIVE }) gated on LIVE_API_CACHE_TESTS=1, which nothing in the repo ever set — verified by grep across .github/, package.json and scripts/. The file is in the test:data glob, so every CI run 'passed' it while executing zero assertions; the issue's mutation proof (unconditional throw at the top of the describe) still exited 0. Confirmed locally: without the flag the runner reports 'tests 0, pass 0'. Adds a scheduled workflow modelled on the existing mcp-live-smoke.yml precedent — cron every 6h at :47 (offset from that job's :23 so two live probes don't hit prod from the same runner range in the same minute), push-to-main filtered to the suite + workflow, and workflow_dispatch. Not pull_request: the target is live production, so a PR run could neither exercise its own changes nor fail for reasons the PR caused. No npm ci — the suite imports only node:assert and node:test. No secrets provisioned; the one authenticated probe stays per-test gated on WM_LIVE_TEST_KEY and reports SKIP, not failure. Turning it on immediately surfaced a stale assertion, which is the whole point: the suite required mimeType 'application/json' for EVERY resources/list entry, with a comment asserting 'the catalog is now all concrete, metadata-only resources'. That stopped being true when the MCP-Apps ui:// fleet landed — production correctly serves ui://worldmonitor/country-risk.html as 'text/html;profile=mcp-app' (api/mcp/ui/shell.ts UI_RESOURCE_MIME_TYPE). Production is right; the never-executed test had rotted. Fixed by mirroring the rule the in-process sibling already uses (tests/mcp-resources.test.mjs:383): expected mimeType is chosen by URI scheme, so it remains an exact-match assertion — a resource declaring the WRONG one of the two still fails — and the payload is now verified to parse as what it declares (HTML doctype vs JSON.parse) rather than assuming JSON. Verified against production: 6 pass, 0 fail, 1 skip (the WM_LIVE_TEST_KEY case). Without the env var: still a clean 0-test skip. * test(security): catch identifier-vs-identifier secret comparisons (#5379) Gap 11. The #3803 timing-oracle guard's regex required the right operand to be process.env.* or a token starting with a bare `expected`/`EXPECTED`. Because `expected\b` has no word boundary inside `expectedSecret`, the guard sailed straight past the most natural way to write the bug: probeSecret !== expectedSecret The left arm had the mirror hole: `\b(?:secret)\b` never matched inside `probeSecret`, so even `probeSecret !== process.env.RELAY_SHARED_SECRET` leaked. Now matches a secret-bearing identifier compared against process.env.*, an expected* constant, or ANOTHER secret-bearing identifier, in either order, and supports member chains (`req.headers.token === expectedToken`). SECRET_VARS collapses to ['secret','token','bearer'] since matching moved from whole-word to substring — the compound entries are subsumed, and a generic 'key' is still excluded so cacheKey/sortKey don't drown the guard in noise. Structural change: the pattern and the comment-stripping step are extracted into exported helpers so the meta-test exercises the EXACT regex the real scan uses. The previous meta-test reconstructed a copy-pasted duplicate, so it could pass while the real scan diverged — a guard-testing-a-guard that proved nothing. The table is now the regex's contract, with must-NOT-match rows carrying equal weight: the correct timingSafeEqual idiom, presence/nullish checks, and type checks (typeof token === 'string') must never flag. Seven rows are REAL lines lifted from the api/ tree with file:line attribution (api/oauth/token.ts:725 grantType === 'refresh_token', api/notification-channels.ts:239, and the _mcp-grant-hmac token.length index compare) so the negative cases are grounded in code that actually exists rather than hypotheticals. False positives get guards deleted, which would return us to the original hole. Quoted literals are unreachable by construction: the operator is bracketed by \s*, never .*, so the matcher cannot step over a quote to reach a fragment inside a string. ALLOWLIST_FILES stays empty — the real api/ scan is green. Mutation-proved: dropping the ident-vs-ident arm reds the meta test. 3/3 green. * docs(auth): pin the entitlement-null fail-open posture where the code lives (#5379) The 'design risk' from the issue. Decision: KEEP fail-open. No behavior change — server/gateway.ts and entitlement-check.ts changes are comments only. The posture was previously articulated ONLY inside a test file, so a reader of server/gateway.ts saw a null-entitlement path serving 200 with no sign it was deliberate. The decision site now documents the posture, the blast-radius reasoning (fail-closed turns any Convex/Upstash blip into a fleet-wide 403 for every paying API customer at once), and what bounds the leak. Verified each bound rather than asserting it: - Not reachable by the never-subscribed: minting a wm_ key ITSELF requires an active entitlement with apiAccess (convex/apiKeys.ts throws API_ACCESS_REQUIRED otherwise), so no entitlement row ⇒ no key ⇒ this path is never entered. - Tier-gated routes do NOT inherit it: checkEntitlement fail-CLOSES on null. - Warm path re-resolves within the 15-min cache, so a lapsed user must sustain an outage rather than wait one out. entitlement-check.ts's docstring and catch comment claimed 'fail-closed … caller blocks the request', which is false for this caller and invited someone to 'fix' the fail-open as a bug. Both now state that null means UNRESOLVED and that the conclusion is caller-dependent, naming both callers. Also documents a MISCONFIGURATION HAZARD the original framing missed: a deploy missing CONVEX_SITE_URL or CONVEX_SERVER_SHARED_SECRET returns null for every user on every request, permanently. For the fail-open caller that is NOT a transient blip the cache heals — there is no warm path to recover to, so the gate is silently disabled indefinitely. Marked P1, not a degraded mode. Tests go 10 -> 19, all 10 originals preserved. New cases pin each boundary at its ACTUAL behavior, not an assumed one: null and undefined serve; {features:{}} and apiAccess:false 403 (a resolved-but-empty row fails CLOSED, so the hole is narrower than 'any malformed object'); {} and a throwing getEntitlements propagate rather than serve; and the warm path 403s once a downgrade resolves. Records one KNOWN GAP as a test rather than hiding it: an entitlement with apiAccess:true and a MISSING validUntil is served with expiry unchecked, since `undefined < Date.now()` is false. Not currently reachable — convex/schema.ts declares validUntil: v.number() as required — so this is latent robustness on the cache path, not a live hole. Filed for follow-up. Mutation-proved: flipping the null case to fail-closed reds 4 tests. 19/19 green. * test(auth): pin the Convex-misconfig null path that the fail-open bound assumes away (#5379) The gateway fail-open comment bounds its risk on 'the warm path re-resolves'. That premise assumes the entitlement EVENTUALLY resolves. A deploy missing CONVEX_SITE_URL or CONVEX_SERVER_SHARED_SECRET takes the early return, so every user resolves to null on every request with no self-healing path — the 15-min cache cannot warm what never resolves, silently disabling the #4611 apiAccess gate fleet-wide and indefinitely. Pinned here so the premise of that bound stays honest. Asserts repeated nulls across distinct userIds (not one coalesced in-flight promise) and a same-user retry (no recovery). Env is saved and restored in a finally, matching this file's existing convention — deleting without restoring would leak the broken env into any test appended after this one, which is the same module-state-leak class PR #5370 had to clean up. 20/20 green. * test(auth): make wm_ key fixtures canonical so they match what production can mint (#5379) Fallout from the validateUserApiKey format tightening two commits back, and a gap in my own blast-radius check: I cleared the consumers by grepping static imports, but server/gateway.ts reaches the validator through a DYNAMIC `await import('./_shared/user-api-key')`, so these suites exercised the real implementation rather than a mock and a static-import grep could not see it. Six tests across two files passed keys like 'wm_free_test_key' and 'wm_test_active_key' — readable placeholders that generateKey() (src/services/api-keys.ts) can never produce, since it always mints wm_ + 40 lowercase hex. They were asserting gateway behavior on an input production cannot generate. Replaced with canonical well-shaped fixtures behind named constants so the role stays legible at each call site. No assertion changed: the same x-user-id rewriting, entitlement gating and plan_key telemetry are still asserted, now with a realistic key. The Convex mocks in both files match on URL, not on the key or its hash, so the values are arbitrary provided they are well-shaped. This raises fidelity rather than accommodating the new guard — the old fixtures would have sailed past a format check that production has always effectively had at the Convex layer. tests/*.test.mts: 4352/4352 green (was 4346 pass / 6 fail). * fix(review): close the three P1s the review found in this PR's own work (#5379) Multi-persona review, including an independent cross-model adversarial pass (gpt-5.5 via Codex). Three P1s, two of them defects this PR introduced. 1. MISSING EXPIRY SERVED FOREVER (server/gateway.ts) — found independently by the security reviewer AND the cross-model pass, both P1/100, citing the same line. An entitlement with apiAccess:true and NO validUntil was SERVED: `undefined < Date.now()` is false, so the expiry arm silently no-opped. Worse than the documented null posture, which at least self-heals — this one re-resolves to the same shape every request, so a lapsed subscriber keeps the keyed surface permanently. The sibling MCP gate already got this right (`ent?.validUntil ?? 0`); the gateway now matches. The earlier commit shipped this as a pinned 'KNOWN GAP' test; that was the wrong call when the fix is one nullish-coalesce, so the test is flipped to assert 403 and a second test pins the narrower residual (a non-numeric validUntil still serves — the real fix there is runtime shape validation in getEntitlements, noted not hand-waved). 2. THE NEW CI GATE COULD PASS ON ZERO TESTS (.github/workflows/live-api-cache-auth.yml) — flagged by three independent reviewers. Setting LIVE_API_CACHE_TESTS was not enough: the suite is one `describe(..., { skip: !LIVE })` and `node --test` exits 0 when everything skips, so a rename or typo would have recreated the exact silent-green bug this workflow was written to fix. The suite's own `assert.equal(LIVE, true)` self-check cannot help — it is inside the skipped describe. The step now pins `--test-reporter=tap` (not the default, which Node selects by TTY-ness) and fails unless `# pass N` shows N>=1. Verified both ways: env var absent -> exit 1 with an actionable ::error::; present -> exit 0, 6 passing. 3. THE #3803 TIMING-ORACLE GUARD WAS PASSING VACUOUSLY (tests/no-non-timing-safe-secret-compare.test.mts). `stripComments` deleted 30.2% of api/ by bytes before the scan — measured and reproduced: a glob like `/*.openapi.json` inside a // comment reads as a block-comment OPENER and ate 4160 bytes of api/mcp/types.ts including `export interface RpcToolDef`, and // inside a URL literal truncated real lines. A violation in a swallowed region was invisible. The stripper is gone (raw scan finds zero false positives across all 174 files), and a new test plants a known violation into EVERY api/ file and requires the scan to flag every one — so any future normalisation that eats real code turns red instead of quietly passing. Also from the review: - Widened the same guard to two shapes it missed: a neutrally-named local vs a secret-NAMED env var (neither operand is secret-named, so every ident arm missed it), and an inline `headers.get('x-...-secret') !== expected` — the codebase's dominant header idiom, and the literal shape of #3803 itself. Negative rows pin that non-secret env vars and non-secret header keys stay unflagged. - server/auth-session.ts: added the AGENTS.md-mandated User-Agent to the Clerk fetch. Every other outbound server fetch sets one; this was the sole exception. - Replaced every cross-file numeric line citation in the new posture comments with symbol references. They were already wrong ON ARRIVAL — this PR's own added lines shifted them, so entitlement-check.ts:289 pointed at a function parameter and :229-233 at an unrelated Redis comment. A comment whose value is being checkable must not rot on commit. Verified: typecheck + typecheck:api clean; vitest 816/816; tests/*.test.mts 4353/4353; tests/*.test.mjs + cli 11719 pass / 0 fail; sidecar 230/230. Mutation-proved: removing `?? 0` reds the closed-gap test. * fix(review): restore silently-gutted coverage and make two failure modes observable (#5379) Second review round, from the reliability and testing reviewers. 1. SILENTLY GUTTED TEST (tests/mcp-proxy.test.mjs). Fallout from this PR's key format tightening, in a file my earlier blast-radius sweep missed precisely BECAUSE it kept passing. The test "rejects wm_ user keys when Convex validation cannot run" used the placeholder 'wm_user_abc123'; since the tightening that key is rejected at the format gate before hashing, so the test still returned 401 while covering none of the path its own comment claims to prove - including the MODULE_NOT_FOUND dynamic-import regression it was written to catch. A green test that stopped testing anything is the exact failure class this PR exists to fix. Now uses a canonically-shaped but never-minted key so the request reaches fetchFromConvex and is rejected there. 2. MY OWN COMMENT WAS FACTUALLY WRONG (server/_shared/entitlement-check.ts). The MISCONFIGURATION HAZARD note added earlier in this PR claims the state is "surfaced" by the one-time console.warn in getConvexSharedSecret(). That warn only fires when the SHARED SECRET is missing - a deploy missing only CONVEX_SITE_URL disabled the Convex fallback, and therefore the fail-OPEN #4611 apiAccess gate, with no signal whatsoever. Rather than weaken the comment to match the code, added getConvexSiteUrl() with its own one-time warn so the claim is true. One warn per variable is deliberate: warning on only one of the two is what created this hole. 3. SILENT PRO->FREE DEGRADATION (server/auth-session.ts). lookupPlanFromClerk's catch swallowed everything and returned 'free' with no logging on any path. The AbortSignal.timeout added earlier in this PR made that path newly reachable from an ordinary Clerk stall rather than only a hard network error, so a sustained Clerk outage would silently downgrade every PRO user to free and look identical to a fleet of genuinely free users. Now logs the reason. The verdict is still not cached, so the next request retries. Also found and filed, NOT fixed here: #5384 - server/_shared/user-api-key.ts cannot distinguish "key does not exist" from "Convex unavailable" and negative-caches both for 60s, so a transient 5xx 401s a paying customer for a full minute. Pre-existing; this PR's shape guard runs after cachedFetchJson and neither causes nor worsens it. Fixing it changes the negative-caching contract on a live auth path and deserves its own PR. Verified: typecheck clean; mcp-proxy + auth-resource-timeout 64/64; entitlement-check + gateway-user-key-apiaccess 40/40. * fix(review): de-flake the live sweep and pin the edge cache-key behavior it exposed (#5379) Third review round. A teammate reported the live suite failing against prod on an assertion my own run had passed, which turned out to be the most useful finding in the whole PR. ROOT CAUSE. The suite's fake-auth assertions target URLs the edge caches publicly for 600s, and the cache key does NOT include X-WorldMonitor-Key (`vary: Origin` only). Verified directly against production: cache-busted URL + invalid key -> x-vercel-cache: MISS -> 401, no-store same URL once warm + same key -> x-vercel-cache: HIT -> 200, public (age 39) So the ORIGIN auth logic is correct; the assertion outcome depended entirely on whether the CDN happened to be holding an anonymous response. My run passed and my teammate's failed for that reason alone. On a 6-hourly schedule that is an intermittently-red job no PR can fix - precisely how a guard earns its way into being ignored, which is the failure this PR exists to prevent. FIXES - Fake-auth probes are now cache-busted, so they test origin auth deterministically and keep testing the thing they are named after. Verified by three consecutive runs: 7 pass / 0 fail / 1 skip each time (previously the first assertion flipped with cache state). - Added an explicit test pinning the cached-anonymous behavior, so it is a stated property rather than a flaky side effect. It asserts STRUCTURALLY that the payload served to an invalid key is the anonymous envelope carrying only the requested public key - so an entitled payload landing in a public cache entry (the #4497 incident) goes red. It also passes cleanly if the behavior is later fixed to 401, taking the fail-closed branch, so fixing the underlying issue will not require touching the test. My first version of that assertion compared byte-lengths across two live requests and was itself flaky (61512 vs 61287) - weather data changes between requests and different edge nodes hold differently-sized entries. Replaced with the structural check; adding a flaky test to a commit about flakiness would have been a poor joke. Filed #5386 for the cache-key posture itself - the origin is right, the cache key needs a product decision (accept / add to Vary / bypass-on-header), and api/bootstrap-auth.test.mjs:302 currently asserts a contract production does not honor when warm. Not fixed here: it is a CDN-rule change, not a code change. ALSO FROM REVIEW - api/mcp/auth.ts: documented `!ent` as intentionally-redundant defence-in-depth. It is unkillable by mutation - every read optional-chains to a falsy default, so a falsy ent already forces tier=0/mcpAccess=false/validUntil=0 and is rejected three times over (verified across all six falsy values, and by a double mutation dropping `!ent` AND `tier < 1` which still 401s). Comment-only; git diff confirms no logic change. - server/auth-session.ts: corrected a comment that named VALIDATION_TIMEOUT_MS as living in server/_shared/user-api-key.ts. It does not - that file inlines the 3_000 literal; only api/_user-api-key.js has the constant. Verified: typecheck + typecheck:api clean; vitest 816/816; tests/*.test.mts 4353/4353; tests/*.test.mjs + cli 11719 pass / 0 fail; mcp-proxy 59/59; mcp-auth-entitlement-and-limits 73/73; live suite 7 pass / 0 fail / 1 skip, stable across three consecutive runs. * fix(review): make the anti-vacuous-pass test itself non-vacuous (#5379) Fourth review round, and the third layer of the same lesson. Two commits ago I removed stripComments because it deleted 30.2% of api/ and let the #3803 timing-oracle guard pass vacuously, and I added a companion test that plants a violation into every api/ file. Its comment promised: "any future normalisation step that eats real code turns this test red." That was false, and the reviewer proved it. The companion had its OWN private readFile+match loop and never touched the real scan's code path. I reproduced it: reintroduced the exact #3803-class stripper into the real scan's line ONLY, ran the suite, and all 4 tests passed — including the one whose entire purpose is catching that. A guard-of-a-guard with no teeth, which is precisely the bug it was written to prevent, one level up. FIX — route both paths through one seam. normaliseForScan() is now the single normalisation point between reading a file and matching it. It is the identity function today, deliberately; the point is that anything which ever transforms source MUST live there, because the real scan and the companion both call it. That shared routing is what turns the companion into an actual safety net. The companion also got two strengthenings, because sharing the seam alone was not sufficient: - A direct no-shrinkage assertion: normaliseForScan(source).length must equal source.length for every file. This states the violated property outright rather than waiting for a planted violation to happen to land in a swallowed region. - Violations are planted at THREE positions (top, middle, bottom), not just appended. A swallowing normaliser eats a REGION, not a whole file — appending only at the end survives a stripper that ate the middle, and the companion would have stayed green while the real scan was blind. My first version made exactly that mistake. MUTATION PROOF. Adding the old stripper to normaliseForScan now fails the companion, naming 127 affected files: normaliseForScan DROPPED source for 127 file(s): api/[...notfound].ts, api/_agent-metadata.ts, ... Anything the scan cannot see, it cannot flag — this is how the guard silently stops working. Before this commit the identical mutation left all 4 tests green. Also confirmed from the same review pass, no change needed: the live-sweep workflow's zero-assertion guard is sound. GitHub Actions runs `run:` steps under `bash --noprofile --norc -eo pipefail`, so errexit aborts the step on a genuine test failure before the grep is reached; the grep covers only the distinct "zero assertions ran" case, which is what it is for. tests/*.test.mts: 4353/4353 green. * docs(ci): register live-api-cache-auth.yml in both docs-stats gates (#5379) CI docs-stats went red on the new workflow. Adding a .github/workflows/*.yml file trips TWO separate gates, and passing the first locally does not imply the second: 1. npm run docs:check (docs-stats.mjs --check) -> needs a row in ARCHITECTURE.md's '## 11. CI/CD' table. Reproduced verbatim: 'ARCHITECTURE.md: CI workflow live-api-cache-auth.yml is not listed in the CI/CD table'. 2. The CI docs-stats job ALSO regenerates and freshness-checks docs/generated/stats.json, which stores workflowCount plus the workflow filename list. Ran npm run docs:stats and committed the result: workflowCount 21 -> 22, filename added. docs:check now reports OK, 80 doc claims match code. * fix(lint): drop exports from the secret-compare test (biome noExportsInTest) (#5379) CI biome went red with 3 errors, all mine: biome's lint/suspicious/noExportsInTest forbids exporting from a test file, and this branch had added three exports (buildSecretComparePattern, PATTERN_CASES, normaliseForScan). origin/main has zero exports in this file, so the branch introduced them. Removed the export keywords rather than suppressing the rule. The stated rationale for exporting was auditability from outside the runner, but nothing imports this file and the property that actually matters is unaffected: the real scan and the planted-violation companion are in the SAME module, so they still share one buildSecretComparePattern and one normaliseForScan seam. Re-verified the seam still has teeth after the change — adding a comment-stripper to normaliseForScan reds the companion, naming 124 files: normaliseForScan DROPPED source for 124 file(s): api/[...notfound].ts, ... 4/4 green restored; npm run lint (biome + enforce-safe-html) passes clean. * docs(solutions): capture the verify-the-verifier convention from the #5379 sweep New: docs/solutions/conventions/verify-the-verifier-mutation-test-every-detection-layer.md The durable lesson from #5379 / PR #5385 is not any individual auth bug — it is that four times in one PR, a layer built to DETECT silent failure had a silent failure of its own, and each was found only by breaking the detector and watching whether it noticed: 1. a live suite inert because nothing set its gating env var (CI passed it having run zero assertions); 2. the CI workflow written to fix that, which could itself pass on zero tests because node --test exits 0 when everything skips; 3. a security regression guard passing vacuously because its comment-stripper deleted 30.2% of api/ before matching; 4. the anti-vacuity companion added to fix (3), which had its own private code path and never exercised the real scan — proven by mutation. The doc's reusable core is the smell (a negative assertion passes silently when its input shrinks, so anything that can shrink the input must be separately pinned) and the three-part recipe, with the emphasis that part 1 alone is necessary but NOT sufficient — that is the layer-4 lesson and the part most likely to be skipped. Classified knowledge-track / convention rather than best-practice (the explicit fallback): this is a rule to apply on every future test, CI-gate, or guard PR. First doc in docs/solutions/conventions/. Overlap adjudicated as Moderate, not High, so created rather than merged: best-practices/test-guard-assertions-and-module-state-reset.md (PR #5369/#5370, two PRs earlier on this same auth surface) shares the mutation-proof PRINCIPLE but covers different mechanisms (JSON.stringify coercing Infinity; a leaked module-state reset). Cross-linked both directions in the Related section, along with the country-scope 'mirror test' doc and the ttl-staleness doc (whose static audit fails the opposite way — over-matching rather than under-matching). CONCEPTS.md: added a 'Test & Guard Verification' cluster defining Vacuous Guard and Mutation Proof — both used with precise project-specific meaning across five documented recurrences now, and neither previously defined. Grounded: every file:line citation verified against the tree; the central claim (real scan and companion both route through the shared normaliseForScan seam) confirmed at :268, :309 and :324; merge state confirmed OPEN/18-pass at write time. Frontmatter passes the parser-safety validator. * fix(review): harden auth verification guards * chore: refresh PR mergeability state * fix(auth): close review hardening gaps * test(auth): stub prevalidation limiter in gateway suite * test(mcp): wire pre-auth guard in world brief fixture * fix(global-tenders): spread SAM.gov request budget, stop retrying 429s (#5444) (#5457) * fix(global-tenders): spread SAM.gov request budget, stop retrying 429s (#5444) SAM.gov enforces a small per-key daily quota (10/day for non-federal keys). The hourly seed fetched SAM every tick and retried 429s in-run — up to ~72 requests/day against that budget — so once the quota tripped, every subsequent run 429'd, the source pinned at 'stale', and its age climbed past the 180-minute ceiling (health SEED_ERROR, empty US tender queries). - pace SAM fetches: skip the request while the previous success is fresher than 150 minutes (~9.6 requests/day), carrying the prior records through with lastSuccessfulAt untouched so staleness accounting stays honest - treat SAM 429s as non-retryable in-run via a retry429 opt-out in fetchResponse (quota-style limits do not clear in seconds; retries only burn more budget) - thread previousSnapshot through to source adapters so pacing can see the prior sam status Tests cover the pacing skip, interval expiry, unchanged first-run behaviour, and the no-retry-on-429 contract; all 28 existing tender tests unchanged. * fix(global-tenders): preserve paced source health (#5457) - keep stale and error SAM states degraded during paced runs - align the SAM health window with its effective request cadence - publish paced in the proto, clients, OpenAPI, and MCP contract * fix(security): update pro-test postcss (#5457) Pin postcss 8.5.12 to clear GHSA-6g55-p6wh-862q in the production dependency audit. --------- Co-authored-by: Elie Habib <[email protected]> * fix(forecast): extend EIA settlement grace (#5524) * fix(forecast): extend EIA settlement grace * test(forecast): cover missing EIA metric grace (#5524) * feat(api): enforce the sold daily cap + informative at-cap 429 (#4635) (#4684) The enforcement half of the #4635 lifecycle, behind API_RATE_LIMIT_ENFORCE (shadow-safe until the flip): - U5: reserveDailyMeter now flags at the SOLD allowance (Starter 1,000/day), not the 10x ceiling -- `overLimit: count > allowance` (was `count > allowance * CEILING_MULTIPLIER`). CEILING_MULTIPLIER removed; the `allowance <= 0` guard keeps Enterprise (-1) uncapped. Renamed the meter's `overCeiling` field -> `overLimit`; the daily X-RateLimit-Limit header now advertises the sold cap (was 10x). - U4: the burst + daily 429 bodies now carry { error, plan, limit, limit_type, reset, upgrade_url } instead of a bare "Too many requests" / "Daily request ceiling exceeded". `planKey` is hoisted at the 429 sites; enterprise (top tier) omits upgrade_url. Additive body contract; X-RateLimit-* header shape unchanged. Telemetry reason strings (rl_ceiling_*) kept for dashboard/audit continuity. Meter 16/16 + gateway 18/18 tests green; typecheck:api clean. Unblocks the API_RATE_LIMIT_ENFORCE flip once the notify/upgrade stack lands. Claude-Session: https://claude.ai/code/session_01SjVX3GyMBmC2XyFWCHogN1 * fix(security): bump find-my-way to 9.7.0 — GHSA-c96f-x56v-gq3h (HTTP/2 DDoS) (#5527) A new high advisory against find-my-way <= 9.6.0 (fastify's router, transitive in consumer-prices-core) landed today and reds the audit-lockfile (consumer-prices-core) check on every PR, while path-filtered main stays green. Lockfile-only bump to the first patched version (9.7.0); verified with the exact CI invocation: node .github/scripts/audit-production-dependencies.mjs --workspace consumer-prices-core ... -> "Production audit OK ... 0 high+ advisories" Unblocks #5526 and any other open PR. Claude-Session: https://claude.ai/code/session_01StNurp4TGC3JLHbTtKJhbp * fix(payments): restore lapsed subscriber reactivation path (#5532) * fix(payments): restore lapsed subscriber reactivation path * fix(deps): patch vulnerable find-my-way release * fix(ui): cancel stale pro banner removal * fix(review): correct subscriber reactivation edge cases * chore(bootstrap): wind down the R2-origin experiment (DebugBear sampling + KTD7 no-go) (#5536) * fix(seo): remove unsupported schemamap robots directive (#5544) * test(bootstrap): cover DebugBear sampling boundaries (#5545) * Merge commit from fork * fix(payments): persist failed Dodo webhook incidents (#5533) * fix(payments): persist failed Dodo webhook incidents * fix(payments): close Dodo webhook failure lifecycle * Address PR review feedback (#5533) - Keep recovery bookkeeping errors out of processing incident recording\n- Serialize failure lifecycle updates with the seeded aggregate lock\n- Add concurrent regression coverage and deploy seeding\n\nNote: pre-existing failure in convex/__tests__/poolSelection.test.ts not addressed by this PR. * fix(payments): harden webhook failure rollout * fix(review): dedupe resolution transition, cover aggregate lifecycle branches - Extract applyWebhookFailureResolution so manual resolve and provider-retry recovery share one timestamped transition (review: maintainability) - Add lifecycle tests for changed-eventType redelivery and record-after- resolve reopening (review: testing) - Correct the ops-signal comment: production attempts the queue and logs scheduler failures without changing the retry response (review: maintainability) * fix(giving): disclose published benchmark provenance (#5540) * fix(giving): disclose published estimate provenance * fix(giving): disclose benchmark provenance in panel * test(giving): verify benchmark provenance in browser * fix(review): harden giving provenance states * fix(ci): refresh documentation component counts * fix(ci): retain valid giving data on refresh failure * fix(giving): close review recovery gaps * fix(payments): dead-letter authenticated malformed webhooks instead of 401 (#5547) Split signature verification from payload validation in the Dodo webhook handler. 401 is now reserved for credentials that fail HMAC verification; a well-signed payload that fails JSON parsing or schema validation records a sanitized dead-letter projection and returns 500, so permanent provider- side defects exhaust retries into a repairable incident rather than a mislabeled signature failure (review: adversarial, PR#5533 finding #1). - verifyDodoSignature mirrors the SDK's vendored standardwebhooks scheme (whsec_ base64 secret, HMAC-SHA256, v1 signatures, 5min tolerance) - Payload validation uses the SDK's exported WebhookPayloadSchema, so the accepted shape is identical to verifyWebhookPayload - Extract persistFailureAndSignal shared by the validation and processing failure paths - Tests: signed schema-invalid and unparseable bodies dead-letter with 500; a bad signature still 401s with no failure row * fix(routing): redirect pricing to canonical section (#5548) * feat(i18n): Hoàn thiện bản dịch tiếng Việt (vi.json) (#5539) * feat: hoàn thiện bản dịch tiếng Việt cho vi.json Dịch 168 giá trị chưa dịch sang tiếng Việt (từ 247 giá trị ban đầu). 79 giá trị còn lại giữ nguyên (thuật ngữ kỹ thuật/tên riêng: PRO, AI, DDoS, WTO, AWACS, macOS, WhatsApp, MMSI, SQUAWK...). Các danh mục đã dịch: - header: Tech News → Tin tức Công nghệ, Cybersecurity → An ninh mạng... - panels: Gold Intelligence → Thông tin Vàng, Fear & Greed → Sợ hãi & Tham lam... - components: Central Banks → Ngân hàng Trung ương, Data Freshness → Độ mới Dữ liệu... - popups: US/NATO → MỸ/NATO, CHINA → TRUNG QUỐC, Recent Tracking → Theo dõi gần đây... - modals: Add → Thêm, download banners... - dashboardTabs: Main → Chính, New Tab → Tab mới, Add tab → Thêm tab... - countryBrief, commands, widgets, preferences, premium * fix(i18n): correct Vietnamese tooltip translations --------- Co-authored-by: Elie Habib <[email protected]> * fix(docker): preserve caller auth for local MCP requests (#5492) * fix(docker): preserve caller auth for local MCP requests Fixes #5471 Signed-off-by: Arpit Tagade <[email protected]> * fix(sidecar): strip transport token from cloud proxies --------- Signed-off-by: Arpit Tagade <[email protected]> Co-authored-by: Elie Habib <[email protected]> * fix(docker): preserve caller auth for local MCP requests (#5537) * fix(docker): preserve caller auth for local MCP requests Fixes #5471 Signed-off-by: Arpit Tagade <[email protected]> * fix(sidecar): strip transport token from cloud proxies --------- Signed-off-by: Arpit Tagade <[email protected]> Co-authored-by: Elie Habib <[email protected]> * fix(seo): prevent raw-text crawlers from extracting "WWorld Monitor" Move the decorative "W" brand mark from inline text to a CSS ::after pseudo-element. Raw textContent extraction now yields "World Monitor" instead of "WWorld Monitor". The visual appearance, accessible name, and first-paint footprint are unchanged — the ::after inherits the same font-size and color from .skeleton-brand-mark. Adds focused regression tests in deploy-config.test.mjs asserting: - Raw text does not contain "WWorld" - The brand mark span has no text content - The "W" is rendered via CSS content Fixes #5541 (#5549) * feat(activation): day-0 pro activation onboarding interstitial (#5534) * feat(activation): pure pro-activation state core — mount decision, step model, fire-once keying (U1) Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * feat(activation): interstitial shell — overlay, step chrome, focus trap, exit summary, en copy (U3) Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * feat(activation): brief/alerts/power step wiring + finish-setup chip (U4-U6) Brief: atomic setNotificationConfig with explicit hour+IANA tz, insights world-brief preview, inline hour select. Alerts: pre-denied blocked state, patch-not-clobber channels, cadence-honest copy. Power: injected deep links + R8 settings pointer. Chip: versioned-key dismissal. Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * feat(activation): checkout-return marker + post-reload boot mount hook (U2) Marker written before clearCheckoutAttempt on the success branch only; mount decision evaluated off the boot critical path with bounded snapshot-retry. Surfaces subscriptionId/currentPeriodStart on getSubscriptionForUser (additive, existing columns) for the fire-once key — accepted plan deviation. Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * fix(activation): re-arm mount retry on subscription snapshot changes too With the real subscription snapshot as the fire-once key input, a boot with live entitlement but a not-yet-loaded subscription snapshot would stall in 'keep' forever watching entitlement only. Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * feat(activation): proActivation locale fan-out — 24 locales, register-calibrated (fa per its file convention) Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * feat(activation): funnel telemetry + end-to-end spec (U7) Typed Umami events with whitelisted minimized payloads (planKey/step/exit counts — never billing identifiers); single entered fire site at mount; 7 Playwright scenarios green against the dev server. Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * refactor(activation): simplify pass — shared focus-trap util, leaf record parsers, type reuse, idle-handle cleanup Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * fix(review): apply findings #1-4, #8-12, #14 — hour-capture ref, seeded alerts fallback, preview tri-state, coverage locks P0 #1: digest hour captured in a closure ref so the in-flight re-render can't wipe the user's pick. P1 #2: alerts catch-path seeds from flow context (+email when brief confirmed) instead of empty — convex channels field is full-replace. P1 #3 + P2 #8-12: failed-state e2e, chip assertion, expired-Pro branch, payload combo, catalog-derived drift guard, focus-trap unit tests. P2 #4 tri-state preview guard. P3 #14 unexported helper. Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7 * fix(review): apply findings #6, #7, #13 — account-scoped records, cross-tab mount claim Marker/fire-once/chip records carry the Clerk userId; foreign-user markers never mount (and are left for the buyer, TTL-reaped); unscoped marke…
koala73
added a commit
that referenced
this pull request
Jul 26, 2026
… mutation-proof Code review of #5605 found the outcome signal wrong in both directions and all three new regression guards passing with the bug restored (proven by execution). Addresses every finding. Classification (#1, #5, #8) — `lastAttemptedProvider === 'none'` conflated causes: - too quiet: canAttemptServerSummarization() denies both an anon/free principal AND an entitled one inside the 403/429 cooldown, so a paying user's live entitlement outage was demoted to console.debug for up to 15 min (24 h via Retry-After). trackLLMFailure is a no-op and no captureConsoleIntegration exists, so that was the only signal — the #5600 shape. summarize-gate now exports isServerSummarizationSuppressed() and the chain records which denial it hit. - too loud: CircuitBreaker.execute returns its default WITHOUT running the callback while on cooldown (breaker defaults maxFailures=2, cooldown=5min), so a chain that contacted nobody still reported "All providers failed". Marking now happens INSIDE the breaker callback, and a short-circuit is recorded as its own cause. - the quiet path no longer claims "using designed fallback"; reaching it means browser T5 never ran and callers render an error/unavailable state. Guards (#2, #3, #6) — each of these was green with the bug restored: - indexOf matched the gate name inside a COMMENT, so moving the mark above the gate passed. Source assertions now strip comments; more importantly the mark lives inside the breaker callback, making the ordering structural. - the locality assertion ran against the whole file, so hoisting the state to module scope passed. Now scoped to generateSummary with a creation-count pin and a broadened module-global check. - the raw-warn regex only matched quoted literals while this file's idiom is a template literal. Now delimiter-agnostic. - slice anchors are asserted to resolve; a renamed anchor made slice(a, -1) silently widen to end-of-file. The decision surface moved into a pure classifier covered by an executed truth table rather than grep. Verified: all three original bypasses now fail (32/32 green, 31/32 with each mutation applied); typecheck and biome clean. Claude-Session: https://claude.ai/code/session_018XDm48Kzuv1GQe4PR1qimE
koala73
added a commit
that referenced
this pull request
Jul 26, 2026
… mutation-proof Code review of #5605 found the outcome signal wrong in both directions and all three new regression guards passing with the bug restored (proven by execution). Addresses every finding. Classification (#1, #5, #8) — `lastAttemptedProvider === 'none'` conflated causes: - too quiet: canAttemptServerSummarization() denies both an anon/free principal AND an entitled one inside the 403/429 cooldown, so a paying user's live entitlement outage was demoted to console.debug for up to 15 min (24 h via Retry-After). trackLLMFailure is a no-op and no captureConsoleIntegration exists, so that was the only signal — the #5600 shape. summarize-gate now exports isServerSummarizationSuppressed() and the chain records which denial it hit. - too loud: CircuitBreaker.execute returns its default WITHOUT running the callback while on cooldown (breaker defaults maxFailures=2, cooldown=5min), so a chain that contacted nobody still reported "All providers failed". Marking now happens INSIDE the breaker callback, and a short-circuit is recorded as its own cause. - the quiet path no longer claims "using designed fallback"; reaching it means browser T5 never ran and callers render an error/unavailable state. Guards (#2, #3, #6) — each of these was green with the bug restored: - indexOf matched the gate name inside a COMMENT, so moving the mark above the gate passed. Source assertions now strip comments; more importantly the mark lives inside the breaker callback, making the ordering structural. - the locality assertion ran against the whole file, so hoisting the state to module scope passed. Now scoped to generateSummary with a creation-count pin and a broadened module-global check. - the raw-warn regex only matched quoted literals while this file's idiom is a template literal. Now delimiter-agnostic. - slice anchors are asserted to resolve; a renamed anchor made slice(a, -1) silently widen to end-of-file. The decision surface moved into a pure classifier covered by an executed truth table rather than grep. Verified: all three original bypasses now fail (32/32 green, 31/32 with each mutation applied); typecheck and biome clean. Claude-Session: https://claude.ai/code/session_018XDm48Kzuv1GQe4PR1qimE
koala73
added a commit
that referenced
this pull request
Jul 26, 2026
…by-design chains (#5377) (#5605) * fix(summarization): stop logging 'All providers failed' for declined-by-design chains (#5377) Both chain-exhausted sites (BETA + normal mode) warned 'All providers failed' even when nothing was attempted — the entitlement gate (#4913/#4915) had declined every server dispatch and browser T5 was unavailable, which is the designed anonymous path. The outage-shaped warning is indistinguishable from a real provider failure in console triage and cost the #5377 investigation two wrong hypotheses. Route both sites through logChainOutcome(): when lastAttemptedProvider is still 'none' (tryApiProvider marks attempts only after the feature + entitlement gates; tryBrowserT5 only after the mlWorker availability check) log at debug with an accurate 'skipped: no eligible provider' message; reserve warn for chains where a provider was genuinely attempted and failed. Note: #5377's part 1 (the enqueue gate — zero anon summarize dispatches) already landed in #4915 via configureSummarizeGate(hasPremiumAccess) and is pinned by tests/summarize-entitlement-gate; this closes the remaining acceptance item. Tests: extended tests/summarize-entitlement-gate.test.mts — the fail sites must route through logChainOutcome (no unconditional string-literal 'All providers failed' warn), debug-before-warn branch on 'none', and attempt marking must stay behind the gates so 'none' means declined-by-design. 21/21 green; RED (1 fail) against the pre-fix source. tsc clean. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(summarization): isolate provider attempt tracking * docs: update service module count * fix(review): make the #5377 outcome classifier correct and its guards mutation-proof Code review of #5605 found the outcome signal wrong in both directions and all three new regression guards passing with the bug restored (proven by execution). Addresses every finding. Classification (#1, #5, #8) — `lastAttemptedProvider === 'none'` conflated causes: - too quiet: canAttemptServerSummarization() denies both an anon/free principal AND an entitled one inside the 403/429 cooldown, so a paying user's live entitlement outage was demoted to console.debug for up to 15 min (24 h via Retry-After). trackLLMFailure is a no-op and no captureConsoleIntegration exists, so that was the only signal — the #5600 shape. summarize-gate now exports isServerSummarizationSuppressed() and the chain records which denial it hit. - too loud: CircuitBreaker.execute returns its default WITHOUT running the callback while on cooldown (breaker defaults maxFailures=2, cooldown=5min), so a chain that contacted nobody still reported "All providers failed". Marking now happens INSIDE the breaker callback, and a short-circuit is recorded as its own cause. - the quiet path no longer claims "using designed fallback"; reaching it means browser T5 never ran and callers render an error/unavailable state. Guards (#2, #3, #6) — each of these was green with the bug restored: - indexOf matched the gate name inside a COMMENT, so moving the mark above the gate passed. Source assertions now strip comments; more importantly the mark lives inside the breaker callback, making the ordering structural. - the locality assertion ran against the whole file, so hoisting the state to module scope passed. Now scoped to generateSummary with a creation-count pin and a broadened module-global check. - the raw-warn regex only matched quoted literals while this file's idiom is a template literal. Now delimiter-agnostic. - slice anchors are asserted to resolve; a renamed anchor made slice(a, -1) silently widen to end-of-file. The decision surface moved into a pure classifier covered by an executed truth table rather than grep. Verified: all three original bypasses now fail (32/32 green, 31/32 with each mutation applied); typecheck and biome clean. Claude-Session: https://claude.ai/code/session_018XDm48Kzuv1GQe4PR1qimE * chore(docs): regenerate stats after rebase onto main Claude-Session: https://claude.ai/code/session_018XDm48Kzuv1GQe4PR1qimE --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Elie Habib <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Description
src/services/ais.tsthat fetches/api/ais/signals, normalizesdisruptionsanddensityelements, validates fields and returns typedAisDisruptionEvent[]andAisDensityZone[].fetchAisSignalsinsrc/App.ts, addingloadAisSignals()to initialloadAllData()andsetupRefreshIntervals(), and callingmap?.setAisData(...)on success.src/config/ais.ts, addingAPI_URLS.aisSignalsandREFRESH_INTERVALS.aisinsrc/config/index.ts, and exporting the new service insrc/services/index.ts.AisDisruptionEvent/AisDensityZoneandaistoMapLayersinsrc/types/index.ts, added AIS rendering (renderAisDensity,renderAisDisruptions) andsetAisDatainsrc/components/Map.ts, added AIS popup rendering insrc/components/MapPopup.ts, and added styles insrc/styles/main.cssand status entries insrc/components/StatusPanel.ts.Testing
npm run devand Vite reported ready on local and network URLs, which succeeded.AISlayer, and capture a screenshot, but the Playwright browser crashed (TargetClosedError / SIGSEGV) so the script failed.Codex Task