Skip to content

fix(payments): re-check Dodo before stale-subscription denial#5447

Merged
koala73 merged 12 commits into
mainfrom
codex/issue-4770-on-demand-renewal-reconciliation
Jul 23, 2026
Merged

fix(payments): re-check Dodo before stale-subscription denial#5447
koala73 merged 12 commits into
mainfrom
codex/issue-4770-on-demand-renewal-reconciliation

Conversation

@koala73

@koala73 koala73 commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Summary

Customers whose renewal had reached Dodo but not yet the local entitlement row could be denied Pro, API, or MCP access at the old validUntil. Recently stale subscriptions are now verified on demand: active provider state refreshes access, affirmative inactive state confirms lapse, and timeouts, provider lag, or unresolved multi-subscription state remain fail-closed but retryable with 503 instead of becoming a hard denial.

  • A durable claim coalesces concurrent verification, while a two-second Dodo budget, no retries, and short outcome cooldowns keep the request hot path bounded.
  • A fresh entitlement read after reconciliation lets a concurrent renewal webhook win. Multiple stale subscriptions advance safely with at most one provider call per action, and an active provider response without a future period is treated as uncertain rather than lapsed.
  • HTTP, wm_ API-key, internal MCP, and edge MCP paths preserve the same billing contract: confirmed lapse is a hard denial; pending or failed verification carries Retry-After and no-store. Short-lived markers also prevent free users from repeatedly invoking reconciliation.
  • Structured billing logs and the billing_verification_503 usage reason expose provider failures and retryable denials without logging secrets.

The provider lookup intentionally remains fail-closed: if Dodo cannot be verified within the budget, access is not granted, but callers receive a bounded retry signal rather than a misleading auth or lapse response.

Fixes #4770.

Validation

  • npm run test:data — 16,050 passed, 0 failed, 6 skipped
  • npm run test:convex — 803 passed
  • npm run test:sidecar — 256 passed
  • npm run typecheck:all and Convex TypeScript check — passed
  • npm run lint — passed (pre-existing warnings only)
  • npm run build — production build passed
  • Post-rebase pre-push gate — frontend/API/Convex typechecks, edge bundle checks, architectural guards, and changed tests passed

Post-Deploy Monitoring & Validation

  • Owner/window: Payments/API on-call for the first 24 hours after deployment.
  • Logs: watch [billing/on-demand-renewal] and [billing/reconcile] for claim, active, lapse, timeout, and failure outcomes; correlate gateway traffic tagged with billing_verification_503.
  • Healthy criteria: recently renewed users recover paid access without manual intervention; retryable 503s are brief; confirmed-lapse 403s do not spike; repeated requests for one user remain inside the verification cooldowns.
  • Validation: compare Dodo verification attempts, active recoveries, hard lapses, and retryable failures before and after deploy; spot-check API-key and MCP responses for matching 403/503 semantics.
  • Rollback trigger: sustained growth in Dodo timeouts or verification actions, prolonged 503s for renewed users, or an unexpected hard-denial increase. Revert this PR to return to webhook/daily reconciliation while investigating.

Compound Engineering
GPT-5

@vercel

vercel Bot commented Jul 22, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Jul 23, 2026 8:53am

Request Review

@greptile-apps

greptile-apps Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an on-demand Dodo provider re-verification step for recently-stale subscriptions (within a 3-day window) to prevent false denials when renewal webhooks haven't propagated yet. It is fail-closed throughout: verified lapses return 403, provider errors return 503 with retry hints, and the wm_ API-key path is hardened from fail-open to fail-closed on null entitlements.

  • New on-demand verification flow (convex/payments/billing.ts, convex/http.ts): a durable OCC lease (claimRecentlyStaleSubscriptionForVerification) coalesces concurrent requests before calling Dodo with a 2 s timeout; the result drives billingStatus markers cached in Redis with short TTLs (60 s for failures, 300 s for confirmed lapses).
  • Gateway and API-key paths updated (server/gateway.ts, server/_shared/entitlement-check.ts, api/_user-api-key.js, api/mcp/auth.ts): all premium-check paths now inspect billingStatus before the normal tier gate and return 403/503 with the appropriate X-Billing-Verification header.
  • Schema extended (convex/schema.ts, convex/payments/subscriptionHelpers.ts): renewalVerificationState and renewalVerificationAttemptAt fields added to subscriptions; live-renewal webhooks clear these fields so normal traffic never enters the on-demand path unnecessarily.

