api-keys.ts150 lines · main
| 1 | import { createHash, randomBytes } from 'node:crypto'; |
| 2 | |
| 3 | import { newId, NotFoundError, ValidationError } from '@briven/shared'; |
| 4 | import { and, desc, eq, isNull } from 'drizzle-orm'; |
| 5 | |
| 6 | import { getDb } from '../db/client.js'; |
| 7 | import { apiKeys, type ApiKey, type MemberRole } from '../db/schema.js'; |
| 8 | |
| 9 | const ASSIGNABLE_KEY_ROLES = [ |
| 10 | 'viewer', |
| 11 | 'developer', |
| 12 | 'admin', |
| 13 | ] as const satisfies readonly MemberRole[]; |
| 14 | type AssignableKeyRole = (typeof ASSIGNABLE_KEY_ROLES)[number]; |
| 15 | |
| 16 | export function isAssignableKeyRole(role: string): role is AssignableKeyRole { |
| 17 | return (ASSIGNABLE_KEY_ROLES as readonly string[]).includes(role); |
| 18 | } |
| 19 | |
| 20 | const KEY_PREFIX = 'brk'; // briven key — shown to the user, searchable in logs |
| 21 | const 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 | */ |
| 28 | export interface CreatedApiKey { |
| 29 | record: ApiKey; |
| 30 | plaintext: string; |
| 31 | } |
| 32 | |
| 33 | export 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 | */ |
| 81 | export 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 | |
| 98 | export 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 | |
| 109 | export 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 | |
| 128 | export 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 | |
| 140 | export 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 | } |