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:
Sebastien Melki
2026-04-17 06:20:39 +03:00
committed by GitHub
parent 935417e390
commit a4d9b0a5fa
13 changed files with 1404 additions and 7 deletions

View File

@@ -0,0 +1,100 @@
/**
* 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);
}

View File

@@ -49,7 +49,16 @@ export default async function handler(req: Request): Promise<Response> {
return new Response(JSON.stringify({ error: 'Method not allowed' }), { status: 405, headers: { ...cors, 'Content-Type': 'application/json' } });
}
const apiKeyResult = validateApiKey(req, { forceKey: true });
let apiKeyResult = validateApiKey(req, { forceKey: true });
// Fallback: wm_ user keys are validated async via Convex, not in the static key list
const wmKey = req.headers.get('X-WorldMonitor-Key') ?? '';
if (apiKeyResult.required && !apiKeyResult.valid && wmKey.startsWith('wm_')) {
const { validateUserApiKey } = await import('../../../server/_shared/user-api-key');
const userKeyResult = await validateUserApiKey(wmKey);
if (userKeyResult) {
apiKeyResult = { valid: true, required: true };
}
}
if (apiKeyResult.required && !apiKeyResult.valid) {
return new Response(JSON.stringify({ error: apiKeyResult.error ?? 'API key required' }), {
status: 401,

View File

@@ -0,0 +1,392 @@
import { convexTest } from "convex-test";
import { expect, test, describe } from "vitest";
import schema from "../schema";
import { api, internal } from "../_generated/api";
import { getFeaturesForPlan } from "../lib/entitlements";
const modules = import.meta.glob("../**/*.ts");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const NOW = Date.now();
const FUTURE = NOW + 86400000 * 30; // 30 days
const PAST = NOW - 86400000; // 1 day ago
const API_USER = { subject: "user-api", tokenIdentifier: "clerk|user-api" };
const PRO_USER = { subject: "user-pro", tokenIdentifier: "clerk|user-pro" };
const FREE_USER = { subject: "user-free", tokenIdentifier: "clerk|user-free" };
const OTHER_USER = { subject: "user-other", tokenIdentifier: "clerk|user-other" };
function makeKeyArgs(n: number) {
const hex = n.toString(16).padStart(5, "0");
const hash = hex.repeat(13).slice(0, 64); // 64-char hex
return {
name: `test-key-${n}`,
keyPrefix: `wm_${hex}`,
keyHash: hash,
};
}
/** Seed entitlement with apiAccess=true (API_STARTER plan, tier 2). */
async function seedApiEntitlement(
t: ReturnType<typeof convexTest>,
userId: string,
opts: { validUntil?: number } = {},
) {
await t.run(async (ctx) => {
await ctx.db.insert("entitlements", {
userId,
planKey: "api_starter",
features: getFeaturesForPlan("api_starter"),
validUntil: opts.validUntil ?? FUTURE,
updatedAt: NOW,
});
});
}
/** Seed entitlement with apiAccess=false (Pro plan, tier 1). */
async function seedProEntitlement(
t: ReturnType<typeof convexTest>,
userId: string,
opts: { validUntil?: number } = {},
) {
await t.run(async (ctx) => {
await ctx.db.insert("entitlements", {
userId,
planKey: "pro_monthly",
features: getFeaturesForPlan("pro_monthly"),
validUntil: opts.validUntil ?? FUTURE,
updatedAt: NOW,
});
});
}
// ---------------------------------------------------------------------------
// createApiKey
// ---------------------------------------------------------------------------
describe("createApiKey", () => {
test("rejects free-tier users (API_ACCESS_REQUIRED)", async () => {
const t = convexTest(schema, modules);
await expect(
t.withIdentity(FREE_USER).mutation(api.apiKeys.createApiKey, makeKeyArgs(1)),
).rejects.toThrow(/API_ACCESS_REQUIRED/);
});
test("rejects pro-tier users without apiAccess", async () => {
const t = convexTest(schema, modules);
await seedProEntitlement(t, "user-pro");
// Pro plan has apiAccess=false — should be rejected
await expect(
t.withIdentity(PRO_USER).mutation(api.apiKeys.createApiKey, makeKeyArgs(1)),
).rejects.toThrow(/API_ACCESS_REQUIRED/);
});
test("rejects users with expired entitlement", async () => {
const t = convexTest(schema, modules);
await seedApiEntitlement(t, "user-api", { validUntil: PAST });
await expect(
t.withIdentity(API_USER).mutation(api.apiKeys.createApiKey, makeKeyArgs(1)),
).rejects.toThrow(/API_ACCESS_REQUIRED/);
});
test("succeeds for API-tier user", async () => {
const t = convexTest(schema, modules);
await seedApiEntitlement(t, "user-api");
const result = await t.withIdentity(API_USER).mutation(
api.apiKeys.createApiKey,
makeKeyArgs(1),
);
expect(result).toMatchObject({
name: "test-key-1",
keyPrefix: "wm_00001",
});
expect(result.id).toBeTruthy();
});
test("enforces per-user limit of 5 active keys", async () => {
const t = convexTest(schema, modules);
await seedApiEntitlement(t, "user-api");
const asApiUser = t.withIdentity(API_USER);
for (let i = 1; i <= 5; i++) {
await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(i));
}
await expect(
asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(6)),
).rejects.toThrow(/KEY_LIMIT_REACHED/);
});
test("revoked keys do not count toward the limit", async () => {
const t = convexTest(schema, modules);
await seedApiEntitlement(t, "user-api");
const asApiUser = t.withIdentity(API_USER);
const first = await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(1));
for (let i = 2; i <= 5; i++) {
await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(i));
}
// Revoke the first key
await asApiUser.mutation(api.apiKeys.revokeApiKey, { keyId: first.id });
// Should succeed since only 4 active keys remain
const sixth = await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(6));
expect(sixth.name).toBe("test-key-6");
});
test("rejects duplicate key hash", async () => {
const t = convexTest(schema, modules);
await seedApiEntitlement(t, "user-api");
const asApiUser = t.withIdentity(API_USER);
await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(1));
await expect(
asApiUser.mutation(api.apiKeys.createApiKey, {
...makeKeyArgs(1),
name: "different-name",
}),
).rejects.toThrow(/DUPLICATE_KEY/);
});
test("rejects invalid keyPrefix format", async () => {
const t = convexTest(schema, modules);
await seedApiEntitlement(t, "user-api");
await expect(
t.withIdentity(API_USER).mutation(api.apiKeys.createApiKey, {
name: "test",
keyPrefix: "wm_toolong00",
keyHash: "a".repeat(64),
}),
).rejects.toThrow(/INVALID_PREFIX/);
});
test("rejects invalid keyHash format", async () => {
const t = convexTest(schema, modules);
await seedApiEntitlement(t, "user-api");
await expect(
t.withIdentity(API_USER).mutation(api.apiKeys.createApiKey, {
name: "test",
keyPrefix: "wm_abcde",
keyHash: "not-a-valid-hash",
}),
).rejects.toThrow(/INVALID_HASH/);
});
test("rejects empty name", async () => {
const t = convexTest(schema, modules);
await seedApiEntitlement(t, "user-api");
await expect(
t.withIdentity(API_USER).mutation(api.apiKeys.createApiKey, {
name: " ",
keyPrefix: "wm_abcde",
keyHash: "a".repeat(64),
}),
).rejects.toThrow(/INVALID_NAME/);
});
});
// ---------------------------------------------------------------------------
// revokeApiKey
// ---------------------------------------------------------------------------
describe("revokeApiKey", () => {
test("revokes own key and returns keyHash", async () => {
const t = convexTest(schema, modules);
await seedApiEntitlement(t, "user-api");
const asApiUser = t.withIdentity(API_USER);
const created = await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(1));
const result = await asApiUser.mutation(api.apiKeys.revokeApiKey, { keyId: created.id });
expect(result.ok).toBe(true);
expect(result.keyHash).toBe(makeKeyArgs(1).keyHash);
});
test("rejects non-owner revoke attempt", async () => {
const t = convexTest(schema, modules);
await seedApiEntitlement(t, "user-api");
const created = await t.withIdentity(API_USER).mutation(
api.apiKeys.createApiKey,
makeKeyArgs(1),
);
await expect(
t.withIdentity(OTHER_USER).mutation(api.apiKeys.revokeApiKey, { keyId: created.id }),
).rejects.toThrow(/NOT_FOUND/);
});
test("rejects double revocation", async () => {
const t = convexTest(schema, modules);
await seedApiEntitlement(t, "user-api");
const asApiUser = t.withIdentity(API_USER);
const created = await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(1));
await asApiUser.mutation(api.apiKeys.revokeApiKey, { keyId: created.id });
await expect(
asApiUser.mutation(api.apiKeys.revokeApiKey, { keyId: created.id }),
).rejects.toThrow(/ALREADY_REVOKED/);
});
});
// ---------------------------------------------------------------------------
// listApiKeys
// ---------------------------------------------------------------------------
describe("listApiKeys", () => {
test("returns empty list when no keys", async () => {
const t = convexTest(schema, modules);
const keys = await t.withIdentity(API_USER).query(api.apiKeys.listApiKeys, {});
expect(keys).toEqual([]);
});
test("returns both active and revoked keys", async () => {
const t = convexTest(schema, modules);
await seedApiEntitlement(t, "user-api");
const asApiUser = t.withIdentity(API_USER);
const k1 = await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(1));
await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(2));
await asApiUser.mutation(api.apiKeys.revokeApiKey, { keyId: k1.id });
const keys = await asApiUser.query(api.apiKeys.listApiKeys, {});
expect(keys).toHaveLength(2);
const active = keys.filter((k: any) => !k.revokedAt);
const revoked = keys.filter((k: any) => k.revokedAt);
expect(active).toHaveLength(1);
expect(revoked).toHaveLength(1);
});
test("does not return other users' keys", async () => {
const t = convexTest(schema, modules);
await seedApiEntitlement(t, "user-api");
await t.withIdentity(API_USER).mutation(api.apiKeys.createApiKey, makeKeyArgs(1));
const otherKeys = await t.withIdentity(OTHER_USER).query(api.apiKeys.listApiKeys, {});
expect(otherKeys).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// validateKeyByHash (internal)
// ---------------------------------------------------------------------------
describe("validateKeyByHash", () => {
test("returns key info for valid active key", async () => {
const t = convexTest(schema, modules);
await seedApiEntitlement(t, "user-api");
await t.withIdentity(API_USER).mutation(api.apiKeys.createApiKey, makeKeyArgs(1));
const result = await t.query(internal.apiKeys.validateKeyByHash, {
keyHash: makeKeyArgs(1).keyHash,
});
expect(result).toMatchObject({
userId: "user-api",
name: "test-key-1",
});
});
test("returns null for revoked key", async () => {
const t = convexTest(schema, modules);
await seedApiEntitlement(t, "user-api");
const asApiUser = t.withIdentity(API_USER);
const created = await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(1));
await asApiUser.mutation(api.apiKeys.revokeApiKey, { keyId: created.id });
const result = await t.query(internal.apiKeys.validateKeyByHash, {
keyHash: makeKeyArgs(1).keyHash,
});
expect(result).toBeNull();
});
test("returns null for nonexistent hash", async () => {
const t = convexTest(schema, modules);
const result = await t.query(internal.apiKeys.validateKeyByHash, {
keyHash: "f".repeat(64),
});
expect(result).toBeNull();
});
});
// ---------------------------------------------------------------------------
// getKeyOwner (internal)
// ---------------------------------------------------------------------------
describe("getKeyOwner", () => {
test("returns owner regardless of revoked status", async () => {
const t = convexTest(schema, modules);
await seedApiEntitlement(t, "user-api");
const asApiUser = t.withIdentity(API_USER);
const created = await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(1));
await asApiUser.mutation(api.apiKeys.revokeApiKey, { keyId: created.id });
const result = await t.query(internal.apiKeys.getKeyOwner, {
keyHash: makeKeyArgs(1).keyHash,
});
expect(result).toEqual({ userId: "user-api" });
});
test("returns null for nonexistent hash", async () => {
const t = convexTest(schema, modules);
const result = await t.query(internal.apiKeys.getKeyOwner, {
keyHash: "f".repeat(64),
});
expect(result).toBeNull();
});
});
// ---------------------------------------------------------------------------
// touchKeyLastUsed (internal) — debounce
// ---------------------------------------------------------------------------
describe("touchKeyLastUsed", () => {
test("sets lastUsedAt on first call", async () => {
const t = convexTest(schema, modules);
await seedApiEntitlement(t, "user-api");
const created = await t.withIdentity(API_USER).mutation(
api.apiKeys.createApiKey,
makeKeyArgs(1),
);
await t.mutation(internal.apiKeys.touchKeyLastUsed, { keyId: created.id });
const keys = await t.withIdentity(API_USER).query(api.apiKeys.listApiKeys, {});
const key = keys.find((k: any) => k.id === created.id);
expect(key?.lastUsedAt).toBeGreaterThan(0);
});
test("skips write for revoked key", async () => {
const t = convexTest(schema, modules);
await seedApiEntitlement(t, "user-api");
const asApiUser = t.withIdentity(API_USER);
const created = await asApiUser.mutation(api.apiKeys.createApiKey, makeKeyArgs(1));
await asApiUser.mutation(api.apiKeys.revokeApiKey, { keyId: created.id });
// Should not throw
await t.mutation(internal.apiKeys.touchKeyLastUsed, { keyId: created.id });
});
});