Confidence Score: 3/5

Safe to merge with caution — one P1 regression where renewed users can remain locked out for up to 5 minutes after a confirmed-lapse verdict; the rest of the logic is sound and well-tested.

The PR is architecturally solid with correct OCC lease, fail-closed semantics, and comprehensive test coverage. The P1 finding (post-renewal access blocked for up to 300 s by the lapsed Redis marker's expired validUntil) is a regression introduced by the marker fast-path bypassing the normal TTL check, affecting real paying users who renew via the portal after a lapse verdict. The P2 finding (dead on_hold arm in outcomeIsUncertain) doesn't affect correctness.

convex/http.ts (lines 204–212, validUntil override for lapsed responses) and api/_user-api-key.js (lines 337–347, same fast-path pattern) need attention before merging.

Important Files Changed

Filename Overview
convex/payments/billing.ts Core of the PR: adds claim/finalize/verify on-demand renewal actions. Logic is well-structured with durable OCC lease, bounded Dodo call, and fail-closed finalization. One design subtlety: outcomeIsUncertain marks on_hold as uncertain but outcomeConfirmsCoveringPeriod preempts it for currentPeriodEnd >= now cases.
convex/http.ts Refactors internalEntitlementsHttpHandler for testability and adds on-demand renewal verification for tier-0 users. Post-action re-read lets concurrent renewal webhooks win. P1 finding: lapsed response should forward-date validUntil to enable post-renewal recovery.
server/_shared/entitlement-check.ts Adds getBillingVerificationDenial helper and verification-marker cache logic. Short-TTL markers are correctly served before the expiry check so cooldown requests stop at Redis.
server/gateway.ts Switches wm_-key null-entitlement path from fail-open 200 to fail-closed 503; adds billing-verification denial checks on internal MCP, legacy bearer, and API-key paths.
api/_user-api-key.js Adds billing-verification cache hit paths before the validUntil check. Same fast-path pattern as entitlement-check.ts — affected by the same lapsed-marker recovery regression.
api/mcp/auth.ts Adds getMcpBillingVerificationDenial wrapping the shared helper with JSON-RPC error formatting. Correct null guard on entitlements cast.
convex/schema.ts Adds renewalVerificationState, renewalVerificationAttemptAt optional fields and a new compound index by_userId_status_currentPeriodEnd used by the on-demand claim query.
convex/payments/subscriptionHelpers.ts Clears new verification fields in handleSubscriptionActive and handleSubscriptionRenewed so a live webhook resets the on-demand state correctly.
convex/tests/billing.test.ts Comprehensive on-demand verification test suite covering active recovery, confirmed lapse, multi-subscription sequencing, finalize failure containment, Dodo failure throttling, and expired-lease re-claim.
tests/convex-http-json-object-guard.test.mjs Updated guard test to handle named handlers extracted outside the inline httpAction callback — correctly chases through the function body to verify parseJsonObjectBody is still called.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Gateway as server/gateway.ts
    participant Redis
    participant Convex as convex/http.ts
    participant Billing as billing.ts (Action)
    participant Dodo as Dodo Provider

    Client->>Gateway: POST /entitlements (wm_ API key)
    Gateway->>Redis: GET cached entitlements
    alt Cache hit (marker TTL path)
        Redis-->>Gateway: billingStatus marker (lapsed/failed)
        Gateway-->>Client: 403/503 (getBillingVerificationDenial)
    else Cache miss or expired
        Redis-->>Gateway: null
        Gateway->>Convex: internalEntitlementsHttpHandler
        Convex->>Convex: getEntitlementsByUserId → tier check
        alt "tier > 0 (active subscription)"
            Convex-->>Gateway: "entitlements (tier>0)"
        else "tier == 0 (stale/lapsed candidate)"
            Convex->>Billing: verifyRecentlyStaleSubscriptionOnDemand
            Billing->>Billing: claimRecentlyStaleSubscriptionForVerification (OCC lease)
            alt currentPeriodEnd not within 3-day window
                Billing-->>Convex: status not_applicable
            else within window
                Billing->>Dodo: reconcileSubscription (2s timeout)
                alt Dodo success — period advanced
                    Dodo-->>Billing: "reconciled, new currentPeriodEnd >= now"
                    Billing->>Billing: finalizeRecentlyStaleSubscriptionVerification
                    Billing-->>Convex: status active
                else Dodo confirms lapse
                    Dodo-->>Billing: reconciled, expired or period not advanced
                    Billing->>Billing: finalize sets lapsed
                    Billing-->>Convex: status subscription_lapsed
                else Dodo timeout or error
                    Dodo-->>Billing: error / timeout
                    Billing->>Billing: finalize sets failed (60s cooldown)
                    Billing-->>Convex: status renewal_verification_failed
                end
                Convex->>Convex: re-read entitlements after verification
            end
            Convex-->>Gateway: entitlements + billingStatus + retryAfterSeconds
        end
        Gateway->>Redis: SET entitlements (TTL: billingMarkerTtlSeconds or 900s)
        alt billingStatus present
            Gateway-->>Client: 403 subscription_lapsed or 503 renewal_verification_failed
        else no billingStatus
            Gateway-->>Client: 200 entitlements
        end
    end
Loading

Reviews (1): Last reviewed commit: "fix(payments): harden renewal verificati..." | Re-trigger Greptile

Comment thread convex/http.ts
Comment thread convex/payments/billing.ts
…code, lock verification guards

Review fixes for #5447 (multi-agent review):

- server/gateway.ts: extract denyForBillingVerification() — the identical
  getBillingVerificationDenial + emitRequest + return block appeared at the
  internal-MCP re-check, wm_-key, and legacy-bearer sites.
- convex/payments/billing.ts: extract queryRecentlyStaleActiveSubscriptions()
  for the byte-identical claim/resolution index queries.
- server/_shared/entitlement-check.ts + api/_user-api-key.js: extract the
  Retry-After clamp used twice per file (per-file helpers; the .js side cannot
  import the .ts module under node --test).
- server/_shared/entitlement-check.ts: narrow getBillingVerificationDenial()
  to Pick<CachedEntitlements, 'billingStatus' | 'retryAfterSeconds'>, removing
  the cast in api/mcp/auth.ts.
- api/bootstrap.js: billing-verification denials now carry the machine-readable
  {code} in the body, matching the REST gateway contract (+ wire tests).
- convex/__tests__/billing.test.ts: lock the NODE_ENV test-injection guard
  (mutation-tested red), the post-reconcile resolution "active" branch, and the
  all-lapsed cooldown fallback.

Verified: vitest 173/173 (billing, internal-entitlements, entitlement-check,
gateway suites), node --test 68/68 (user-api-key, bootstrap-auth), tsx --test
187/187 (mcp, gateway-internal-mcp, json-object guard), typecheck +
typecheck:api + convex tsc, biome lint clean.

Claude-Session: https://claude.ai/code/session_015Xu7ySmJDshR4o3vHxWkKZ
… config-aware fail-open

Implements the three P1 findings + remaining dials from the #5447 multi-agent
review (verdict was Not ready):

Marker race (P1): a renewal webhook landing while a request's Convex read was
in flight could be clobbered by that request's later Redis marker write (bare
SET, last-writer-wins), hard-403ing a just-paid customer until marker TTL.
- upsertEntitlements now schedules a second cache sync at +15s (longer than
  any edge request lifetime), overwriting late stale-marker writes.
