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) (#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>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import { validateApiKey } from '../../api/_api-key.js';
|
||||
import { validateBearerToken } from '../auth-session';
|
||||
import { getEntitlements } from './entitlement-check';
|
||||
import { validateUserApiKey } from './user-api-key';
|
||||
|
||||
/**
|
||||
* Returns true when the caller has a valid API key OR a PRO bearer token.
|
||||
@@ -16,6 +17,15 @@ export async function isCallerPremium(request: Request): Promise<boolean> {
|
||||
const validKeys = (process.env.WORLDMONITOR_VALID_KEYS ?? '')
|
||||
.split(',').map((k) => k.trim()).filter(Boolean);
|
||||
if (validKeys.length > 0 && validKeys.includes(wmKey)) return true;
|
||||
|
||||
// Check user-owned API keys (wm_ prefix) via Convex lookup.
|
||||
// Key existence alone is not sufficient — verify the owner's entitlement.
|
||||
const userKey = await validateUserApiKey(wmKey);
|
||||
if (userKey) {
|
||||
const ent = await getEntitlements(userKey.userId);
|
||||
if (ent && ent.features.apiAccess === true) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const keyCheck = validateApiKey(request, {}) as { valid: boolean; required: boolean };
|
||||
|
||||
83
server/_shared/user-api-key.ts
Normal file
83
server/_shared/user-api-key.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Validates user-owned API keys by hashing the provided key and looking up
|
||||
* the hash in Convex via the internal HTTP action.
|
||||
*
|
||||
* Uses cachedFetchJson for Redis caching with in-flight coalescing and
|
||||
* environment-partitioned keys (no raw=true — keys are prefixed by deploy).
|
||||
*/
|
||||
|
||||
import { cachedFetchJson, deleteRedisKey } from './redis';
|
||||
|
||||
interface UserKeyResult {
|
||||
userId: string;
|
||||
keyId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const CACHE_TTL_SECONDS = 60; // 1 min — short to limit staleness on revocation
|
||||
const NEG_TTL_SECONDS = 60; // negative cache: avoid hammering Convex with invalid keys
|
||||
const CACHE_KEY_PREFIX = 'user-api-key:';
|
||||
|
||||
/** SHA-256 hex digest (Web Crypto API — works in Edge Runtime). */
|
||||
async function sha256Hex(input: string): Promise<string> {
|
||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
||||
return Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a user-owned API key.
|
||||
*
|
||||
* Returns the userId and key metadata if valid, or null if invalid/revoked.
|
||||
* Uses cachedFetchJson for Redis caching with request coalescing and
|
||||
* standard NEG_SENTINEL for negative results.
|
||||
*/
|
||||
export async function validateUserApiKey(key: string): Promise<UserKeyResult | null> {
|
||||
if (!key || !key.startsWith('wm_')) return null;
|
||||
|
||||
const keyHash = await sha256Hex(key);
|
||||
const cacheKey = `${CACHE_KEY_PREFIX}${keyHash}`;
|
||||
|
||||
try {
|
||||
return await cachedFetchJson<UserKeyResult>(
|
||||
cacheKey,
|
||||
CACHE_TTL_SECONDS,
|
||||
() => fetchFromConvex(keyHash),
|
||||
NEG_TTL_SECONDS,
|
||||
);
|
||||
} catch (err) {
|
||||
// Fail-soft: transient Convex/network errors degrade to unauthorized
|
||||
// rather than bubbling a 500 through the gateway or isCallerPremium.
|
||||
console.warn('[user-api-key] validateUserApiKey failed:', err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch key validation from Convex internal endpoint. */
|
||||
async function fetchFromConvex(keyHash: string): Promise<UserKeyResult | null> {
|
||||
const convexSiteUrl = process.env.CONVEX_SITE_URL;
|
||||
const convexSharedSecret = process.env.CONVEX_SERVER_SHARED_SECRET;
|
||||
if (!convexSiteUrl || !convexSharedSecret) return null;
|
||||
|
||||
const resp = await fetch(`${convexSiteUrl}/api/internal-validate-api-key`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'worldmonitor-gateway/1.0',
|
||||
'x-convex-shared-secret': convexSharedSecret,
|
||||
},
|
||||
body: JSON.stringify({ keyHash }),
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
});
|
||||
|
||||
if (!resp.ok) return null;
|
||||
return resp.json() as Promise<UserKeyResult | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the Redis cache entry for a specific API key hash.
|
||||
* Called after revocation to ensure the key cannot be used during the TTL window.
|
||||
* Uses prefixed keys (no raw=true) matching the cache writes above.
|
||||
*/
|
||||
export async function invalidateApiKeyCache(keyHash: string): Promise<void> {
|
||||
await deleteRedisKey(`${CACHE_KEY_PREFIX}${keyHash}`);
|
||||
}
|
||||
@@ -305,9 +305,49 @@ export function createDomainGateway(
|
||||
|
||||
// API key validation — tier-gated endpoints require EITHER an API key OR a valid bearer token.
|
||||
// Authenticated users (sessionUserId present) bypass the API key requirement.
|
||||
const keyCheck = validateApiKey(request, {
|
||||
let keyCheck = validateApiKey(request, {
|
||||
forceKey: (isTierGated && !sessionUserId) || needsLegacyProBearerGate,
|
||||
});
|
||||
}) as { valid: boolean; required: boolean; error?: string };
|
||||
|
||||
// User-owned API keys (wm_ prefix): when the static WORLDMONITOR_VALID_KEYS
|
||||
// check fails, try async Convex-backed validation for user-issued keys.
|
||||
let isUserApiKey = false;
|
||||
const wmKey = request.headers.get('X-WorldMonitor-Key') ?? '';
|
||||
if (keyCheck.required && !keyCheck.valid && wmKey.startsWith('wm_')) {
|
||||
const { validateUserApiKey } = await import('./_shared/user-api-key');
|
||||
const userKeyResult = await validateUserApiKey(wmKey);
|
||||
if (userKeyResult) {
|
||||
isUserApiKey = true;
|
||||
keyCheck = { valid: true, required: true };
|
||||
// Inject x-user-id for downstream entitlement checks
|
||||
if (!sessionUserId) {
|
||||
sessionUserId = userKeyResult.userId;
|
||||
request = new Request(request.url, {
|
||||
method: request.method,
|
||||
headers: (() => {
|
||||
const h = new Headers(request.headers);
|
||||
h.set('x-user-id', sessionUserId);
|
||||
return h;
|
||||
})(),
|
||||
body: request.body,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// User API keys on PREMIUM_RPC_PATHS need verified pro-tier entitlement.
|
||||
// Admin keys (WORLDMONITOR_VALID_KEYS) bypass this since they are operator-issued.
|
||||
if (isUserApiKey && needsLegacyProBearerGate && sessionUserId) {
|
||||
const { getEntitlements } = await import('./_shared/entitlement-check');
|
||||
const ent = await getEntitlements(sessionUserId);
|
||||
if (!ent || !ent.features.apiAccess) {
|
||||
return new Response(JSON.stringify({ error: 'API access subscription required' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json', ...corsHeaders },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (keyCheck.required && !keyCheck.valid) {
|
||||
if (needsLegacyProBearerGate) {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
@@ -358,9 +398,9 @@ export function createDomainGateway(
|
||||
}
|
||||
|
||||
// Entitlement check — blocks tier-gated endpoints for users below required tier.
|
||||
// Valid API-key holders bypass entitlement checks (they have full access by virtue
|
||||
// of possessing a key). Only bearer-token users go through the tier gate.
|
||||
if (!(keyCheck.valid && request.headers.get('X-WorldMonitor-Key'))) {
|
||||
// Admin API-key holders (WORLDMONITOR_VALID_KEYS) bypass entitlement checks.
|
||||
// User API keys do NOT bypass — the key owner's tier is checked normally.
|
||||
if (!(keyCheck.valid && wmKey && !isUserApiKey)) {
|
||||
const entitlementResponse = await checkEntitlement(request, pathname, corsHeaders);
|
||||
if (entitlementResponse) return entitlementResponse;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user