mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* 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>
101 lines
3.6 KiB
TypeScript
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);
|
|
}
|