- Hard-403 subscription_lapsed marker TTL 300s -> 60s: its TTL is the
  worst-case wrongful-denial window; the row-level 5-min lapsed cooldown
  already suppresses Dodo calls, so the cost delta is negligible.

MCP error contract (P1): confirmed lapse now emits -32002 at HTTP 403 instead
of overloading -32001 (docs/mcp-error-catalog.mdx pairs -32001 exclusively
with 401 + WWW-Authenticate + OAuth-reauth recovery — doc-following agents
looped through pointless re-auth). Catalog updated (new -32002 section,
billing rows in the -32603/HTTP tables, emitter list). Mid-call denials
survive the tool-fetch layer: new api/mcp/billing-denial.ts throws a typed
BillingDenialError from _execute fetches (header-detected), and dispatch
re-emits the full contract (status, Retry-After, X-Billing-Verification,
data.code) instead of flattening to 200/-32603 "data fetch failed".

Config-aware fail-closed (P1): getEntitlements() returning null because
CONVEX_SITE_URL / shared secret are UNSET is a deploy defect, not customer
billing state — the wm_-key path now fails open with a loud error log for
that case (new isEntitlementBackendConfigured()); genuine verification
failures keep the retryable 503.

Dials (P2s): edge-MCP usage telemetry classifies billing 503s as
billing_verification_503 via a new 'billing' phase (was auth_unavailable —
false outage pages); concurrent-waiter Retry-After now sized to the leader's
expected completion (~3s), not the 15s crash-safety lease; not_applicable
marker TTL 60s -> 300s (validated safe: every tier-changing write
unconditionally overwrites the key via syncEntitlementCache) cutting
never-subscribed-cohort Convex load ~5x; docs/usage-errors.mdx documents the
four new codes.

