/** * API-key persistence over the app's KV state store (`App.create({ kv })`). * * Records are keyed by token hash (`ApiKey.keyFor`) — the plaintext token is * never stored — and validated against the scope catalog on read; unknown, * corrupt, expired, or invalid records resolve to `null`. An `id → recordKey` * index makes revoke-by-id O(1). The persisted {@link Record}/{@link * MintInput} shapes live here (derived from `ApiKey.schema` so they cannot * drift from the resolved shape), while the shared credential primitives * (token format/generation, storage-key derivation, redaction) live in * `./ApiKey.js`. */ import * as z from 'zod/mini' import * as ApiKey from './ApiKey.js' import type * as Store from './internal/Store.js' import * as Scope from './Scope.js' /** Zod schemas owned by the API-keys module. */ export namespace schema { /** * Fields accepted when minting a key, and the single source of truth for the * {@link MintInput} type and structural validation. {@link mint} validates * scope identifiers against its configured catalog. */ export const MintInput = z.extend(z.omit(ApiKey.schema.ApiKey, { id: true }), { createdBy: z .optional(z.string()) .check(z.describe('Identity creating the key (e.g. admin email), recorded for audit.')), expiresAt: z .optional(z.string()) .check(z.describe('ISO 8601 expiry timestamp. Omit for a non-expiring key.')), // TODO: make required once orgs exist. orgId: z.optional(z.string()).check(z.describe('Owning organization id.')), }) /** * A persisted API-key record: the resolved `ApiKey.schema` plus * persistence/display fields. Extending the resolved schema keeps the resolved * and persisted shapes in lockstep. */ export const Record = z.extend(ApiKey.schema.ApiKey, { createdAt: z.string().check(z.describe('ISO 8601 creation timestamp.')), createdBy: z.optional(z.string()).check(z.describe('Identity that created the key.')), expiresAt: z.optional(z.string()).check(z.describe('ISO 8601 expiry timestamp.')), tokenLast4: z.string().check(z.describe('Last 4 chars of the plaintext token.')), }) } /** * A durable API-key record as persisted. A superset of the resolved * `ApiKey.ApiKey` with provisioning/display metadata. The plaintext token is * never stored — records are keyed by its hash (`ApiKey.keyFor`) and carry only * `tokenLast4` for display. */ export type Record = z.output /** * Fields accepted when minting a key via {@link mint}. Uses the schema's * *input* type so defaulted fields (e.g. `environment`) are optional for * callers — the default is applied during `mint`'s validation. */ export type MintInput = z.input /** Result of {@link mint}: the persisted record and its one-time token. */ export type MintResult = { /** The persisted key record (metadata only — never the token). */ record: Record /** The plaintext token, shown once and unrecoverable afterward. */ token: string } /** * Mints a key: validates the input (e.g. rejects an unknown scope at the write * boundary), persists its hashed record, and returns the record plus the * one-time plaintext token (shown once, unrecoverable afterward). * * @param state - The KV state store holding key records. * @param input - The key to mint. * @param options - Scope catalog used for validation. * @returns The persisted record and its one-time token. */ export async function mint( state: Store.State, input: MintInput, options: mint.Options = {}, ): Promise { const valid = schema.MintInput.safeParse(input) if (!valid.success) throw new Error(`invalid API key input: ${z.prettifyError(valid.error)}`) const data = valid.data if (!validScopes(data.scopes, options.scopeCatalog ?? Scope.catalog)) throw new Error('invalid API key input: unknown scope') const time = new Date() const token = ApiKey.generateToken(data.environment) const recordKey = ApiKey.keyFor(token) const id = ApiKey.generateId() const record: Record = { allowedIps: data.allowedIps, createdAt: time.toISOString(), environment: data.environment, id, // TODO: drop fallback once a real org concept exists. orgId: data.orgId ?? id, scopes: data.scopes, tokenLast4: token.slice(-4), ...(data.billingActive === undefined ? {} : { billingActive: data.billingActive }), ...(data.createdBy === undefined ? {} : { createdBy: data.createdBy }), ...(data.expiresAt === undefined ? {} : { expiresAt: data.expiresAt }), ...(data.name === undefined ? {} : { name: data.name }), ...(data.projectId === undefined ? {} : { projectId: data.projectId }), ...(data.rateLimits === undefined ? {} : { rateLimits: data.rateLimits }), } // Bound the stored record by its expiry so the backend evicts it natively. const ttl = data.expiresAt === undefined ? undefined : Math.max(1, Date.parse(data.expiresAt) - time.getTime()) await state.put(recordKey, JSON.stringify(record), { ttl }) // Index `id → recordKey` so revoke-by-id is O(1) (records are keyed by // token hash, unrecoverable from the id alone). Shares the record's TTL so // both entries expire together. await state.put(idKeyFor(record.id), recordKey, { ttl }) // Index `org → recordKey` (one entry per key, so concurrent mints never // race a shared value) backing org-scoped listing without a full scan. await state.put(orgKeyFor(record.orgId, record.id), recordKey, { ttl }) return { record, token } } export declare namespace mint { /** Options for {@link mint}. */ type Options = { /** Scope catalog accepted while minting the key. */ scopeCatalog?: Scope.Catalog | undefined } } /** * Resolves a plaintext API key token to its metadata, or `null`. Expiry is * enforced on read — not just via the store's native TTL — so it holds on * backends that evict lazily and regardless of TTL granularity. * * @param state - The KV state store holding key records. * @param token - The presented plaintext token. * @param options - Scope catalog used for validation. * @returns The resolved key metadata, or `null`. */ export async function resolve( state: Store.State, token: string, options: resolve.Options = {}, ): Promise { return read(state, ApiKey.keyFor(token), Date.now(), options.scopeCatalog ?? Scope.catalog) } export declare namespace resolve { /** Options for {@link resolve}. */ type Options = { /** Scope catalog accepted while resolving the key. */ scopeCatalog?: Scope.Catalog | undefined } } /** * Reads a key record by id (metadata only — never the token), or `null`. * * @param state - The KV state store holding key records. * @param id - The key id (`key_…`). * @param options - Scope catalog used for validation. * @returns The record, or `null`. */ export async function get( state: Store.State, id: string, options: get.Options = {}, ): Promise { const recordKey = await state.get(idKeyFor(id)) if (!recordKey) return null return read(state, recordKey, Date.now(), options.scopeCatalog ?? Scope.catalog) } export declare namespace get { /** Options for {@link get}. */ type Options = { /** Scope catalog accepted while reading the key. */ scopeCatalog?: Scope.Catalog | undefined } } /** * Lists key records (metadata only — never tokens), newest first. Expired or * catalog-invalid records are skipped. * * @param state - The KV state store holding key records. * @param options - Optional owner filter. * @returns The records. */ export async function list( state: Store.State, options: list.Options = {}, ): Promise { const { keys } = await state.list({ prefix: ApiKey.recordPrefix }) const records: Record[] = [] for (const { name } of keys) { const record = await read(state, name, Date.now(), options.scopeCatalog ?? Scope.catalog) if (record && (!options.orgId || record.orgId === options.orgId)) records.push(record) } // Newest first; ids are random, so sort by creation time. return records.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) } export declare namespace list { /** Options for {@link list}. */ type Options = { /** Restrict results to keys owned by this organization. */ orgId?: string | undefined /** Scope catalog accepted while listing keys. */ scopeCatalog?: Scope.Catalog | undefined } } /** * Lists an organization's key records through the org index (no full scan), * newest first. Dangling index entries (revoked or expired records) are * skipped and lazily deleted. * * @param state - The KV state store holding key records. * @param orgId - The owning organization id (`org_…`). * @param options - Optional environment/project filters. * @returns The records. */ export async function listByOrg( state: Store.State, orgId: string, options: listByOrg.Options = {}, ): Promise { const { keys } = await state.list({ prefix: `${orgPrefix}${orgId}:` }) const records: Record[] = [] for (const { name } of keys) { const recordKey = await state.get(name) const record = recordKey ? await read(state, recordKey, Date.now(), options.scopeCatalog ?? Scope.catalog) : null if (!record) { await state.delete(name) continue } if (options.environment && record.environment !== options.environment) continue if (!options.projectId || record.projectId === options.projectId) records.push(record) } return records.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) } export declare namespace listByOrg { /** Options for {@link listByOrg}. */ type Options = { /** Restrict results to keys in this environment. */ environment?: 'production' | 'sandbox' | undefined /** Restrict results to keys attributed to this project. */ projectId?: string | undefined /** Scope catalog accepted while listing keys. */ scopeCatalog?: Scope.Catalog | undefined } } /** * Revokes a key by id, deleting its record and index entries. * * @param state - The KV state store holding key records. * @param id - The key id (`key_…`). * @returns Whether a matching record was deleted. */ export async function revoke(state: Store.State, id: string): Promise { const recordKey = await state.get(idKeyFor(id)) if (!recordKey) return false // Parse loosely for the org index entry: even a record the catalog no // longer validates must clean up its index on revoke. const raw = await state.get(recordKey) const orgId = (() => { try { return raw ? (JSON.parse(raw) as { orgId?: string }).orgId : undefined } catch { return undefined } })() await state.delete(recordKey) await state.delete(idKeyFor(id)) if (orgId) await state.delete(orgKeyFor(orgId, id)) return true } /** * Updates a key's IP allowlist, display name, and attribution, rewriting its * record in place and moving the org index entry when the org changes. * * @param state - The KV state store holding key records. * @param id - The key id (`key_…`). * @param input - Key metadata to apply. * @param options - Scope catalog used for validation. * @returns The updated record, or `null` when the key is absent. */ export async function update( state: Store.State, id: string, input: update.Input, options: update.Options = {}, ): Promise { if (input.allowedIps !== undefined) { const valid = ApiKey.schema.AllowedIps.safeParse(input.allowedIps) if (!valid.success) throw new Error(`invalid API key input: ${z.prettifyError(valid.error)}`) } const recordKey = await state.get(idKeyFor(id)) if (!recordKey) return null const record = await read(state, recordKey, Date.now(), options.scopeCatalog ?? Scope.catalog) if (!record) return null const updated: Record = { ...record, ...(input.allowedIps === undefined ? {} : { allowedIps: input.allowedIps }), ...(input.name === undefined ? {} : { name: input.name }), ...(input.orgId === undefined ? {} : { orgId: input.orgId }), ...(input.projectId === undefined ? {} : { projectId: input.projectId }), } const ttl = updated.expiresAt === undefined ? undefined : Math.max(1, Date.parse(updated.expiresAt) - Date.now()) await state.put(recordKey, JSON.stringify(updated), { ttl }) if (updated.orgId !== record.orgId) await state.delete(orgKeyFor(record.orgId, id)) await state.put(orgKeyFor(updated.orgId, id), recordKey, { ttl }) return updated } export declare namespace update { /** Metadata applied by {@link update}; omitted fields keep their current value. */ type Input = { /** Replacement client IP/CIDR allowlist. An empty list clears the restriction. */ allowedIps?: readonly string[] | undefined /** New human-readable key name. */ name?: string | undefined /** New owning organization id (`org_…`). */ orgId?: string | undefined /** New attributed project id (`prj_…`). */ projectId?: string | undefined } /** Options for {@link update}. */ type Options = { /** Scope catalog accepted while reading the key. */ scopeCatalog?: Scope.Catalog | undefined } } /** * Re-attributes a key while preserving the existing public API. * * @param state - The KV state store holding key records. * @param id - The key id (`key_…`). * @param input - Attribution to apply. * @param options - Scope catalog used for validation. * @returns The updated record, or `null` when the key is absent. */ export function attribute( state: Store.State, id: string, input: attribute.Input, options: attribute.Options = {}, ): Promise { return update(state, id, input, options) } export declare namespace attribute { /** Attribution applied by {@link attribute}; omitted fields keep their current value. */ type Input = Pick /** Options for reading and updating the key record. */ type Options = update.Options } /** * Stamps `billingActive` onto every key an organization owns in an environment, * re-syncing the snapshot the auth path reads when billing flips (activation or * lapse). Called off the request path from the billing writer, so the hot path * never reads billing state. Records already carrying the target value are left * untouched. * * @param state - The KV state store holding key records. * @param options - Owner, environment, and the new billing-active value. * @returns The number of records updated. */ export async function setBillingActive( state: Store.State, options: setBillingActive.Options, ): Promise { const { active, environment, orgId, scopeCatalog } = options const records = await listByOrg(state, orgId, { environment, scopeCatalog }) let updated = 0 for (const record of records) { if (record.billingActive === active) continue const recordKey = await state.get(idKeyFor(record.id)) if (!recordKey) continue const next: Record = { ...record, billingActive: active } const ttl = next.expiresAt === undefined ? undefined : Math.max(1, Date.parse(next.expiresAt) - Date.now()) await state.put(recordKey, JSON.stringify(next), { ttl }) updated += 1 } return updated } export declare namespace setBillingActive { /** Options for {@link setBillingActive}. */ type Options = { /** The new billing-active value to stamp. */ active: boolean /** Environment whose keys are re-stamped. */ environment: 'production' | 'sandbox' /** Owning organization id (`org_…`). */ orgId: string /** Scope catalog accepted while updating keys. */ scopeCatalog?: Scope.Catalog | undefined } } /** * Rebuilds the `id` and `org` index entries for every stored record — the * run-once backfill making legacy keys visible to org-scoped listing. Records * themselves are never modified, so existing tokens keep resolving. * * @param state - The KV state store holding key records. * @param options - Scope catalog used for validation. * @returns Scanned and indexed record counts. */ export async function backfill( state: Store.State, options: backfill.Options = {}, ): Promise { const { keys } = await state.list({ prefix: ApiKey.recordPrefix }) let indexed = 0 for (const { name } of keys) { const record = await read(state, name, Date.now(), options.scopeCatalog ?? Scope.catalog) if (!record) continue const ttl = record.expiresAt === undefined ? undefined : Math.max(1, Date.parse(record.expiresAt) - Date.now()) await state.put(idKeyFor(record.id), name, { ttl }) await state.put(orgKeyFor(record.orgId, record.id), name, { ttl }) indexed += 1 } return { indexed, scanned: keys.length } } export declare namespace backfill { /** Options for {@link backfill}. */ type Options = { /** Scope catalog accepted while reading keys. */ scopeCatalog?: Scope.Catalog | undefined } /** Result of {@link backfill}. */ type Result = { /** Records whose index entries were written. */ indexed: number /** Stored records scanned (including invalid or expired ones, skipped). */ scanned: number } } /** * Reads and validates a stored record by storage key. Invalid, corrupt, or * expired records (`expiresAt <= now`) resolve to `null`. */ async function read( state: Store.State, key: string, now: number, scopeCatalog: Scope.Catalog, ): Promise { const raw = await state.get(key) if (!raw) return null const value = (() => { try { return JSON.parse(raw) as unknown } catch { return undefined } })() if (value === undefined) return null const parsed = schema.Record.safeParse(StoredRecord.normalizeRateLimits(value)) if (!parsed.success) return null const record = parsed.data if (!validScopes(record.scopes, scopeCatalog)) return null if (record.expiresAt !== undefined && Date.parse(record.expiresAt) <= now) return null return record } namespace StoredRecord { type UnknownRecord = { [key: string]: unknown } /** Normalizes persisted pre-period quotas without accepting legacy fields at the write boundary. */ export function normalizeRateLimits(value: unknown): unknown { if (!value || typeof value !== 'object' || Array.isArray(value)) return value const record = value as UnknownRecord const rateLimits = record['rateLimits'] if (!rateLimits || typeof rateLimits !== 'object' || Array.isArray(rateLimits)) return value return { ...record, rateLimits: Object.fromEntries( Object.entries(rateLimits).map(([scope, rateLimit]) => { if (!rateLimit || typeof rateLimit !== 'object' || Array.isArray(rateLimit)) return [scope, rateLimit] const perMinute = (rateLimit as UnknownRecord)['perMinute'] return [ scope, typeof perMinute === 'number' ? { limit: perMinute, period: 'minute' } : rateLimit, ] }), ), } } } /** Returns whether every granted scope belongs to the configured catalog. */ function validScopes(scopes: readonly string[], scopeCatalog: Scope.Catalog): boolean { return scopes.every((scope) => scope === Scope.wildcard || Scope.includes(scopeCatalog, scope)) } /** Storage-key prefix for the `id → recordKey` index. */ const idPrefix = 'apikey_id:' /** Storage key under which a key's `id → recordKey` index entry is persisted. */ function idKeyFor(id: string): string { return `${idPrefix}${id}` } /** Storage-key prefix for the per-key `org → recordKey` listing index. */ const orgPrefix = 'apikey_org:' /** Storage key under which a key's org index entry is persisted. */ function orgKeyFor(orgId: string, id: string): string { return `${orgPrefix}${orgId}:${id}` }