File size: 4,818 Bytes
9d2d895 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | /**
* 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,
waitForConvexAuthForUser,
} from './convex-client';
import { getClerkToken, getCurrentClerkUser } from './clerk';
import {
assertAccountStillCurrent,
settleAccountOperation,
} from './account-operation';
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). */
export 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 userId = getCurrentClerkUser()?.id;
if (!userId) throw new Error('Sign in to create an API key.');
const plaintext = generateKey();
const keyPrefix = plaintext.slice(0, 8);
const keyHash = await sha256Hex(plaintext);
const [client, api] = await Promise.all([getConvexClient(), getConvexApi()]);
if (!client || !api) throw new Error('Convex unavailable');
if (!await waitForConvexAuthForUser(userId)) {
throw new Error('Account changed while creating the API key. Try again.');
}
const result = await settleAccountOperation(
userId,
'creating the API key',
() => client.mutation(
(api as any).apiKeys.createApiKey,
{ name: name.trim(), keyPrefix, keyHash },
),
);
assertAccountStillCurrent(userId, 'creating the API key');
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 userId = getCurrentClerkUser()?.id;
if (!userId) return [];
const [client, api] = await Promise.all([getConvexClient(), getConvexApi()]);
if (!client || !api) return [];
if (!await waitForConvexAuthForUser(userId)) {
assertAccountStillCurrent(userId, 'loading API keys');
throw new Error('Authentication unavailable while loading API keys. Try again.');
}
return settleAccountOperation(
userId,
'loading API keys',
() => 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 userId = getCurrentClerkUser()?.id;
if (!userId) throw new Error('Sign in to revoke API keys.');
const [client, api] = await Promise.all([getConvexClient(), getConvexApi()]);
if (!client || !api) throw new Error('Convex unavailable');
if (!await waitForConvexAuthForUser(userId)) {
throw new Error('Account changed while revoking the API key. Try again.');
}
const result = await settleAccountOperation(
userId,
'revoking the API key',
() => client.mutation((api as any).apiKeys.revokeApiKey, { keyId }),
);
assertAccountStillCurrent(userId, 'revoking the API key');
// 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 settleAccountOperation(
userId,
'invalidating the API key cache',
getClerkToken,
);
if (token) {
assertAccountStillCurrent(userId, 'invalidating the API key cache');
const resp = await settleAccountOperation(
userId,
'invalidating the API key cache',
() => fetch('/api/invalidate-user-api-key-cache', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ keyHash: result.keyHash }),
}),
);
assertAccountStillCurrent(userId, 'invalidating the API key cache');
if (!resp.ok) {
console.warn('[api-keys] cache invalidation failed:', resp.status);
}
}
}
}
|