Deferred: OpenAPI spec injector for the 503/code contract -> #5463 (34
generated specs; split to keep this PR reviewable). billing.ts module split
(review #11) intentionally skipped as optional churn.

Verified: vitest 175/175 (billing, internal-entitlements, entitlement-check,
gateway suites incl. new resync + fail-open tests), node --test 68/68,
tsx --test 260/260 (mcp incl. two new mid-call denial tests, internal-mcp,
guard, procurement, output-contracts, quota-concurrent, transport, usage);
typecheck + typecheck:api + convex tsc + biome clean.

Claude-Session: https://claude.ai/code/session_015Xu7ySmJDshR4o3vHxWkKZ
@mintlify

mintlify Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
WorldMonitor 🟢 Ready View Preview Jul 22, 2026, 7:34 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

…ls, classify mid-call billing telemetry

Fresh-eyes review round on #5447 (12 reviewers + 15 validators against
6212ee1). Applies every validated finding with a mechanical fix:

Stale-grant race in the race fix (correctness + adversarial, validated): the
+15s delayed cache re-sync replayed the upsert-time snapshot, so a renewal
followed within 15s by a downgrade/cancel re-cached the stale higher-tier
snapshot for up to 900s — a stale-GRANT window absent pre-PR (the read-side
validUntil check passes the future-dated snapshot). The delayed job is now
resyncEntitlementCacheFromDb, which re-reads the entitlement row at fire
time; by construction it cannot revert a newer write. Tests assert the
scheduled pair shape and that the re-sync writes current state.

get_airspace billing swallow (maintainability + api-contract + agent-native,
validated): the one tool left off the billing-denial propagation. A denial on
the requested leg silently became a plausible partial:true 200 ('no aircraft')
— worse than an error. Both legs now run throwIfBillingDenial and any
BillingDenialError rejection is rethrown ahead of the partial/both-failed
paths. Two tests lock the 403/503 contracts through the tool.

Mid-call billing telemetry (adversarial + testing, validated): dispatch's
re-emitted denials bypassed the new 'billing' phase — a mid-call 503 counted
as rate_limit_degraded and a 403 as ok. handler.ts now keys the phase on
X-Billing-Verification for dispatched responses too, and BillingDenialError
logs/captures at warning, not error (expected customer state).

