Files
worldmonitor/api/invalidate-user-api-key-cache.ts
Sebastien Melki a4d9b0a5fa feat(auth): user-facing API key management (create / list / revoke) (#3125)
* feat(auth): user-facing API key management (create / list / revoke)

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) <noreply@anthropic.com>

* fix(api-keys): address PR review — cache invalidation, prefix validation, 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) <noreply@anthropic.com>

* fix(api-keys): address consolidated PR review (P0–P3)

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) <noreply@anthropic.com>

* fix(api-keys): gate on apiAccess (not tier), wire wm_ keys through edge 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) <noreply@anthropic.com>

* fix(webhooks): remove wm_ user key fallback from shipping webhooks

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.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Elie Habib <elie.habib@gmail.com>
2026-04-17 07:20:39 +04:00

101 lines
3.6 KiB
TypeScript

/**
* POST /api/invalidate-user-api-key-cache
*
* Deletes the Redis cache entry for a revoked user API key so the gateway
* stops accepting it immediately instead of waiting for TTL expiry.
*
* Authentication: Clerk Bearer token (any signed-in user).
* Body: { keyHash: string }
*
* Ownership is verified via Convex — the keyHash must belong to the caller.
*/
export const config = { runtime: 'edge' };
// @ts-expect-error — JS module, no declaration file
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
// @ts-expect-error — JS module, no declaration file
import { jsonResponse } from './_json-response.js';
import { validateBearerToken } from '../server/auth-session';
import { invalidateApiKeyCache } from '../server/_shared/user-api-key';
export default async function handler(req: Request): Promise<Response> {
if (isDisallowedOrigin(req)) {
return jsonResponse({ error: 'Origin not allowed' }, 403);
}
const cors = getCorsHeaders(req, 'POST, OPTIONS');
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: cors });
}
if (req.method !== 'POST') {
return jsonResponse({ error: 'Method not allowed' }, 405, cors);
}
const authHeader = req.headers.get('Authorization') ?? '';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
if (!token) {
return jsonResponse({ error: 'UNAUTHENTICATED' }, 401, cors);
}
const session = await validateBearerToken(token);
if (!session.valid || !session.userId) {
return jsonResponse({ error: 'UNAUTHENTICATED' }, 401, cors);
}
let body: { keyHash?: string };
try {
body = await req.json();
} catch {
return jsonResponse({ error: 'Invalid JSON body' }, 422, cors);
}
const { keyHash } = body;
if (typeof keyHash !== 'string' || !/^[a-f0-9]{64}$/.test(keyHash)) {
return jsonResponse({ error: 'Invalid keyHash' }, 422, cors);
}
// Verify the keyHash belongs to the calling user (tenancy boundary).
// Fail-closed: if ownership cannot be verified, reject the request.
const convexSiteUrl = process.env.CONVEX_SITE_URL;
const convexSharedSecret = process.env.CONVEX_SERVER_SHARED_SECRET;
if (!convexSiteUrl || !convexSharedSecret) {
console.warn('[invalidate-cache] Missing CONVEX_SITE_URL or CONVEX_SERVER_SHARED_SECRET');
return jsonResponse({ error: 'Service unavailable' }, 503, cors);
}
try {
const ownerResp = await fetch(`${convexSiteUrl}/api/internal-get-key-owner`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-convex-shared-secret': convexSharedSecret,
},
body: JSON.stringify({ keyHash }),
signal: AbortSignal.timeout(3_000),
});
if (!ownerResp.ok) {
console.warn(`[invalidate-cache] Convex ownership check HTTP ${ownerResp.status}`);
return jsonResponse({ error: 'Service unavailable' }, 503, cors);
}
const ownerData = await ownerResp.json() as { userId?: string } | null;
if (!ownerData) {
// Hash not in DB — nothing to invalidate, but not an error
return jsonResponse({ ok: true }, 200, cors);
}
if (ownerData.userId !== session.userId) {
return jsonResponse({ error: 'FORBIDDEN' }, 403, cors);
}
} catch (err) {
// Fail-closed: ownership check failed — reject to surface the issue
console.warn('[invalidate-cache] Ownership check failed:', err instanceof Error ? err.message : String(err));
return jsonResponse({ error: 'Service unavailable' }, 503, cors);
}
await invalidateApiKeyCache(keyHash);
return jsonResponse({ ok: true }, 200, cors);
}