/** * The resolved API-key resource and the reusable credential primitives. * * Holds the resolved {@link ApiKey} type and its {@link schema}, the token * format/generation ({@link generateToken}/{@link generateId}), the * storage-key derivation ({@link keyFor}/{@link recordPrefix}), and log * {@link redact}ion. These live here — not in the persistence module — so * issued and parsed credentials are byte-identical and cannot drift. The * persisted record/mint-input shapes and provisioning (mint/list/revoke) live * in `./ApiKeys.js`, which composes these primitives with the app's KV state * store (`App.create({ kv })`). */ import { Hash, Hex } from 'ox' import * as z from 'zod/mini' import * as Id from './internal/Id.js' import * as IpAllowlist from './internal/IpAllowlist.js' import * as RateLimit from './internal/RateLimit.js' /** Zod schemas owned by the API-key module. */ export namespace schema { /** Client IP/CIDR rules restricting where a key may be used. */ export const AllowedIps = z .readonly( z.array( z .string() .check( z.maxLength(64), z.refine(IpAllowlist.isRule), z.describe('An exact IPv4/IPv6 address or CIDR range.'), ), ), ) .check( z.maxLength(100), z.describe('Client IP addresses and CIDR ranges restricting where a key may be used.'), ) /** * Canonical schema for resolved API-key metadata, and the single source of * truth for the {@link ApiKey} type. `ApiKeys` derives its persisted * `Record` and `MintInput` schemas from this one, so the resolved and * persisted shapes cannot drift. */ export const ApiKey = z.object({ allowedIps: z ._default(AllowedIps, []) .check( z.describe( 'Client IP addresses and CIDR ranges allowed to use this key. An empty list means unrestricted access.', ), z.meta({ examples: [['203.0.113.0/24', '2001:db8::1']] }), ), billingActive: z .optional(z.boolean()) .check( z.describe( "Whether the owning organization's billing is active for this key's environment. Stored on the record at mint, then separately after billing changes so reconciliation cannot overwrite newer key metadata. Drives the sandbox public-quota throttle; absent or false throttles a sandbox key to the anonymous public quota.", ), ), environment: z ._default(z.enum(['production', 'sandbox']), 'production') .check(z.describe('Key environment.')), id: z.string().check(z.describe('Stable key id.')), name: z.optional(z.string()).check(z.describe('Human-readable key name.')), orgId: z.string().check(z.describe('Owning organization id.')), projectId: z.optional(z.string()).check(z.describe('Attributed project id (`prj_…`).')), rateLimits: z .optional(z.record(z.string(), RateLimit.schema.Limit)) .check( z.describe( "Per-key quota overrides keyed by quota scope. The reserved '*' entry is the per-key default; any other key overrides that scope. Omit to inherit server config. The most specific match wins.", ), ), scopes: z .readonly(z.array(z.string().check(z.minLength(1)))) .check(z.describe("Granted scopes. '*' grants all scopes.")), }) } /** API-key metadata resolved by an auth source. */ export type ApiKey = z.output /** * Replaces every issued token in a string with a masked placeholder so secrets * never reach logs. Apply to anything that may embed a credential — error * stacks, request dumps, and the values of the `tempo-api-key` and * `Authorization` headers (whose tokens match the issued-token pattern). */ export function redact(text: string): string { return text .replace(tokenPattern(), (match) => `${match.split('sk:')[0]}sk:…`) .replace(legacyTokenPattern(), 'tempo_…') } /** * Token prefix per environment. The prefix encodes the environment so a leaked * sandbox token is visibly distinct from a production one; otherwise there is a * single token kind. Prefixes exist purely for recognizability and leak * detection (CI secret scanning, log redaction — see {@link redact}). */ const tokenPrefix = { production: 'tempo:sk:', sandbox: 'tempo_sandbox:sk:', } as const /** * Greppable pattern matching an issued token (`tempo:sk:`/`tempo_sandbox:sk:` + * 48–64 lowercase hex chars), suitable for CI secret scanning and log * redaction. A fresh instance is returned on each call so callers never share * `lastIndex` state. */ function tokenPattern(): RegExp { return /tempo(_sandbox)?:sk:[a-f0-9]{48,64}/g } /** * Greppable pattern matching a deprecated v1 token (`tempo_` + 40 lowercase hex * chars), kept so legacy credentials are redacted from logs with the same * guarantees as current tokens. A fresh instance is returned on each call so * callers never share `lastIndex` state. */ function legacyTokenPattern(): RegExp { return /tempo_[a-f0-9]{40}/g } /** * Generates a new plaintext token (environment prefix + 48 lowercase hex chars * / 192 bits). Shared by every source backend so issued tokens are * format-identical. */ export function generateToken(environment: ApiKey['environment'] = 'production'): string { return `${tokenPrefix[environment]}${Hex.random(24).slice(2)}` } /** Generates a stable, opaque key id (`key_…`). */ export function generateId(): string { return Id.generate('key') } /** Storage-key prefix under which key records are persisted, keyed by token hash. */ export const recordPrefix = 'apikey:' /** Hashes a plaintext token to its at-rest lookup hash (lowercase hex, no `0x`). */ export function hash(token: string): string { return Hash.sha256(Hex.fromString(token)).slice(2) } /** Storage key under which the record for a plaintext token is persisted. */ export function keyFor(token: string): string { return `${recordPrefix}${hash(token)}` }