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:
100
api/invalidate-user-api-key-cache.ts
Normal file
100
api/invalidate-user-api-key-cache.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
392
convex/__tests__/apiKeys.test.ts
Normal file
392
convex/__tests__/apiKeys.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -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
184
convex/apiKeys.ts
Normal 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() });
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
103
src/services/api-keys.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user