import { customAlphabet } from "nanoid"; import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"; const nano = customAlphabet(alphabet, 16); const nanoLong = customAlphabet( "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 32, ); export type IdPrefix = | "req" | "att" | "sess" | "proj" | "org" | "key" | "usage" | "evt" | "wh" | "aud" | "abuse" | "crawl" | "cpg"; export function newId(prefix: IdPrefix): string { return `${prefix}_${nano()}`; } export type ApiKeyMode = "live" | "test"; export interface GeneratedApiKey { /** Full plaintext key — shown once, never persisted. */ plaintext: string; /** SHA-256 hash persisted in the database. */ hash: string; /** Short non-secret prefix for display: `fch_live_ab12…` */ prefix: string; /** Last 4 characters for display. */ last4: string; mode: ApiKeyMode; } export function generateApiKey(mode: ApiKeyMode = "live"): GeneratedApiKey { const secret = nanoLong(); const plaintext = `fch_${mode}_${secret}`; return { plaintext, hash: hashApiKey(plaintext), prefix: plaintext.slice(0, 13), last4: plaintext.slice(-4), mode, }; } export function hashApiKey(plaintext: string): string { return createHash("sha256").update(plaintext, "utf8").digest("hex"); } export function parseApiKeyMode(plaintext: string): ApiKeyMode | null { if (plaintext.startsWith("fch_live_")) return "live"; if (plaintext.startsWith("fch_test_")) return "test"; return null; } export function safeEqual(a: string, b: string): boolean { const ab = Buffer.from(a); const bb = Buffer.from(b); if (ab.length !== bb.length) return false; return timingSafeEqual(ab, bb); } export function randomToken(bytes = 32): string { return randomBytes(bytes).toString("base64url"); }