Also applied: BillingVerificationStatus is now a single exported type
(entitlement-check.ts) imported by types.ts/auth.ts/billing-denial.ts (was
hand-duplicated in four files); not_applicable marker TTL 300s -> 900s in both
copies (validator re-audited the overwrite-on-tier-change invariant; restores
pre-#4770 cache economics for the never-subscribed cohort); the resync-delay
comment now states the real bound (Convex-read->Redis-write span, not tool
timeouts); entitlement_verification_unavailable carries X-Billing-Verification
like its siblings; Retry-After parse distinguishes missing from zero;
usage-errors.mdx no longer calls the fixed 5s Retry-After dynamic.

New coverage: finalize CAS no-op guard, remote_status_unusable on-demand
mapping, tier-gated gateway billing-503 passthrough, billing-denial unit
matrix (unknown/absent markers, headerless doubles, non-finite Retry-After),
TS/JS marker-TTL parity scrape test.

Verified: vitest 179/179, node --test 68/68, tsx --test 271/271, typecheck +
typecheck:api + convex tsc, biome clean.

Claude-Session: https://claude.ai/code/session_015Xu7ySmJDshR4o3vHxWkKZ
…uler ms-tick in re-sync test

- api/api-route-exceptions.json: api/mcp/billing-denial.ts is a pure helper
  module for the MCP handler family (exports no route); the sebuf contract
  gate requires every api/ file to be a gateway or a listed exception.
- billing.test.ts: the two runAfter calls each stamp their own Date.now()
  (not faked), so the scheduled delta can be 15001ms when the calls straddle
  a millisecond tick — assert [15000, 15100) instead of exact equality.

Verified: lint:api-contract clean (140 files, 106 entries); billing.test.ts
115/115 x3 runs; biome clean.

Claude-Session: https://claude.ai/code/session_015Xu7ySmJDshR4o3vHxWkKZ
…CP + bootstrap, dedupe renewal state machine, harden tests

Review fixes for #4770 (PR #5447), round 4:

- api/mcp/billing-denial.ts + api/mcp/auth.ts: recognize the gateway's
  entitlement_verification_unavailable 503 in tool _execute fetches so a
  backend-unreachable denial keeps its status/Retry-After/X-Billing-
  Verification contract instead of flattening into -32603 at HTTP 200
  (wm_-key MCP contexts sign with X-WorldMonitor-Key, not internal HMAC).
- api/_user-api-key.js: the bootstrap wm_-key surface now emits the same
  documented entitlement_verification_unavailable contract on a Convex
  lookup failure (docs/usage-errors.mdx previously promised it unqualified).
- convex/payments/billing.ts: fold the triplicated pending/failed/lapsed
  union arms into RenewalCooldownOutcome; single-copy the finalize-then-
  return pattern in the resolution switch (behavior unchanged).
- convex/__tests__/billing.test.ts: pin the resolution-stage sibling
  cooldown Retry-After threading (40s, not the 1s progress retry) and
  getOnDemandRenewalResolution's pending branch.
- tests/billing-marker-ttl-parity.test.mjs: pin the Retry-After fallback
  default across the TS/JS mirror (was unpinned drift surface).
- docs/mcp-error-catalog.mdx: document the new mid-call 503 row.

Validation: tsx mcp/billing-denial/parity suites 153 pass; node --test
user-api-key/bootstrap-auth 68 pass; vitest convex billing suites 134
pass; typecheck:api + convex tsc clean.

Claude-Session: https://claude.ai/code/session_01S1xbUWd2W7nNQVE21U2LQw
…le backoff

Review finding #4 (PR #5447 round 4), option (a): request-path renewal
verification previously reused reconcileOneStaleRow's shared bookkeeping,
so a customer retrying through a Dodo blip (one attempt per 60s cooldown)
bumped reconcileFailureCount toward the 30-day backoff cap and silently
deferred the nightly reconciler for that row once the user went idle.

- markDodoReconcileAttempt / applyDodoSubscriptionReconciliation gain an
  optional source ("cron" | "on_demand", default "cron"). on_demand
  attempts never touch the backoff pair (reconcileFailureCount /
  lastReconcileAttemptAt) but fully participate in the consecutive-404
  streak (reconcileNotFoundCount) in BOTH directions — a definitive 404
  advances the terminal "deleted in Dodo" gate and any non-404 provider
  evidence resets it, keeping one coherent streak definition across paths.
- verifyRecentlyStaleSubscriptionOnDemand threads source: "on_demand";
  cron call sites are unchanged (default preserves behavior).
- schema.ts comment updated to describe the real ownership split.
- Two regression tests pin the isolation; mutation-tested: reverting the
  markDodoReconcileAttempt guard turns both red.

Validation: vitest convex suites 138 pass; convex tsc clean.

Claude-Session: https://claude.ai/code/session_01S1xbUWd2W7nNQVE21U2LQw
@koala73
koala73 merged commit daa4924 into main Jul 23, 2026
25 checks passed
koala73 added a commit that referenced this pull request Jul 23, 2026
…confirmed denial

Review finding #2 (PR #5447 round 4, P1): when the on-demand Dodo re-check
pushed /api/internal-entitlements past the gateway's 3s fetch budget, the
abort collapsed to the same null as "no entitlement", and every non-wm_
surface hard-denied (403 at checkEntitlementDetailed before the billing
branch; 401 at the internal-MCP and MCP gates, sending agents into a
pointless re-auth loop) — reproducing the exact hard-denial-instead-of-
retryable-503 this PR eliminates, via ordinary latency variance.

- getEntitlements() now returns a synthesized, deny-side
  verificationUnavailable marker (free-shaped, tier 0, never cached) on
  TRANSIENT failures: fetch abort/timeout, network error, Convex 5xx.
  null narrows to "unconfigured backend or confirmed/malformed answer"
  (4xx stays null: a bad shared secret is a deploy defect, not transient),
  preserving the wm_-key misconfig fail-open carve-out unchanged.
- getBillingVerificationDenial() maps the marker to the retryable
  entitlement_verification_unavailable 503 (Retry-After 5, no-store,
  X-Billing-Verification), so all three gateway sites (wm_ key,
  internal-MCP re-check, legacy bearer) and checkEntitlementDetailed
  route it automatically via their existing denial-first calls.
- getMcpBillingVerificationDenial() maps the marker to the same -32603 @
  503 JSON-RPC envelope, replacing the -32001 @ 401 re-auth advice at the
  MCP entitlement gate; with the round-4 allowlist fix, wm_-key tool
  fetches propagate the denial end-to-end through dispatch.
- Docs: usage-errors.mdx code table broadened to all surfaces;
  mcp-error-catalog.mdx 503 site list updated.

Deliberately NOT included: an elapsed-time short-circuit inside
verifyRecentlyStaleSubscriptionOnDemand — the Dodo call is already
hard-bounded (2000ms, maxRetries 0) and the remaining chain is fast
mutations; a wall-clock guard would shave sub-second tails at the cost of
nondeterminism in a payments action. The graceful-503 above is the fix.

Validation: vitest 824 pass (incl. 4 new transient-split tests); tsx MCP
suites 205 pass (incl. new pre-check 503 test); mutation-tested both ways
(sentinel->null: 2 red; MCP mapping removed: 1 red); typecheck:all +
convex tsc clean.

Claude-Session: https://claude.ai/code/session_01S1xbUWd2W7nNQVE21U2LQw
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 &amp; last so RSS entities aren't decoded twice (#5432)

* fix(feed-digest): decode &amp; last so RSS entities aren't decoded twice

`decodeXmlEntities` decoded `&amp;` first. That turns the escaped ampersand of
`&amp;lt;` into a live `&`, which the very next `.replace` immediately consumes
as `&lt;` — so a single pass decodes two levels. `&amp;` has to go last.

The visible damage differs by call site:

- `extractTag` (titles): a headline whose literal text is `&lt;script&gt;`
  arrives escaped as `&amp;lt;script&amp;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 &lt;script&gt; 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 `&#128512;`
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 &apos; branch in the round-trip helper

Review follow-up. The local escapeXml helper didn't escape apostrophes, so the
round-trip assertion never produced '&apos;' 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…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(payments): re-check Dodo before hard-denying recently stale active subscriptions

1 participant