api-keys.ts150 lines · main
1import { createHash, randomBytes } from 'node:crypto';
2
3import { newId, NotFoundError, ValidationError } from '@briven/shared';
4import { and, desc, eq, isNull } from 'drizzle-orm';
5
6import { getDb } from '../db/client.js';
7import { apiKeys, type ApiKey, type MemberRole } from '../db/schema.js';
8
9const ASSIGNABLE_KEY_ROLES = [
10 'viewer',
11 'developer',
12 'admin',
13] as const satisfies readonly MemberRole[];
14type AssignableKeyRole = (typeof ASSIGNABLE_KEY_ROLES)[number];
15
16export function isAssignableKeyRole(role: string): role is AssignableKeyRole {
17 return (ASSIGNABLE_KEY_ROLES as readonly string[]).includes(role);
18}
19
20const KEY_PREFIX = 'brk'; // briven key — shown to the user, searchable in logs
21const KEY_ENTROPY_BYTES = 32; // 256 bits
22
23/**
24 * Deploy / CLI keys. The plaintext is returned exactly once on creation;
25 * only a SHA-256 hash is stored. A 4-char `suffix` is stored separately so
26 * the dashboard can show a harmless hint like "brk_•••••ab12".
27 */
28export interface CreatedApiKey {
29 record: ApiKey;
30 plaintext: string;
31}
32
33export async function createApiKey(input: {
34 projectId: string;
35 createdBy: string;
36 name: string;
37 /**
38 * Effective role this key carries when authenticating a request. Must be
39 * one of the assignable tiers (viewer / developer / admin) — `owner` is
40 * reserved for human owners and cannot be issued as a key role. Defaults
41 * to 'admin' for backward-compat with callers that don't specify.
42 */
43 role?: AssignableKeyRole;
44 expiresAt?: Date;
45}): Promise<CreatedApiKey> {
46 const role = input.role ?? 'admin';
47 if (!isAssignableKeyRole(role)) {
48 throw new ValidationError(`api key role must be one of ${ASSIGNABLE_KEY_ROLES.join(' | ')}`, {
49 role,
50 });
51 }
52
53 const raw = randomBytes(KEY_ENTROPY_BYTES).toString('base64url');
54 const plaintext = `${KEY_PREFIX}_${raw}`;
55 const hash = createHash('sha256').update(plaintext).digest('hex');
56 const suffix = plaintext.slice(-4);
57
58 const db = getDb();
59 const [record] = await db
60 .insert(apiKeys)
61 .values({
62 id: newId('k'),
63 projectId: input.projectId,
64 createdBy: input.createdBy,
65 name: input.name,
66 hash,
67 suffix,
68 role,
69 expiresAt: input.expiresAt ?? null,
70 })
71 .returning();
72 if (!record) throw new Error('api key insert returned no row');
73
74 return { record, plaintext };
75}
76
77/**
78 * Resolve a plaintext key to a project. Also bumps `last_used_at`.
79 * Returns null if the key is invalid, revoked, or expired.
80 */
81export async function resolveApiKey(
82 plaintext: string,
83): Promise<{ projectId: string; keyId: string; role: MemberRole } | null> {
84 const hash = createHash('sha256').update(plaintext).digest('hex');
85 const db = getDb();
86 const [row] = await db
87 .select()
88 .from(apiKeys)
89 .where(and(eq(apiKeys.hash, hash), isNull(apiKeys.revokedAt)))
90 .limit(1);
91 if (!row) return null;
92 if (row.expiresAt && row.expiresAt.getTime() < Date.now()) return null;
93
94 await db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, row.id));
95 return { projectId: row.projectId, keyId: row.id, role: row.role };
96}
97
98export interface MaskedApiKey {
99 id: string;
100 name: string;
101 suffix: string;
102 role: MemberRole;
103 createdAt: Date;
104 lastUsedAt: Date | null;
105 expiresAt: Date | null;
106 revokedAt: Date | null;
107}
108
109export async function listApiKeysForProject(projectId: string): Promise<MaskedApiKey[]> {
110 const db = getDb();
111 const rows = await db
112 .select({
113 id: apiKeys.id,
114 name: apiKeys.name,
115 suffix: apiKeys.suffix,
116 role: apiKeys.role,
117 createdAt: apiKeys.createdAt,
118 lastUsedAt: apiKeys.lastUsedAt,
119 expiresAt: apiKeys.expiresAt,
120 revokedAt: apiKeys.revokedAt,
121 })
122 .from(apiKeys)
123 .where(eq(apiKeys.projectId, projectId))
124 .orderBy(desc(apiKeys.createdAt));
125 return rows;
126}
127
128export async function renameApiKey(projectId: string, keyId: string, name: string): Promise<void> {
129 const db = getDb();
130 const [row] = await db
131 .select()
132 .from(apiKeys)
133 .where(and(eq(apiKeys.id, keyId), eq(apiKeys.projectId, projectId)))
134 .limit(1);
135 if (!row) throw new NotFoundError('api_key', keyId);
136 if (row.revokedAt) return; // revoked keys are immutable
137 await db.update(apiKeys).set({ name }).where(eq(apiKeys.id, keyId));
138}
139
140export async function revokeApiKey(projectId: string, keyId: string): Promise<void> {
141 const db = getDb();
142 const [row] = await db
143 .select()
144 .from(apiKeys)
145 .where(and(eq(apiKeys.id, keyId), eq(apiKeys.projectId, projectId)))
146 .limit(1);
147 if (!row) throw new NotFoundError('api_key', keyId);
148 if (row.revokedAt) return; // already revoked — idempotent
149 await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, keyId));
150}