Files
worldmonitor/server/_shared/user-api-key.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

84 lines
2.9 KiB
TypeScript

/**
* 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}`);
}