/** * 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. Snapshotted on the record at mint and re-synced on billing changes, so the auth path needs no billing read. 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)}` }