Skip to content

feat(auth): user-facing API key management (create / list / revoke)#3125

Merged
koala73 merged 5 commits into
mainfrom
feat/api-key-management
Apr 17, 2026
Merged

feat(auth): user-facing API key management (create / list / revoke)#3125
koala73 merged 5 commits into
mainfrom
feat/api-key-management

Conversation

@SebastienMelki

Copy link
Copy Markdown
Collaborator

Summary

  • Full-stack API key management: users can create, list, and revoke API keys from the Settings UI
  • Keys are generated client-side, SHA-256 hashed, and only the hash is stored in Convex — plaintext shown once on creation
  • Gateway middleware validates user-owned keys via Convex lookup with Redis cache
  • Max 5 active keys per user, wm_ prefix format

Backend

  • New userApiKeys Convex table with indexes on userId and keyHash
  • Public mutations: createApiKey, listApiKeys, revokeApiKey
  • Internal: validateKeyByHash, touchKeyLastUsed for gateway validation
  • HTTP endpoints: /api/api-keys (authenticated CRUD) + /api/internal-validate-api-key (service-to-service)
  • premium-check.ts updated to validate user-owned keys before static key list

Frontend

  • New "API Keys" tab in UnifiedSettings (only visible when signed in)
  • Create form, copy-on-creation banner, key list with prefix + timestamps, revoke with confirmation

Closes #3116
Depends on #3115 (login gate removal)

Test plan

  • Sign in, open Settings, see "API Keys" tab
  • Create a key — banner shows full key with copy button
  • Key appears in the list with wm_XXXXX******** prefix
  • Copy button works, key is valid wm_ format
  • Revoke a key — confirmation prompt, key moves to "Revoked" section
  • Try creating 6th key — error message about limit
  • Use a created key in X-WorldMonitor-Key header against a premium endpoint — should authenticate
  • Revoked key returns 403

🤖 Generated with Claude Code

@vercel

vercel Bot commented Apr 16, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Apr 17, 2026 3:13am

Request Review

@SebastienMelki

Copy link
Copy Markdown
Collaborator Author

@koala73 — this builds on #3115 (login gate removal). Together they complete the self-serve flow: sign in → create API keys → use the API.

Key details:

  • Keys use wm_ prefix, SHA-256 hashed (plaintext never stored)
  • Max 5 active keys per user
  • Gateway validates user keys via Convex + 5-min Redis cache
  • UI is a new tab in Settings (only shows when signed in)