View File

@@ -9,6 +9,7 @@
*/
import type * as alertRules from "../alertRules.js";
import type * as apiKeys from "../apiKeys.js";
import type * as config_productCatalog from "../config/productCatalog.js";
import type * as constants from "../constants.js";
import type * as contactMessages from "../contactMessages.js";
@@ -43,6 +44,7 @@ import type {
declare const fullApi: ApiFromModules<{
alertRules: typeof alertRules;
apiKeys: typeof apiKeys;
"config/productCatalog": typeof config_productCatalog;
constants: typeof constants;
contactMessages: typeof contactMessages;

184
convex/apiKeys.ts Normal file
View File

@@ -0,0 +1,184 @@
import { ConvexError, v } from "convex/values";
import { internalMutation, internalQuery, mutation, query } from "./_generated/server";
import { requireUserId } from "./lib/auth";
/** Maximum number of active (non-revoked) API keys per user. */
const MAX_KEYS_PER_USER = 5;
// ---------------------------------------------------------------------------
// Public mutations & queries (require Clerk JWT via ctx.auth)
// ---------------------------------------------------------------------------
/**
* Create a new API key.
*
* The caller must generate the random key client-side (or in the HTTP action)
* and pass the SHA-256 hex hash + the first 8 chars (prefix) here.
* The plaintext key is NEVER stored in Convex.
*
* Requires an active entitlement with apiAccess=true (API_STARTER+ plans).
* Pro plans (tier 1) have apiAccess=false and cannot create keys.
*/
export const createApiKey = mutation({
args: {
name: v.string(),
keyPrefix: v.string(),
keyHash: v.string(),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
// Entitlement gate: only users with apiAccess may create API keys.
// This is catalog-driven — Pro (tier 1) has apiAccess=false;
// API_STARTER+ (tier 2+) have apiAccess=true.
const entitlement = await ctx.db
.query("entitlements")
.withIndex("by_userId", (q) => q.eq("userId", userId))
.first();
if (
!entitlement ||
entitlement.validUntil < Date.now() ||
!entitlement.features.apiAccess
) {
throw new ConvexError("API_ACCESS_REQUIRED");
}
if (!args.name.trim()) {
throw new ConvexError("INVALID_NAME");
}
if (!/^wm_[a-f0-9]{5}$/.test(args.keyPrefix)) {
throw new ConvexError("INVALID_PREFIX");
}
if (!/^[a-f0-9]{64}$/.test(args.keyHash)) {
throw new ConvexError("INVALID_HASH");
}
// Enforce per-user key limit (count only non-revoked keys)
const existing = await ctx.db
.query("userApiKeys")
.withIndex("by_userId", (q) => q.eq("userId", userId))
.collect();
const activeCount = existing.filter((k) => !k.revokedAt).length;
if (activeCount >= MAX_KEYS_PER_USER) {
throw new ConvexError("KEY_LIMIT_REACHED");
}
// Guard against duplicate hash (astronomically unlikely, but belt-and-suspenders)
const dup = await ctx.db
.query("userApiKeys")
.withIndex("by_keyHash", (q) => q.eq("keyHash", args.keyHash))
.first();
if (dup) {
throw new ConvexError("DUPLICATE_KEY");
}
const id = await ctx.db.insert("userApiKeys", {
userId,
name: args.name.trim(),
keyPrefix: args.keyPrefix,
keyHash: args.keyHash,
createdAt: Date.now(),
});
return { id, name: args.name.trim(), keyPrefix: args.keyPrefix };
},
});
/** List all API keys for the current user (active + revoked). */
export const listApiKeys = query({
args: {},
handler: async (ctx) => {
const userId = await requireUserId(ctx);
const keys = await ctx.db
.query("userApiKeys")
.withIndex("by_userId", (q) => q.eq("userId", userId))
.collect();
return keys.map((k) => ({
id: k._id,
name: k.name,
keyPrefix: k.keyPrefix,
createdAt: k.createdAt,
lastUsedAt: k.lastUsedAt,
revokedAt: k.revokedAt,
}));
},
});
/** Revoke a key owned by the current user. */
export const revokeApiKey = mutation({
args: { keyId: v.id("userApiKeys") },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const key = await ctx.db.get(args.keyId);
if (!key || key.userId !== userId) {
throw new ConvexError("NOT_FOUND");
}
if (key.revokedAt) {
throw new ConvexError("ALREADY_REVOKED");
}
await ctx.db.patch(args.keyId, { revokedAt: Date.now() });
return { ok: true, keyHash: key.keyHash };
},
});
// ---------------------------------------------------------------------------
// Internal (service-to-service) — called from HTTP actions / middleware
// ---------------------------------------------------------------------------
/**
* Look up an API key by its SHA-256 hash.
* Returns the key row (with userId) if found and not revoked, else null.
* Used by the edge gateway to validate incoming API keys.
*/
export const validateKeyByHash = internalQuery({
args: { keyHash: v.string() },
handler: async (ctx, args) => {
const key = await ctx.db
.query("userApiKeys")
.withIndex("by_keyHash", (q) => q.eq("keyHash", args.keyHash))
.first();
if (!key || key.revokedAt) return null;
return {
id: key._id,
userId: key.userId,
name: key.name,
};
},
});
/**
* Look up the owner of a key by its hash, regardless of revoked status.
* Used by the cache-invalidation endpoint to verify tenancy.
*/
export const getKeyOwner = internalQuery({
args: { keyHash: v.string() },
handler: async (ctx, args) => {
const key = await ctx.db
.query("userApiKeys")
.withIndex("by_keyHash", (q) => q.eq("keyHash", args.keyHash))
.first();
return key ? { userId: key.userId } : null;
},
});
/**
* Bump lastUsedAt for a key (fire-and-forget from the gateway).
* Skips the write if lastUsedAt was updated within the last 5 minutes
* to reduce Convex write load for hot keys.
*/
const TOUCH_DEBOUNCE_MS = 5 * 60 * 1000;
export const touchKeyLastUsed = internalMutation({
args: { keyId: v.id("userApiKeys") },
handler: async (ctx, args) => {
const key = await ctx.db.get(args.keyId);
if (!key || key.revokedAt) return;
if (key.lastUsedAt && key.lastUsedAt > Date.now() - TOUCH_DEBOUNCE_MS) return;
await ctx.db.patch(args.keyId, { lastUsedAt: Date.now() });
},
});

View File

@@ -634,6 +634,103 @@ http.route({
}),
});
// ---------------------------------------------------------------------------
// User API key validation (service-to-service only)
// ---------------------------------------------------------------------------
// Service-to-service: validate a user API key by its SHA-256 hash.
// Called by the Vercel edge gateway to look up user-owned keys.
http.route({
path: "/api/internal-validate-api-key",
method: "POST",
handler: httpAction(async (ctx, request) => {
const providedSecret = request.headers.get("x-convex-shared-secret") ?? "";
const expectedSecret = process.env.CONVEX_SERVER_SHARED_SECRET ?? "";
if (!expectedSecret || !(await timingSafeEqualStrings(providedSecret, expectedSecret))) {
return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
let body: { keyHash?: unknown };
try {
body = await request.json() as { keyHash?: unknown };
} catch {
return new Response(JSON.stringify({ error: "INVALID_JSON" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (typeof body.keyHash !== "string" || body.keyHash.length === 0) {
return new Response(JSON.stringify({ error: "MISSING_KEY_HASH" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const result = await ctx.runQuery(
(internal as any).apiKeys.validateKeyByHash,
{ keyHash: body.keyHash },
);
if (result) {
// Fire-and-forget: update lastUsedAt (don't await, don't block response)
void ctx.runMutation((internal as any).apiKeys.touchKeyLastUsed, { keyId: result.id });
}
return new Response(JSON.stringify(result), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}),
});
// Service-to-service: look up the owner of a key by hash (regardless of revoked status).
// Used by the cache-invalidation endpoint to verify tenancy boundaries.
http.route({
path: "/api/internal-get-key-owner",
method: "POST",
handler: httpAction(async (ctx, request) => {
const providedSecret = request.headers.get("x-convex-shared-secret") ?? "";
const expectedSecret = process.env.CONVEX_SERVER_SHARED_SECRET ?? "";
if (!expectedSecret || !(await timingSafeEqualStrings(providedSecret, expectedSecret))) {
return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
let body: { keyHash?: unknown };
try {
body = await request.json() as { keyHash?: unknown };
} catch {
return new Response(JSON.stringify({ error: "INVALID_JSON" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (typeof body.keyHash !== "string" || !/^[a-f0-9]{64}$/.test(body.keyHash)) {
return new Response(JSON.stringify({ error: "INVALID_KEY_HASH" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const result = await ctx.runQuery(
(internal as any).apiKeys.getKeyOwner,
{ keyHash: body.keyHash },
);
return new Response(JSON.stringify(result), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}),
});
http.route({
path: "/dodopayments-webhook",
method: "POST",

View File

@@ -215,6 +215,18 @@ export default defineSchema({
.index("by_dodoProductId", ["dodoProductId"])
.index("by_planKey", ["planKey"]),
userApiKeys: defineTable({
userId: v.string(),
name: v.string(),
keyPrefix: v.string(), // first 8 chars of plaintext key, for display
keyHash: v.string(), // SHA-256 hex digest — never store plaintext
createdAt: v.number(),
lastUsedAt: v.optional(v.number()),
revokedAt: v.optional(v.number()),
})
.index("by_userId", ["userId"])
.index("by_keyHash", ["keyHash"]),
emailSuppressions: defineTable({
normalizedEmail: v.string(),
reason: v.union(v.literal("bounce"), v.literal("complaint"), v.literal("manual")),

View File

@@ -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 };

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

View File

@@ -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;
}

View File

@@ -12,6 +12,7 @@ import { getAuthState } from '@/services/auth-state';
import { track } from '@/services/analytics';
import { isEntitled } from '@/services/entitlements';
import { getSubscription, openBillingPortal } from '@/services/billing';
import { createApiKey, listApiKeys, revokeApiKey, type ApiKeyInfo } from '@/services/api-keys';
function showToast(msg: string): void {
document.querySelector('.toast-notification')?.remove();
@@ -38,7 +39,7 @@ export interface UnifiedSettingsConfig {
onMapProviderChange?: (provider: MapProvider) => void;
}
type TabId = 'settings' | 'panels' | 'sources';
type TabId = 'settings' | 'panels' | 'sources' | 'api-keys';
export class UnifiedSettings {
private overlay: HTMLElement;
@@ -53,6 +54,10 @@ export class UnifiedSettings {
private draftPanelSettings: Record<string, PanelConfig> = {};
private panelsJustSaved = false;
private savedTimeout: ReturnType<typeof setTimeout> | null = null;
private apiKeys: ApiKeyInfo[] = [];
private apiKeysLoading = false;
private apiKeysError = '';
private newlyCreatedKey: string | null = null;
constructor(config: UnifiedSettingsConfig) {
this.config = config;
@@ -164,6 +169,28 @@ export class UnifiedSettings {
this.updateSourcesCounter();
return;
}
if (target.closest('.api-keys-create-btn')) {
void this.handleCreateApiKey();
return;
}
const revokeBtn = target.closest<HTMLElement>('.api-keys-revoke-btn');
if (revokeBtn?.dataset.keyId) {
void this.handleRevokeApiKey(revokeBtn.dataset.keyId);
return;
}
if (target.closest('.api-keys-copy-btn')) {
const key = this.newlyCreatedKey;
if (key) {
void navigator.clipboard.writeText(key).then(() => {
const btn = this.overlay.querySelector<HTMLElement>('.api-keys-copy-btn');
if (btn) { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy'; }, 1500); }
});
}
return;
}
});
this.overlay.addEventListener('input', (e) => {
@@ -246,6 +273,7 @@ export class UnifiedSettings {
<button class="${tabClass('settings')}" data-tab="settings" role="tab" aria-selected="${this.activeTab === 'settings'}" id="us-tab-settings" aria-controls="us-tab-panel-settings">${t('header.tabSettings')}</button>
<button class="${tabClass('panels')}" data-tab="panels" role="tab" aria-selected="${this.activeTab === 'panels'}" id="us-tab-panels" aria-controls="us-tab-panel-panels">${t('header.tabPanels')}</button>
<button class="${tabClass('sources')}" data-tab="sources" role="tab" aria-selected="${this.activeTab === 'sources'}" id="us-tab-sources" aria-controls="us-tab-panel-sources">${t('header.tabSources')}</button>
${getAuthState().user ? `<button class="${tabClass('api-keys')}" data-tab="api-keys" role="tab" aria-selected="${this.activeTab === 'api-keys'}" id="us-tab-api-keys" aria-controls="us-tab-panel-api-keys">API Keys</button>` : ''}
</div>
<div class="unified-settings-tab-panel${this.activeTab === 'settings' ? ' active' : ''}" data-panel-id="settings" id="us-tab-panel-settings" role="tabpanel" aria-labelledby="us-tab-settings">
${prefs.html}
@@ -279,6 +307,24 @@ export class UnifiedSettings {
<button class="sources-select-none">${t('common.selectNone')}</button>
</div>
</div>
${getAuthState().user ? `
<div class="unified-settings-tab-panel${this.activeTab === 'api-keys' ? ' active' : ''}" data-panel-id="api-keys" id="us-tab-panel-api-keys" role="tabpanel" aria-labelledby="us-tab-api-keys">
<div class="api-keys-section">
<div class="api-keys-header">
<p class="api-keys-desc">Create API keys to access WorldMonitor data programmatically. Keys are shown once on creation — store them securely.</p>
</div>
<div class="api-keys-create-form">
<input type="text" class="api-keys-name-input" placeholder="Key name (e.g. my-app)" maxlength="64" />
<button class="btn btn-primary api-keys-create-btn">Create Key</button>
</div>
<div class="api-keys-created-banner" id="usApiKeysBanner" style="display:none;"></div>
<div class="api-keys-error" id="usApiKeysError" style="display:none;"></div>
<div class="api-keys-list" id="usApiKeysList">
<div class="api-keys-loading">Loading...</div>
</div>
</div>
</div>
` : ''}
</div>
`;
@@ -300,6 +346,18 @@ export class UnifiedSettings {
this.renderRegionPills();
this.renderSourcesGrid();
this.updateSourcesCounter();
// API keys: Enter to submit
const apiKeyInput = this.overlay.querySelector<HTMLInputElement>('.api-keys-name-input');
if (apiKeyInput) {
apiKeyInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') void this.handleCreateApiKey();
});
}
if (this.activeTab === 'api-keys') {
void this.loadApiKeys();
}
}
private switchTab(tab: TabId): void {
@@ -314,6 +372,10 @@ export class UnifiedSettings {
this.overlay.querySelectorAll('.unified-settings-tab-panel').forEach(el => {
el.classList.toggle('active', (el as HTMLElement).dataset.panelId === tab);
});
if (tab === 'api-keys') {
void this.loadApiKeys();
}
}
private renderUpgradeSection(): string {
@@ -615,4 +677,146 @@ export class UnifiedSettings {
counter.textContent = t('header.sourcesEnabled', { enabled: String(enabledTotal), total: String(allSources.length) });
}
// ---------------------------------------------------------------------------
// API Keys tab
// ---------------------------------------------------------------------------
private async loadApiKeys(): Promise<void> {
this.apiKeysLoading = true;
this.apiKeysError = '';
this.renderApiKeysList();
try {
this.apiKeys = await listApiKeys();
} catch (err) {
this.apiKeysError = err instanceof Error ? err.message : 'Failed to load keys';
} finally {
this.apiKeysLoading = false;
this.renderApiKeysList();
}
}
private async handleCreateApiKey(): Promise<void> {
const input = this.overlay.querySelector<HTMLInputElement>('.api-keys-name-input');
const btn = this.overlay.querySelector<HTMLButtonElement>('.api-keys-create-btn');
const name = input?.value.trim();
if (!name || !input || !btn) return;
btn.disabled = true;
btn.textContent = 'Creating...';
this.apiKeysError = '';
this.newlyCreatedKey = null;
this.hideBanner();
try {
const result = await createApiKey(name);
this.newlyCreatedKey = result.key;
input.value = '';
this.showCreatedBanner(result.key);
await this.loadApiKeys();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to create key';
this.apiKeysError = msg.includes('KEY_LIMIT_REACHED')
? 'Maximum of 5 active keys reached. Revoke an existing key first.'
: msg.includes('API_ACCESS_REQUIRED')
? 'API keys require an API access subscription (API Starter or higher).'
: msg;
this.renderApiKeysError();
} finally {
btn.disabled = false;
btn.textContent = 'Create Key';
}
}
private async handleRevokeApiKey(keyId: string): Promise<void> {
const keyInfo = this.apiKeys.find(k => k.id === keyId);
const keyName = keyInfo?.name ?? 'this key';
if (!confirm(`Revoke "${keyName}"? This cannot be undone. Any applications using this key will stop working.`)) return;
try {
await revokeApiKey(keyId);
await this.loadApiKeys();
} catch (err) {
this.apiKeysError = err instanceof Error ? err.message : 'Failed to revoke key';
this.renderApiKeysError();
}
}
private showCreatedBanner(key: string): void {
const banner = this.overlay.querySelector<HTMLElement>('#usApiKeysBanner');
if (!banner) return;
banner.style.display = 'block';
banner.innerHTML = `
<div class="api-keys-banner-title">Key created — copy it now, it won't be shown again</div>
<div class="api-keys-banner-key">
<code class="api-keys-key-value">${escapeHtml(key)}</code>
<button class="btn btn-secondary api-keys-copy-btn">Copy</button>
</div>
`;
}
private hideBanner(): void {
const banner = this.overlay.querySelector<HTMLElement>('#usApiKeysBanner');
if (banner) {
banner.style.display = 'none';
banner.innerHTML = '';
}
}
private renderApiKeysError(): void {
const el = this.overlay.querySelector<HTMLElement>('#usApiKeysError');
if (!el) return;
if (this.apiKeysError) {
el.style.display = 'block';
el.textContent = this.apiKeysError;
} else {
el.style.display = 'none';
el.textContent = '';
}
}
private renderApiKeysList(): void {
const container = this.overlay.querySelector('#usApiKeysList');
if (!container) return;
if (this.apiKeysLoading && this.apiKeys.length === 0) {
container.innerHTML = '<div class="api-keys-loading">Loading...</div>';
return;
}
this.renderApiKeysError();
const active = this.apiKeys.filter(k => !k.revokedAt);
const revoked = this.apiKeys.filter(k => k.revokedAt);
if (active.length === 0 && revoked.length === 0) {
container.innerHTML = '<div class="api-keys-empty">No API keys yet. Create one above to get started.</div>';
return;
}
const formatDate = (ts: number) => new Date(ts).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
const renderKey = (k: ApiKeyInfo) => {
const isRevoked = !!k.revokedAt;
return `
<div class="api-keys-item${isRevoked ? ' revoked' : ''}">
<div class="api-keys-item-main">
<span class="api-keys-item-name">${escapeHtml(k.name)}</span>
<code class="api-keys-item-prefix">${escapeHtml(k.keyPrefix)}${'*'.repeat(8)}</code>
</div>
<div class="api-keys-item-meta">
<span>Created ${formatDate(k.createdAt)}</span>
${k.lastUsedAt ? `<span>Last used ${formatDate(k.lastUsedAt)}</span>` : ''}
${isRevoked ? `<span class="api-keys-item-revoked-badge">Revoked ${formatDate(k.revokedAt!)}</span>` : ''}
</div>
${!isRevoked ? `<button class="btn btn-ghost api-keys-revoke-btn" data-key-id="${escapeHtml(k.id)}">Revoke</button>` : ''}
</div>
`;
};
container.innerHTML = active.map(renderKey).join('')
+ (revoked.length > 0 ? `<div class="api-keys-revoked-section"><div class="api-keys-revoked-label">Revoked</div>${revoked.map(renderKey).join('')}</div>` : '');
}
}

103
src/services/api-keys.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Frontend service for managing user API keys.
*
* Uses the shared ConvexClient (WebSocket) to call mutations/queries in
* convex/apiKeys.ts. Key generation + hashing happens client-side so the
* plaintext key is shown to the user exactly once without a round-trip
* that could log it.
*/
import { getConvexClient, getConvexApi, waitForConvexAuth } from './convex-client';
import { getClerkToken } from './clerk';
export interface ApiKeyInfo {
id: string;
name: string;
keyPrefix: string;
createdAt: number;
lastUsedAt?: number;
revokedAt?: number;
}
export interface CreateApiKeyResult {
id: string;
name: string;
keyPrefix: string;
/** Plaintext key — shown to the user ONCE. */
key: string;
}
/** Generate a random key: wm_<40 hex chars> (20 bytes = 160 bits). */
function generateKey(): string {
const raw = new Uint8Array(20);
crypto.getRandomValues(raw);
const hex = Array.from(raw, (b) => b.toString(16).padStart(2, '0')).join('');
return `wm_${hex}`;
}
/** SHA-256 hex digest of a string. */
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('');
}
/**
* Create a new API key for the current user.
* Returns the full plaintext key (shown once) and metadata.
*/
export async function createApiKey(name: string): Promise<CreateApiKeyResult> {
const client = await getConvexClient();
const api = await getConvexApi();
if (!client || !api) throw new Error('Convex unavailable');
await waitForConvexAuth();
const plaintext = generateKey();
const keyPrefix = plaintext.slice(0, 8);
const keyHash = await sha256Hex(plaintext);
const result = await client.mutation(
(api as any).apiKeys.createApiKey,
{ name: name.trim(), keyPrefix, keyHash },
);
return { id: result.id, name: result.name, keyPrefix: result.keyPrefix, key: plaintext };
}
/** List all API keys for the current user. */
export async function listApiKeys(): Promise<ApiKeyInfo[]> {
const client = await getConvexClient();
const api = await getConvexApi();
if (!client || !api) return [];
await waitForConvexAuth();
return client.query((api as any).apiKeys.listApiKeys, {});
}
/** Revoke an API key by its Convex document ID. */
export async function revokeApiKey(keyId: string): Promise<void> {
const client = await getConvexClient();
const api = await getConvexApi();
if (!client || !api) throw new Error('Convex unavailable');
await waitForConvexAuth();
const result = await client.mutation((api as any).apiKeys.revokeApiKey, { keyId });
// Await cache bust so the gateway stops accepting the revoked key immediately.
// If this fails, the 60s cache TTL limits the staleness window.
if (result?.keyHash) {
const token = await getClerkToken();
if (token) {
const resp = await fetch('/api/invalidate-user-api-key-cache', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ keyHash: result.keyHash }),
});
if (!resp.ok) {
console.warn('[api-keys] cache invalidation failed:', resp.status);
}
}
}
}

View File

@@ -22727,6 +22727,167 @@ body.map-width-resizing {
.manage-billing-btn:hover { color: var(--text); }
/* ---- API Keys Settings Tab ---- */
.api-keys-section {
padding: 16px 0;
}
.api-keys-desc {
font-size: 12px;
color: var(--text-dim);
margin: 0 0 16px;
line-height: 1.5;
}
.api-keys-create-form {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.api-keys-name-input {
flex: 1;
padding: 8px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
outline: none;
}
.api-keys-name-input:focus {
border-color: var(--accent);
}
.api-keys-name-input::placeholder {
color: var(--text-dim);
}
.api-keys-created-banner {
padding: 12px;
background: color-mix(in srgb, #22c55e 8%, transparent);
border: 1px solid color-mix(in srgb, #22c55e 25%, transparent);
border-radius: 6px;
margin-bottom: 12px;
}
.api-keys-banner-title {
font-size: 12px;
font-weight: 600;
color: #22c55e;
margin-bottom: 8px;
}
.api-keys-banner-key {
display: flex;
align-items: center;
gap: 8px;
}
.api-keys-key-value {
flex: 1;
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', monospace;
font-size: 12px;
color: var(--text);
background: var(--surface);
padding: 6px 10px;
border-radius: 4px;
word-break: break-all;
user-select: all;
}
.api-keys-error {
font-size: 12px;
color: var(--semantic-critical);
margin-bottom: 8px;
}
.api-keys-loading,
.api-keys-empty {
font-size: 12px;
color: var(--text-dim);
padding: 16px 0;
text-align: center;
}
.api-keys-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 6px;
}
.api-keys-item.revoked {
opacity: 0.5;
}
.api-keys-item-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.api-keys-item-name {
font-size: 13px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.api-keys-item-prefix {
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', monospace;
font-size: 11px;
color: var(--text-dim);
}
.api-keys-item-meta {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 11px;
color: var(--text-dim);
text-align: right;
white-space: nowrap;
}
.api-keys-item-revoked-badge {
color: var(--semantic-critical);
font-weight: 600;
}
.api-keys-revoke-btn {
font-size: 12px;
padding: 4px 10px;
color: var(--semantic-critical);
flex-shrink: 0;
}
.api-keys-revoke-btn:hover {
background: color-mix(in srgb, var(--semantic-critical) 10%, transparent);
}
.api-keys-revoked-section {
margin-top: 16px;
}
.api-keys-revoked-label {
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 6px;
}
/* ---- Energy Crisis Panel ---- */
.ecp-container {