Surfaced during the #3199 shadow-audit: cancelled / downgraded customers retain full programmatic API access via their old wm_ key.
Mechanism (verified in code)
validateUserApiKey (server/_shared/user-api-key.ts) validates key existence / not-revoked only — it never checks the owner's subscription/entitlement. A lapsed customer's key still resolves to their userId.
- The only
apiAccess gate for user keys is server/gateway.ts:932 (if (isUserApiKey && needsLegacyProBearerGate …)), scoped to PREMIUM_RPC_PATHS. Tier-gated routes 403 via checkEntitlement. Every other public /api/*/v1/* endpoint has no apiAccess check for user keys → served.
Evidence (Axiom wm_api_usage, 30d)
Two accounts with planKey: free + expired entitlement (apiRateLimit: 0) — keys minted while they were paying API Starter, never revoked on downgrade:
- 2,633 requests served (200), only 178 blocked (403 tier_403), 1×304.
- Served the core paid surface:
list-market-quotes, list-acled-events, commodity/crypto quotes, list-cyber-threats, list-fire-detections, list-military-flights, macro signals, vessel snapshots, cable-health.
Impact
The API Starter product (keyed, cross-origin, programmatic access — the thing customers pay ~$99/mo for) is not revoked on churn. Cancelled customers keep it free. Revenue-integrity gap, not a data breach (the underlying data is public). Pre-existing (predates #3199). #3199 rate-limit enforcement does NOT close it — an expired key has apiRateLimit:0 → ineligible for the per-account layer → falls to per-IP → still served.
Fix
Enforce active apiAccess on all wm_ user-key requests, not just PREMIUM_RPC_PATHS:
Acceptance criteria
Surfaced during the #3199 shadow-audit: cancelled / downgraded customers retain full programmatic API access via their old
wm_key.Mechanism (verified in code)
validateUserApiKey(server/_shared/user-api-key.ts) validates key existence / not-revoked only — it never checks the owner's subscription/entitlement. A lapsed customer's key still resolves to theiruserId.apiAccessgate for user keys isserver/gateway.ts:932(if (isUserApiKey && needsLegacyProBearerGate …)), scoped toPREMIUM_RPC_PATHS. Tier-gated routes 403 viacheckEntitlement. Every other public/api/*/v1/*endpoint has no apiAccess check for user keys → served.Evidence (Axiom
wm_api_usage, 30d)Two accounts with
planKey: free+ expired entitlement (apiRateLimit: 0) — keys minted while they were paying API Starter, never revoked on downgrade:list-market-quotes,list-acled-events, commodity/crypto quotes,list-cyber-threats,list-fire-detections,list-military-flights, macro signals, vessel snapshots, cable-health.Impact
The API Starter product (keyed, cross-origin, programmatic access — the thing customers pay ~$99/mo for) is not revoked on churn. Cancelled customers keep it free. Revenue-integrity gap, not a data breach (the underlying data is public). Pre-existing (predates #3199). #3199 rate-limit enforcement does NOT close it — an expired key has
apiRateLimit:0→ ineligible for the per-account layer → falls to per-IP → still served.Fix
Enforce active
apiAccesson allwm_user-key requests, not justPREMIUM_RPC_PATHS:isUserApiKeyrequest, resolve the entitlement (already done in the fix(api): P1 — enforce per-tier API limits (Phase 1: per-account burst + usage meter + safety ceiling) #3199 rate-limit block) and reject with 403 "API access requires an active subscription" when!features.apiAccessor the entitlement is expired (validUntil < now).subscription.expired/ downgrade webhook (defense in depth; makes keys visibly dead).Acceptance criteria
wm_key whose owner lacks activeapiAccess→ 403 on all/api/*routes (public + premium), not just tier-gated ones.apiAccessis unaffected.