Ready for review when you are. Metered billing (#3117) can follow separately.

@greptile-apps

greptile-apps Bot commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds full-stack API key management (create / list / revoke) with a wm_-prefixed format, SHA-256 hash-only storage in Convex, and a Redis-cached gateway validation layer. The overall architecture is sound, but there is one blocking issue with the revocation flow.

  • Revocation is not immediate: the positive Redis cache entry (written in server/_shared/user-api-key.ts) has a 5-minute TTL and is never invalidated when a key is revoked. Revoked keys will continue to grant premium access until the cache naturally expires, directly contradicting the test plan assertion "Revoked key returns 403".
  • keyPrefix accepts arbitrary strings: the createApiKey mutation's prefix validation only checks length (4–12 chars), not the wm_[hex] format.
  • touchKeyLastUsed writes to revoked documents: the mutation doesn't guard against revokedAt.

Confidence Score: 3/5

Not safe to merge until the revocation cache-invalidation gap is addressed — revoked keys remain active for up to 5 minutes.

One P1 finding breaks the primary security contract of key revocation: a revoked key continues granting premium API access for up to 5 minutes due to the missing Redis cache invalidation. The test plan explicitly tests for immediate revocation (returns 403), which will fail.

server/_shared/user-api-key.ts (missing cache invalidation on revocation) and convex/apiKeys.ts (keyPrefix validation and touchKeyLastUsed guard)

Important Files Changed

Filename Overview
server/_shared/user-api-key.ts Gateway cache layer for user key validation; positive cache entries are never invalidated on revocation, leaving a 5-minute window where revoked keys still grant access.
convex/apiKeys.ts New Convex mutations/queries for API key lifecycle; auth guards and ownership checks are correct, but keyPrefix validation accepts arbitrary strings and touchKeyLastUsed doesn't skip revoked keys.
convex/http.ts New HTTP routes for CRUD and internal service-to-service key validation; timing-safe secret comparison and CORS handling look correct.
convex/schema.ts New userApiKeys table with correct field types and both required indexes (by_userId, by_keyHash).
server/_shared/premium-check.ts Small integration — calls validateUserApiKey before falling through to legacy static-key and bearer-token paths; logic order is correct.
src/components/UnifiedSettings.ts New API Keys tab with create/revoke flows; escapeHtml is applied consistently on user-controlled values; no XSS vectors found.
src/services/api-keys.ts Frontend Convex client wrapper; key is generated and hashed client-side before being sent as a hash-only mutation, plaintext never leaves the browser.
src/styles/main.css CSS-only changes for the new API Keys tab; uses existing CSS variables correctly.

Sequence Diagram

sequenceDiagram
    actor User
    participant Browser
    participant ConvexDB
    participant Gateway
    participant Redis

    User->>Browser: Click Create Key
    Browser->>Browser: generateKey() wm_xxx
    Browser->>Browser: sha256(wm_xxx) digest
    Browser->>ConvexDB: mutation createApiKey
    ConvexDB-->>Browser: id, name, keyPrefix
    Browser-->>User: Show plaintext key once

    Note over User,Gateway: Using the key in an API request
    User->>Gateway: GET /api/... with wm_xxx
    Gateway->>Redis: GET user-api-key:digest
    alt Cache hit (TTL 5 min)
        Redis-->>Gateway: userId, keyId, name
        Gateway-->>User: 200 OK premium granted
    else Cache miss
        Redis-->>Gateway: null
        Gateway->>ConvexDB: POST internal-validate-api-key
        ConvexDB-->>Gateway: userId, id, name or null
        Gateway->>Redis: SET user-api-key:digest TTL 300s
        Gateway-->>User: 200 OK or 403
    end

    Note over User,Redis: Revocation — Redis entry NOT invalidated
    User->>Browser: Click Revoke
    Browser->>ConvexDB: mutation revokeApiKey
    ConvexDB-->>Browser: ok true
    Note over Gateway,Redis: Stale cache entry lives up to 5 more minutes
    User->>Gateway: GET /api/... with revoked wm_xxx
    Gateway->>Redis: GET user-api-key:digest
    Redis-->>Gateway: stale hit
    Gateway-->>User: 200 OK — should be 403
Loading

Reviews (1): Last reviewed commit: "feat(auth): user-facing API key manageme..." | Re-trigger Greptile

Comment thread server/_shared/user-api-key.ts Outdated
Comment on lines +63 to +64
if (result) {
await setCachedJson(cacheKey, result, CACHE_TTL_SECONDS, true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Cache not invalidated on key revocation

revokeApiKey patches revokedAt in Convex, but the positive Redis cache entry written here (5-min TTL) is never deleted. A gateway request arriving within that window will hit the cached valid result and grant premium access even after the user clicked "Revoke". The test plan expects a revoked key to return 403 immediately, which will not hold until the cache naturally expires.

A proper fix threads the key's hash digest back through the revocation response so the caller can call deleteCachedJson before acknowledging the revocation to the user. The document's stored hash is already available inside the revokeApiKey mutation handler and can be returned alongside { ok: true }.

Comment thread convex/apiKeys.ts Outdated
Comment on lines +31 to +32
if (args.keyPrefix.length < 4 || args.keyPrefix.length > 12) {
throw new ConvexError("INVALID_PREFIX");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 keyPrefix accepts any 4–12 char string

The validation only checks length; it doesn't enforce that the prefix follows the wm_ format or contains only valid hex characters after the wm_ marker. A caller directly invoking the Convex mutation can store an arbitrary display prefix like notakey! while supplying a legitimate hash — misleading users in the key list UI.

Suggested change
if (args.keyPrefix.length < 4 || args.keyPrefix.length > 12) {
throw new ConvexError("INVALID_PREFIX");
if (!/^wm_[a-f0-9]{5,9}$/.test(args.keyPrefix)) {
throw new ConvexError("INVALID_PREFIX");
}

Comment thread convex/apiKeys.ts
Comment on lines +139 to +146
export const touchKeyLastUsed = internalMutation({
args: { keyId: v.id("userApiKeys") },
handler: async (ctx, args) => {
const key = await ctx.db.get(args.keyId);
if (key) {
await ctx.db.patch(args.keyId, { lastUsedAt: Date.now() });
}
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 touchKeyLastUsed updates revoked keys

touchKeyLastUsed only checks if (key) before patching — it doesn't skip revoked documents. Because the gateway fires it fire-and-forget after a positive cache hit (which can occur for revoked keys during the 5-minute stale window), this will write a fresh lastUsedAt timestamp onto a revoked key, muddying the audit trail.

Suggested change
export const touchKeyLastUsed = internalMutation({
args: { keyId: v.id("userApiKeys") },
handler: async (ctx, args) => {
const key = await ctx.db.get(args.keyId);
if (key) {
await ctx.db.patch(args.keyId, { lastUsedAt: Date.now() });
}
},
const key = await ctx.db.get(args.keyId);
if (key && !key.revokedAt) {
await ctx.db.patch(args.keyId, { lastUsedAt: Date.now() });
}

@koala73

koala73 commented Apr 16, 2026

Copy link
Copy Markdown
Owner

Review — PR #3125

Why this PR? Ships user-facing API keys (create/list/revoke) so customers can programmatically hit premium endpoints using their own wm_... key rather than a shared WORLDMONITOR_VALID_KEYS entry. New userApiKeys Convex table, Clerk-gated UI tab, and edge-side Redis-cached Convex lookup in premium-check.ts.

Core scaffolding is good: SHA-256-at-rest, one-time plaintext display, per-user cap, ownership guard on revoke, 160-bit entropy, wm_ short-circuit before hashing. The below must land before merge.


🛑 P1 — Blocking

1. Privilege escalation: any signed-in user can self-mint premium access on isCallerPremium() handlers.
convex/apiKeys.ts:25-65, server/_shared/premium-check.ts:21-23, server/worldmonitor/intelligence/v1/get-country-intel-brief.ts:40-42, server/worldmonitor/news/v1/summarize-article.ts:42-45.
createApiKey() only requires requireUserId() (any Clerk-authenticated user, free plan included), but isCallerPremium() now returns true for any validated user-owned key. A free account can create a key and unlock premium-only framework / systemAppend inputs on public LLM endpoints. The mutation must gate on plan, or isCallerPremium() must verify the key owner's entitlement, not mere existence.

2. New key type is not wired into the gateway auth path — the test plan is broken.
server/gateway.ts:305-363, api/_api-key.js:34-70, server/_shared/premium-check.ts:12-29.
The gateway calls validateApiKey() which only accepts WORLDMONITOR_VALID_KEYS. A freshly created wm_... key passed in X-WorldMonitor-Key will 401 at the gateway before any handler runs, including the premium endpoints this PR claims to unlock. The stated test step "Use a created key in X-WorldMonitor-Key header against a premium endpoint — should authenticate" cannot pass today. validateApiKey() needs a branch that accepts user-owned wm_ keys (likely async, via validateUserApiKey()), or the gateway needs a separate user-key pre-check before validateApiKey.

3. Cross-environment cache poisoning via raw=true.
server/_shared/user-api-key.ts:35-39,63-69, server/_shared/redis.ts:8-24,48-83.
The validation cache writes/reads user-api-key:<sha256> with raw=true, bypassing prefixKey(). This repo prefixes handler cache keys specifically because preview/production share one Upstash instance. A preview deployment validating a key against preview Convex will populate a positive entry that production reads and trusts without consulting production Convex. Drop raw=true, or if preview and prod share the same Convex deployment, document that explicitly and still partition by env.

4. Revocation is stale up to 5 minutes.
server/_shared/user-api-key.ts:17,37-40,63-69, convex/apiKeys.ts:90-105.
CACHE_TTL_SECONDS = 300 with no invalidation path — revokeApiKey() updates Convex only, never touches Redis. A revoked key keeps authenticating on any validateUserApiKey() path until TTL expiry, contradicting this PR's own "revoked key returns 403" test plan. Options: (a) shorten TTL for this key type to ~30-60s, (b) have the edge delete the Redis entry on a revoke response, (c) version the cache (store updatedAt alongside and check mtime on hit).

5. No tests.
Project rule: always write tests when adding new features or changing logic. This is a security-sensitive auth path. At minimum: duplicate-hash rejection, per-user cap boundary, non-owner revoke denied, negative-cache TTL, privilege-gate on create (once (1) is fixed).

6. Cache stampede — should use cachedFetchJson.
server/_shared/user-api-key.ts:28-48.
Manual getCachedJson → fetch → setCachedJson bypasses cachedFetchJson()'s inflight coalescing. A burst of N parallel edge invocations with a fresh key sends N Convex round-trips instead of 1. Switch to cachedFetchJson — it also comes with NEG_SENTINEL built-in, removing the need for the hand-rolled '__INVALID__' constant.


⚠️ P2 — High

7. Duplicate key-creation paths.
convex/http.ts:178-278 re-implements create/list/revoke as POST /api/api-keys with action in body. The frontend in src/services/api-keys.ts:49 uses the WebSocket ConvexClient and never touches this HTTP action. Either wire it up to a real consumer (external/agent access, documented) or delete it — two paths for the same security-critical op will drift.

8. Brittle error-to-HTTP-status mapping by substring.
convex/http.ts:271-274 does msg.includes('KEY_LIMIT_REACHED') / NOT_FOUND / ALREADY_REVOKED. Convex error serialization changes silently map everything to 500. Use err instanceof ConvexError + err.data with a structured tag.

9. Negative-cache sentinel inconsistent with repo standard.
user-api-key.ts:21 defines '__INVALID__', while server/_shared/redis.ts:95 already exports NEG_SENTINEL = '__WM_NEG__'. Reuse the shared sentinel (falls out of (6) above).


🟡 P3 — Medium

10. getAuthState().user read is static at render time.
src/components/UnifiedSettings.ts:276,312. If the user signs out while Settings is open on API Keys, the tab stays active but empty. Subscribe to subscribeAuthState, or guard in switchTab.

11. anyApi.apiKeys!.createApiKey as any casts.
convex/http.ts:229,243,258 use anyApi.apiKeys! with as any, while line 312 uses (internal as any).apiKeys.validateKeyByHash. Inconsistent even with itself. Prefer the codegen'd internal.apiKeys.* — the ! non-null and as any disappear.

12. listApiKeys silently returns [] on Convex unavailability.
src/services/api-keys.ts:63. Users see "No keys yet" instead of an outage message — hides errors.

13. touchKeyLastUsed fires on every cache-miss.
convex/apiKeys.ts:145-153. Chatty for hot keys. Skip if lastUsedAt > now - 5min.


🔵 P4 — Low / nitpick

  • keyPrefix.length < 4 || > 12 (apiKeys.ts:32) accepts values the client never sends (always 8). Tighten to === 8.
  • Revoke uses window.confirm (UnifiedSettings.ts:722); rest of settings uses inline patterns.
  • Hardcoded #22c55e in main.css:22744 — should be --semantic-ok or similar token.
  • track() is imported in UnifiedSettings.ts but no analytics on create/revoke.
  • /api/internal-validate-api-key has no CORS/OPTIONS handler — fine (service-to-service only), but a one-line comment on the route would prevent future confusion.

✅ Good

  • SHA-256-only at rest, plaintext never logged.
  • 160-bit entropy (20 random bytes).
  • wm_ prefix short-circuits non-user keys before hashing.
  • timingSafeEqualStrings on CONVEX_SERVER_SHARED_SECRET.
  • Ownership check on revoke (key.userId !== userId).
  • Duplicate-hash rejection belt-and-suspenders.

Suggested fix order

  1. (2) + (1): decide the key's gate. Either (a) validateApiKey accepts wm_ keys async and the gateway admits them, with createApiKey restricted to pro users — or (b) keys work only on isCallerPremium-only endpoints that are free to non-pro and the gate checks plan. Whichever: close the split-brain.
  2. (3): drop raw=true or partition explicitly by env.
  3. (4): shorten TTL or invalidate on revoke.
  4. (6) + (9): switch to cachedFetchJson.
  5. (7): consolidate to one path.
  6. (8): structured ConvexError.data.
  7. (5): tests.

@SebastienMelki

Copy link
Copy Markdown
Collaborator Author

@koala73 Addressed all three review items:

  1. P1 — Cache invalidation on revoke: revokeApiKey now returns the keyHash, and the frontend fires a best-effort POST to a new /api/invalidate-user-api-key-cache edge function that deletes the Redis entry immediately. Revoked keys are rejected by the gateway right away instead of lingering for up to 5 min.

  2. P2 — keyPrefix validation: Replaced loose length check with /^wm_[a-f0-9]{5,9}$/ regex — enforces wm_ prefix and hex-only characters.

  3. P2 — touchKeyLastUsed on revoked keys: Added !key.revokedAt guard so revoked keys don't get a fresh lastUsedAt timestamp during the stale cache window.

Ready for another look when you get a chance!

@koala73

koala73 commented Apr 16, 2026

Copy link
Copy Markdown
Owner

Consolidated Review - PR #3125

Why this PR?

Lets signed-in users mint personal API keys (client-side generation, SHA-256 hashed, stored in Convex, validated at the edge via Redis-cached Convex lookup) and use them in X-WorldMonitor-Key instead of depending solely on the static WORLDMONITOR_VALID_KEYS list. Closes #3116.


P0 - Blocking

1. Entitlement bypass: any signed-in user gets PRO via a self-issued key

Files: convex/apiKeys.ts:25-65, 118-133, server/_shared/premium-check.ts:21-23, api/chat-analyst.ts:88-91, api/scenario/v1/run.ts:31-37, src/components/UnifiedSettings.ts:276

createApiKey only calls requireUserId (no tier check). validateUserApiKey returns {userId, keyId, name} with no tier info. isCallerPremium then returns true on any valid wm_ key.

Net effect:

  • A free signed-in user creates a key and calls standalone Pro-only endpoints (chat-analyst, scenario/v1/run, etc.) as "premium".
  • Even if issuance was meant to be paid-only, a previously-PRO user who downgrades keeps access forever because validation never rechecks the owner's tier.

Fix: gate issuance on getEntitlements(userId).features.tier >= 1, AND re-check entitlement on every validation (either in validateUserApiKey by joining to the entitlements table, or by stamping tier on the key row and re-reading it at the edge).


P1 - Blocking

2. User keys are not wired into the main domain gateway

Files: server/gateway.ts:308-342, api/_api-key.js:34-69, api/intelligence/v1/[rpc].ts:3-8, src/shared/premium-paths.ts:7-33

createDomainGateway auth still only consults WORLDMONITOR_VALID_KEYS. A freshly-created user key will 401 on /api/intelligence/v1/*, /api/market/v1/*, and every other gatewayed premium endpoint.

The PR test plan says "use a created key against a premium endpoint: should authenticate" which is only true for the handful of standalone endpoints that go through isCallerPremium. Half the surface a user would expect is broken.

Fix: call validateUserApiKey inside api/_api-key.js (or the gateway's auth middleware) and cover it with an e2e test that hits one gatewayed endpoint.

3. Revocation is not atomic (up to 5 min window)

Files: server/_shared/user-api-key.ts:64-70, convex/apiKeys.ts:104-105, src/services/api-keys.ts:88-96, api/invalidate-user-api-key-cache.ts:35-60

Positive auth results are cached for 300s. Cache purge is a fire-and-forget browser fetch after the revoke mutation returns. If the tab closes, the network drops, or the user signs out between revoke and invalidate, the revoked key keeps authenticating for up to 5 minutes.

Fix options, any one of:

  • (a) await the invalidate and surface failure in the UI
  • (b) move invalidation server-side: Convex revokeApiKey schedules an internal action that POSTs to Vercel with CONVEX_SERVER_SHARED_SECRET
  • (c) shorten TTL to 30-60s and accept the Convex load

P2 - High

4. Dead HTTP create path leaks plaintext in the response body

convex/http.ts /api/api-keys with action:"create" generates the plaintext key server-side and returns it in the JSON body. No frontend caller exists (the UI uses the WebSocket mutation path in src/services/api-keys.ts). Either delete the action, or document and gate it for CLI clients. Convex/CDN intermediaries may log HTTP bodies.

5. Cache-invalidate endpoint is cross-tenant

api/invalidate-user-api-key-cache.ts only requires a valid Clerk token, not that the keyHash belongs to the caller. Any signed-in user can invalidate any other user's cached key. Low direct impact (forces one Convex refetch), but still a tenancy boundary violation. Verify keyHash belongs to session.userId before calling invalidateApiKeyCache.

6. No tests on 939 lines of auth-sensitive code

Need unit coverage for: SHA-256 matching, cache HIT/MISS/INVALID sentinel, Convex timeout fallback, revoked-key rejection, per-user limit, duplicate-hash guard, ownership on the invalidate endpoint. Plus one e2e: create, use against a gatewayed endpoint, revoke, confirm next call is rejected within the TTL window.

7. Wrong HTTP status code and raw error leakage

convex/http.ts:338: KEY_LIMIT_REACHED returns 429 (implies retriable with Retry-After). Use 409 Conflict. Also leaks raw err.message into the response body: sanitize non-whitelisted errors to a generic 500.

8. Env var surface undocumented

CONVEX_SERVER_SHARED_SECRET must be set in both Convex and Vercel. Not mentioned in the PR body or test plan. If unset in preview, everything silently 401s.


P3 - Medium / Low

  1. listApiKeys returns active + revoked with no pagination or retention. Add auto-delete of revoked rows after 90d, or paginate.
  2. No expiresAt field on the schema. Add it now; later schema migration is awkward.
  3. No audit log for create/revoke events. Compliance gap.
  4. getAuthState().user is only read at render time (UnifiedSettings.ts:600, 608). Tab stays stale on sign-in/sign-out while Settings is open.
  5. Banner hardcodes #22c55e instead of a token like --semantic-success.
  6. keyPrefix regex allows 5-9 hex chars but producers always slice (0, 8): tighten to ^wm_[a-f0-9]{5}$.

Good

  • Plaintext never crosses the WebSocket on the used path.
  • 160-bit entropy + SHA-256 is appropriate for random high-entropy keys (no slow hash needed).
  • Negative-result cache sentinel prevents Convex hammering on bad-key probes.
  • timingSafeEqualStrings used on the shared-secret path.
  • Correct indexes (by_userId, by_keyHash).
  • MAX_KEYS_PER_USER=5 counts non-revoked only, allowing clean rotation.

Recommendation

Request changes. P0 and P1 must ship in this PR. P2 items (especially tests and the dead-code cleanup) should land alongside them.

@SebastienMelki

Copy link
Copy Markdown
Collaborator Author

Addressed consolidated review (P0–P3) — 082f330

P0: Entitlement bypass — fixed

  • createApiKey now queries the entitlements table and requires tier >= 1. Free users get PRO_REQUIRED.
  • isCallerPremium now calls getEntitlements(userId) after validating a wm_ key — key existence alone no longer grants premium access.

P1: Gateway auth — fixed

  • Added async validateUserApiKey fallback in gateway.ts when the static WORLDMONITOR_VALID_KEYS check fails for wm_ prefixed keys.
  • User keys inject x-user-id for downstream entitlement checks. Admin keys bypass entitlement; user keys do not — the owner's tier is verified via the normal entitlement path.
  • User keys on PREMIUM_RPC_PATHS (legacy pro gate) get explicit getEntitlements verification.

P1: Revocation atomicity — fixed

  • Cache TTL lowered 300s → 60s (limits worst-case staleness window).
  • Frontend now awaits cache invalidation instead of fire-and-forget. Failures are logged.

P2: Dead HTTP path — removed

  • Deleted /api/api-keys POST + OPTIONS handlers from convex/http.ts. No frontend consumer existed; this path leaked plaintext in the response body and would have drifted from the WebSocket path.

P2: Cross-tenant invalidation — fixed

  • New getKeyOwner internal query (returns owner regardless of revoked status).
  • New /api/internal-get-key-owner HTTP route.
  • invalidate-user-api-key-cache.ts now verifies the keyHash belongs to session.userId before evicting.

P2: Cache stampede + cross-env poisoning + sentinel — fixed

  • user-api-key.ts rewritten to use cachedFetchJson — gets in-flight coalescing, env-prefixed keys (no more raw=true), and standard NEG_SENTINEL for free.

P2: Tests — added

  • 22 Convex tests (convex/__tests__/apiKeys.test.ts): tier gate on create, expired entitlement rejection, per-user limit, revoked keys not counted toward limit, duplicate hash, invalid prefix/hash/name, non-owner revoke, double revoke, list isolation, validateKeyByHash for active/revoked/missing, getKeyOwner, touchKeyLastUsed debounce.

P3/P4: Minor fixes

  • keyPrefix regex tightened {5,9}{5} (matches the always-8-char prefix wm_XXXXX).
  • touchKeyLastUsed debounces writes (skips if lastUsedAt > now - 5min).
  • UI surfaces PRO_REQUIRED with a clear message.

Not addressed in this commit (tracked for follow-up):

  • P3-9: Pagination / auto-delete of revoked rows after 90d
  • P3-10: expiresAt field on schema
  • P3-11: Audit log for create/revoke
  • P3-12: getAuthState().user reactivity on sign-out
  • P3-13: Hardcoded #22c55e → design token
  • P4: track() analytics on create/revoke

@koala73

koala73 commented Apr 16, 2026

Copy link
Copy Markdown
Owner

Re-review — PR #3125 (post-fixes through 082f33014)

Pulled latest origin/feat/api-key-management (2 new commits on top of the original). Walked each previously-raised P1/P2 against the diff. All P1s resolved; most P2/P3 addressed. Residuals are low-risk.

✅ P1 — all resolved

  1. Privilege escalation → fixed at both layers (defense in depth).

    • convex/apiKeys.ts:33-43: createApiKey now reads entitlements, checks validUntil >= Date.now() and features.tier >= 1, throws PRO_REQUIRED otherwise.
    • server/_shared/premium-check.ts:22-28: isCallerPremium re-checks getEntitlements(userKey.userId) on every call — key existence alone no longer implies premium. Also tightened keyCheck.valid && keyCheck.required (line 34) so trusted-origin short-circuits don't grant PRO.
    • Covered by tests: rejects free-tier users (PRO_REQUIRED), rejects users with expired entitlement, succeeds for pro user.
  2. Gateway wiring → fixed. server/gateway.ts:312-349:

    • On admin-key fail + wm_ prefix, calls validateUserApiKey async, sets isUserApiKey = true, flips keyCheck to valid, injects x-user-id.
    • Legacy-pro path (needsLegacyProBearerGate) re-checks getEntitlements and 403s non-pro.
    • Tier-gated path: entitlement check bypass is !(keyCheck.valid && wmKey && !isUserApiKey) — user keys do go through checkEntitlement, which reads the injected x-user-id. Admin keys still bypass.
  3. Cross-env cache poisoning → fixed. server/_shared/user-api-key.ts:40-46 now uses cachedFetchJson with no raw=true flag; cache keys are partitioned by VERCEL_ENV prefix like every other handler.

  4. Revocation staleness → fixed.

    • TTL dropped 300 → 60s (user-api-key.ts:17).
    • revokeApiKey returns keyHash (convex/apiKeys.ts:122).
    • New api/invalidate-user-api-key-cache.ts edge endpoint with Clerk Bearer + tenancy guard via /api/internal-get-key-owner.
    • Frontend (src/services/api-keys.ts:88-101) awaits the invalidation call post-revoke instead of fire-and-forget.
    • Worst-case staleness is now min(60s TTL, Convex+Redis round-trip) rather than 5 min.
  5. No tests → fixed. 22 tests in convex/__tests__/apiKeys.test.ts covering: tier gate (incl. expired entitlement), per-user limit, revoked-don't-count, duplicate hash, prefix/hash regex, empty name, ownership on revoke, double-revoke, list scoping, validateKeyByHash revoked-null, getKeyOwner, touchKeyLastUsed debounce + revoked-skip.

  6. Cache stampede / sentinel → fixed. cachedFetchJson brings in-flight coalescing and the shared NEG_SENTINEL; custom '__INVALID__' is gone.

✅ P2 — resolved

  1. Duplicate HTTP path removed. convex/http.ts no longer exposes /api/api-keys create/list/revoke; only the service-to-service /api/internal-validate-api-key + new /api/internal-get-key-owner remain. anyApi.apiKeys! + as any casts are gone.
  2. Error-to-status mapping — moot: the HTTP path that did msg.includes(...) was deleted.

✅ P3 — mostly resolved

  1. touchKeyLastUsed debounce: TOUCH_DEBOUNCE_MS = 5 * 60 * 1000 (apiKeys.ts:173-183), also skips revoked keys. Tested.
  2. PRO_REQUIRED surfaced in UI (UnifiedSettings.ts:722-723).
  3. Key prefix tightened to exact regex /^wm_[a-f0-9]{5}$/.

🟡 Residual — not blocking

  • invalidate-user-api-key-cache.ts:60-84 fails open on tenancy check errors. If Convex env is missing, or ownerResp.ok === false, or the fetch throws, the cache entry is deleted without ownership verification. Also: if ownerData === null (hash not in DB), the ownerData && ... guard short-circuits past the FORBIDDEN return. Practical exploit surface is tiny — attacker would need another user's SHA-256(plaintext) and a Convex outage — worst case is a forced Convex refetch. Consider fail-closed (503) on Convex errors so failure modes surface in logs; the frontend already tolerates this (line 99 just warns).
  • listApiKeys silently returns [] on Convex unavailability (api-keys.ts:71) — still hides outages as "no keys yet".
  • getAuthState().user read is static at render time (UnifiedSettings.ts:276,312) — tab row isn't re-rendered on sign-out mid-session. UX-only.
  • Hardcoded #22c55e and window.confirm still present — cosmetic.

Verdict

P1 hardening is thorough — two-layer entitlement verification (create-time + every validation), proper gateway integration, env-partitioned caching, and an awaited revocation cache-bust. Tests cover the critical paths. The residuals are UX/operational nits; none gate merge.

LGTM to ship once the fail-open on the invalidation endpoint is acknowledged — happy to either fail-closed it or leave it as-is with a log line.

@koala73

koala73 commented Apr 16, 2026

Copy link
Copy Markdown
Owner

Correction — missed a P1

I cleared this too fast. Verified in convex/config/productCatalog.ts:54-88:

PRO_FEATURES         tier: 1,  apiAccess: false
API_STARTER_FEATURES tier: 2,  apiAccess: true
API_BUSINESS_FEATURES tier: 2, apiAccess: true
ENTERPRISE_FEATURES  tier: 3,  apiAccess: true

The PR gates on tier >= 1 in three places, which grants API keys to Pro subscribers even though Pro's own feature flag says apiAccess: false. This collapses the Pro and API product tiers — a Pro user (no API access in the catalog) can mint a wm_ key and hit premium RPCs through it, undercutting the higher-priced API_STARTER/API_BUSINESS plans.

🛑 Added P1 — gate on apiAccess, not on generic premium tier

Fix all three sites:

  • convex/apiKeys.ts:9,41REQUIRED_TIER = 1 + tier < REQUIRED_TIER. Change to if (!entitlement?.features.apiAccess) throw new ConvexError("API_ACCESS_REQUIRED") (keep the validUntil guard).
  • server/_shared/premium-check.ts:26ent && ent.features.tier >= 1. Change to ent && ent.features.apiAccess === true.
  • server/gateway.ts:343!ent || ent.features.tier < 1. Change to !ent || ent.features.apiAccess !== true.

Using features.apiAccess (rather than tier >= 2) keeps the gate catalog-driven — if a future plan mixes tiers differently, the gate follows the product definition instead of numeric drift.

Test coverage note

convex/__tests__/apiKeys.test.ts uses getFeaturesForPlan("pro_monthly") which currently returns apiAccess: false. The current passing tests only accidentally cover the correct behavior because tier: 1 happened to be both the code's threshold and Pro's tier — flipping to apiAccess will cause succeeds for pro user to fail and expose the mismatch. Update the test fixtures to use an API-tier plan key (e.g. "api_starter_monthly" — whichever key getFeaturesForPlan accepts) for the positive-path cases, and add a dedicated rejects pro-tier (no apiAccess) test to lock the correct gate.

Where my earlier "✅ P1 resolved" was wrong

I validated that (a) free users are blocked and (b) the entitlement check is re-verified on every call — both true. I did not cross-check the threshold value against the product catalog. Sorry, good catch.

Verdict

Not ship-ready until the three tier >= 1 sites flip to apiAccess, and the tests are updated accordingly. Other P1s and P2s from the prior review remain resolved.

@koala73 koala73 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Details provided in comments

Copy link
Copy Markdown
Collaborator Author

Follow-up review after Koala's correction

I agree with Koala's latest P1: this PR should gate API-key issuance/usage on features.apiAccess, not on generic premium tier >= 1.

Why this is still blocking:

  • convex/config/productCatalog.ts defines PRO_FEATURES as tier: 1 but apiAccess: false, while API plans have apiAccess: true.
  • The current patch still uses tier >= 1 in three places:
    • convex/apiKeys.ts
    • server/_shared/premium-check.ts
    • server/gateway.ts
  • That collapses the Pro and API products: a Pro subscriber can mint a wm_ key and use API-only access despite the catalog explicitly denying API access.

There are also two additional issues still worth fixing in the same pass:

  1. wm_ keys still do not work on all direct edge routes.
  • api/v2/shipping/route-intelligence.ts
  • api/v2/shipping/webhooks.ts

Both still call validateApiKey(req, { forceKey: true }) before isCallerPremium(), and api/_api-key.js only knows about WORLDMONITOR_VALID_KEYS. So user-issued keys 401 before the Convex-backed user-key path is reached.

  1. The new cachedFetchJson integration changed user-key lookup failures from fail-soft to throw.
  • server/_shared/user-api-key.ts
  • server/_shared/redis.ts

Previously, a transient Convex/network failure during user-key validation degraded to null / unauthorized. Now fetchFromConvex() can throw through cachedFetchJson(), which bubbles out of both the gateway fallback and isCallerPremium(). That can turn a wm_ request into a 500 during lookup outages.

Test note

convex/__tests__/apiKeys.test.ts currently seeds pro_monthly and expects "succeeds for pro user". That test is locking in the wrong behavior, because pro_monthly currently has apiAccess: false in the catalog. Positive-path fixtures should use an API-tier plan, and there should be a dedicated negative test for Pro-without-API-access.

Suggested fix order

  1. Flip the three entitlement checks from tier >= 1 to features.apiAccess === true.
  2. Update the tests to use an API-access plan for success cases and add an explicit Pro-without-API-access rejection test.
  3. Wire wm_ keys through the direct edge routes that still short-circuit on validateApiKey().
  4. Restore fail-soft behavior for transient user-key lookup failures.

After that, the remaining residuals look non-blocking to me.

SebastienMelki and others added 4 commits April 16, 2026 22:32
Adds full-stack API key management so authenticated users can create,
list, and revoke their own API keys from the Settings UI.

Backend:
- Convex `userApiKeys` table with SHA-256 key hash storage
- Mutations: createApiKey, listApiKeys, revokeApiKey
- Internal query validateKeyByHash + touchKeyLastUsed for gateway
- HTTP endpoints: /api/api-keys (CRUD) + /api/internal-validate-api-key
- Gateway middleware validates user-owned keys via Convex + Redis cache

Frontend:
- New "API Keys" tab in UnifiedSettings (visible when signed in)
- Create form with copy-on-creation banner (key shown once)
- List with prefix display, timestamps, and revoke action
- Client-side key generation + hashing (plaintext never sent to DB)

Closes #3116

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…ion, revoked-key guard

- Invalidate Redis cache on key revocation so gateway rejects revoked keys
  immediately instead of waiting for 5-min TTL expiry (P1)
- Enforce `wm_` prefix format with regex instead of loose length check (P2)
- Skip `touchKeyLastUsed` for revoked keys to preserve clean audit trail (P2)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
P0: gate createApiKey on pro entitlement (tier >= 1); isCallerPremium
now verifies key-owner tier instead of treating existence as premium.

P1: wire wm_ user keys into the domain gateway auth path with async
Convex-backed validation; user keys go through entitlement checks
(only admin keys bypass). Lower cache TTL 300s → 60s and await
revocation cache-bust instead of fire-and-forget.

P2: remove dead HTTP create/list/revoke path from convex/http.ts;
switch to cachedFetchJson (stampede protection, env-prefixed keys,
standard NEG_SENTINEL); add tenancy check on cache-invalidation
endpoint via new /api/internal-get-key-owner route; add 22 Convex
tests covering tier gate, per-user limit, duplicate hash, ownership
revoke guard, getKeyOwner, and touchKeyLastUsed debounce.

P3: tighten keyPrefix regex to exactly 5 hex chars; debounce
touchKeyLastUsed (5 min); surface PRO_REQUIRED in UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…ge routes, harden error paths

- Gate API key creation/validation on features.apiAccess instead of tier >= 1.
  Pro (tier 1, apiAccess=false) can no longer mint keys — only API_STARTER+.
- Wire wm_ user keys through standalone edge routes (shipping/route-intelligence,
  shipping/webhooks) that were short-circuiting on validateApiKey before async
  Convex validation could run.
- Restore fail-soft behavior in validateUserApiKey: transient Convex/network
  errors degrade to unauthorized instead of bubbling a 500.
- Fail-closed on cache invalidation endpoint: ownership check errors now return
  503 instead of silently proceeding (surfaces Convex outages in logs).
- Tests updated: positive paths use api_starter (apiAccess=true), new test locks
  Pro-without-API-access rejection. 23 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@SebastienMelki

Copy link
Copy Markdown
Collaborator Author

@koala73 — Ready for re-review. Latest commit f9b1ffdb addresses all remaining feedback:

P0 fix: apiAccess gate (your catch)

  • All three tier >= 1 sites flipped to features.apiAccess — Pro (tier 1, apiAccess: false) can no longer mint or use API keys. Only API_STARTER+ plans.

P1 fix: wm_ keys on direct edge routes

  • route-intelligence.ts and webhooks.ts now fall back to async validateUserApiKey for wm_ prefixed keys before 401ing.

Bug fix: fail-soft regression

  • validateUserApiKey wraps cachedFetchJson in try-catch — transient Convex/network errors degrade to unauthorized instead of 500.

Residual: fail-closed invalidation endpoint

  • Ownership check errors now return 503 (fail-closed) instead of silently proceeding. Hash-not-in-DB returns 200.

Tests

  • Positive paths switched to api_starter (apiAccess=true). New test: "rejects pro-tier users without apiAccess". 23 tests pass.

Branch rebased on latest main.

Copy link
Copy Markdown
Collaborator Author

I’m Codex. Re-reviewed the latest head (f9b1ffdb) against Koala’s correction and my earlier Codex follow-up, and I don’t see the prior blockers anymore.

What now looks fixed:

  • convex/apiKeys.ts, server/_shared/premium-check.ts, and server/gateway.ts now gate on features.apiAccess instead of tier >= 1, so Pro no longer incorrectly gets API-key access.
  • api/v2/shipping/route-intelligence.ts and api/v2/shipping/webhooks.ts now fall back to async validateUserApiKey(...) for wm_ keys before returning 401.
  • server/_shared/user-api-key.ts restored fail-soft behavior around cachedFetchJson(...), so transient Convex/network failures degrade to unauthorized instead of bubbling a 500.
  • api/invalidate-user-api-key-cache.ts is now fail-closed on ownership-check errors.
  • convex/__tests__/apiKeys.test.ts switched the positive path to an API-access plan and added the explicit Pro-without-API-access rejection case.

One residual I still notice: src/services/api-keys.ts still returns [] from listApiKeys() when the Convex client/API is unavailable, which can mask an outage as “no keys yet.” I still consider that non-blocking.

From Codex’s side, no new blocking findings on this head.

@koala73

koala73 commented Apr 17, 2026

Copy link
Copy Markdown
Owner

Re-review — PR #3125 (commit f9b1ffdb9, apiAccess gate fix)

Pulled latest after force-push. Walked the apiAccess fix across all three gate sites, tests, standalone endpoints, and invalidation endpoint.

✅ P1 apiAccess gate: fixed at all three sites

File Line Gate
convex/apiKeys.ts 38-43 !entitlement.features.apiAccessAPI_ACCESS_REQUIRED
server/_shared/premium-check.ts 26 ent.features.apiAccess === true
server/gateway.ts 343 !ent.features.apiAccess

Pro (tier 1, apiAccess: false) is correctly rejected. API_STARTER+ (tier 2, apiAccess: true) passes. Gate is catalog-driven via features.apiAccess, not tier-as-proxy.

✅ Tests properly updated

  • Positive path uses seedApiEntitlementgetFeaturesForPlan("api_starter") (apiAccess: true, tier 2)
  • New rejects pro-tier users without apiAccess test (line 79-87) locks the exact regression with seedProEntitlementgetFeaturesForPlan("pro_monthly") (apiAccess: false)
  • Expired entitlement test updated to use seedApiEntitlement with validUntil: PAST
  • All 22 tests exercise the correct fixtures

✅ Standalone endpoints covered

api/v2/shipping/route-intelligence.ts:69 and webhooks.ts:158 both call isCallerPremium(req) after accepting the wm_ key, which re-verifies apiAccess === true on the key owner via getEntitlements. Downgraded users are caught.

✅ Invalidation endpoint now fail-closed

api/invalidate-user-api-key-cache.ts: returns 503 when Convex env is missing (line 64-67), on non-200 from Convex (line 79-82), and on fetch failure (line 91-94). ownerData === null (hash not in DB) correctly returns 200 (no-op). Tenancy guard properly blocks cross-user invalidation (line 88-89).

✅ UI updated

Error message changed to "API keys require an API access subscription (API Starter or higher)" (line 722-723).


🛑 New P1 — webhook ownership model breaks with user-owned keys

api/v2/shipping/webhooks.ts:112-119,121-124,219-240,248-255,257-289

The PR wires wm_ user keys into shipping/webhooks, but webhook ownership is keyed to SHA-256(apiKey) via callerFingerprint(), not to the user. With the new multi-key model (up to 5 keys per user), this creates three concrete failures:

  1. Cross-key blindness: User creates webhook with key A (ownerTag = SHA-256(A)), then calls with key B → callerFingerprint = SHA-256(B) → owner index miss → "no webhooks". Same user, invisible resources.

  2. Revoke orphans: Revoking key A leaves the webhook's ownerTag pointing at a dead fingerprint. No key can ever match it again. The webhook becomes permanently unmanageable (can't list, update, deactivate, or rotate secret).

  3. Owner index fragmentation: The Redis Set at webhook:owner:<hash>:v1 is per-key, not per-user. 5 keys = 5 disjoint owner indexes for the same account.

Root cause: callerFingerprint() was designed for static admin keys (1 key = 1 identity). User-owned keys break that 1:1 assumption.

Recommended fix: Remove the wm_ fallback from webhooks.ts only (keep it on route-intelligence.ts which is read-only, no ownership state). Then migrate webhook ownership from key-hash to userId in a follow-up PR. Trying to fix the ownership model inside this PR risks touching webhook delivery workers and the HMAC signing path.


Residual (non-blocking, unchanged)

  • listApiKeys silent [] on Convex unavailable
  • getAuthState().user static at render time
  • Hardcoded #22c55e, window.confirm

Verdict

The apiAccess gate is correctly wired everywhere. Ship-ready once the wm_ fallback is removed from webhooks.ts (a 9-line revert). The webhook ownership migration can follow separately.

Webhook ownership is keyed to SHA-256(apiKey) via callerFingerprint(),
not to the user. With user-owned keys (up to 5 per user), this causes
cross-key blindness (webhooks invisible when calling with a different
key) and revoke-orphaning (revoking the creating key makes the webhook
permanently unmanageable). User keys remain supported on the read-only
route-intelligence endpoint. Webhook ownership migration to userId will
follow in a separate PR.
@koala73

koala73 commented Apr 17, 2026

Copy link
Copy Markdown
Owner

Webhook ownership P1 — fixed in 0bb2f028e

Removed the wm_ user key fallback from api/v2/shipping/webhooks.ts. Webhooks remain admin-key-only (validateApiKey with forceKey: true) until ownership is migrated from SHA-256(apiKey) to userId in a follow-up PR.

api/v2/shipping/route-intelligence.ts keeps wm_ support since it's read-only (no ownership state).

All pre-push hooks passed (typecheck, 95 server tests, 169 edge function tests, boundary lint).

No remaining blockers from my side.

@koala73
koala73 dismissed their stale review April 17, 2026 03:20

Reviewed, fixed 1 small blocker

@koala73
koala73 merged commit a4d9b0a into main Apr 17, 2026
10 checks passed
@koala73
koala73 deleted the feat/api-key-management branch April 17, 2026 03:20
koala73 added a commit that referenced this pull request Apr 18, 2026
…QUIRED

NS_ERROR_UNEXPECTED (WORLDMONITOR-N6/N7/N8/N9): Firefox 149/Ubuntu XPCOM
Worker init failure. Same family as already-filtered NS_ERROR_ABORT and
NS_ERROR_OUT_OF_MEMORY. 0 repo matches. Worker fallback confirmed working
via breadcrumbs ("keeping flat list").

ConvexError: API_ACCESS_REQUIRED (WORLDMONITOR-NA, 15 events/5 users):
expected business error from PR #3125 (API key management). Free user opens
API Keys tab, server correctly denies, client try/catch at
UnifiedSettings.ts:731 handles gracefully. Convex WS transport leaks the
rejection to Sentry before the client Promise chain catches it.
koala73 added a commit that referenced this pull request Apr 18, 2026
…QUIRED (#3188)

NS_ERROR_UNEXPECTED (WORLDMONITOR-N6/N7/N8/N9): Firefox 149/Ubuntu XPCOM
Worker init failure. Same family as already-filtered NS_ERROR_ABORT and
NS_ERROR_OUT_OF_MEMORY. 0 repo matches. Worker fallback confirmed working
via breadcrumbs ("keeping flat list").

ConvexError: API_ACCESS_REQUIRED (WORLDMONITOR-NA, 15 events/5 users):
expected business error from PR #3125 (API key management). Free user opens
API Keys tab, server correctly denies, client try/catch at
UnifiedSettings.ts:731 handles gracefully. Convex WS transport leaks the
rejection to Sentry before the client Promise chain catches it.
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.

feat(auth): user-facing API key management (create / list / revoke)

2 participants