import type { Kysely } from 'kysely'; import type { ApiKeyRow, ApiKeyScope, Database } from '../db/schema.js'; /** * API keys — the first principal that is not a person. * * They exist because a second deployment cannot read content without one, which is why SCOPE moved * them up from Phase 5 to arrive with the delivery API. The more interesting effect is on the role * model: until now "who is asking" always meant "which user row", and a key is what forces that * question to have a second answer. * * Modelled on `passwordReset.ts` deliberately, down to the hashing helper. `id` **is** the SHA-256 * of the token, so verification is one indexed lookup rather than a scan over rows, and a database * dump contains nothing usable. The raw value exists exactly once — in the response that created * it — and there is no endpoint anywhere that can read it back. * * What a key is *not*: a user with a role. It cannot own content, cannot author a revision, and * must never satisfy a check written as "an editor did this". `hasRole` answers false for one, and * scopes are the only thing it can be asked about. */ /** * Every scope that exists. * * `search:write` is the second, and the first that is not a read — so a key issued for a site now * has a choice to make rather than an obvious answer. It admits appending one row to * `search_queries` and nothing else: it cannot read the log back, cannot delete from it, and * carries no access to content. A site that does not report searches should not be given it. */ export declare const API_KEY_SCOPES: readonly ApiKeyScope[]; export declare class ApiKeyError extends Error { readonly code: 'not_found' | 'invalid_scope' | 'revoked'; name: string; constructor(message: string, code?: 'not_found' | 'invalid_scope' | 'revoked'); } export interface ApiKey extends Omit { scopes: ApiKeyScope[]; } export interface CreatedApiKey { key: ApiKey; /** * The raw token. Exists here and nowhere else — never stored, never logged, never readable again. * * The caller has exactly one chance to show it, which is why the admin screen reveals it through * a short-lived cookie rather than a query string: a URL lands in history, in `Referer`, and in * access logs, and this one carries a live credential. */ token: string; } export declare function createApiKey(db: Kysely, input: { label: string; scopes: ApiKeyScope[]; expiresAt?: string | null; userId?: string | null; }): Promise; /** * The key a presented token names, if it is currently usable. * * Returns `undefined` for absent, malformed, unknown, revoked, and expired alike. The caller must * answer identically to all five: distinguishing "no such key" from "revoked key" tells whoever is * probing which of their guesses was once real. * * **This runs per request and is deliberately not memoised.** It looks like an obvious cache — one * indexed lookup by primary key, the same answer every time, on the hot path of every delivery * read — and a per-isolate cache with a short TTL was measured against and rejected. What it buys * is one row read out of roughly ten for a page, on requests that a working edge cache means the * Worker never sees at all. What it costs is that **revoking a key stops taking effect * immediately**: revocation is the one control a site has when a key leaks, and a window in which * a revoked credential still resolves is a strictly worse trade than a row read. * * `selectAll()` is likewise not a projection worth narrowing. D1 bills rows scanned, this is a * single row found by primary key, and every column is used by `hydrate`. */ export declare function verifyApiKey(db: Kysely, token: string | null | undefined): Promise; /** * Note that a key was used, at minute resolution. * * Coarse on purpose. The question this column answers is "is anything still using this key", asked * before revoking one — and a write per request would put a database round trip on the hot path of * every delivery read to sharpen an answer nobody needs to the second. * * Never throws, for the same reason `recordAuditEntry` never does: the request it describes has * already been authorised, and failing it here would turn a bookkeeping problem into a 500. */ export declare function touchApiKey(db: Kysely, key: ApiKey): Promise; export declare function listApiKeys(db: Kysely): Promise; export declare function getApiKey(db: Kysely, id: string): Promise; /** * Revoke a key. Not a delete. * * The audit log records that a key was created and by whom, and entries name it by id; deleting the * row would leave those pointing at something nothing can resolve. Same reasoning as deactivating a * user rather than removing them. * * Conditional on it not already being revoked, and the row count is checked, so revoking twice does * not move the timestamp — the moment access ended is a fact, and a second click should not rewrite * it. */ export declare function revokeApiKey(db: Kysely, id: string): Promise; /** Whether this key carries a scope. The only question a key can be asked. */ export declare function apiKeyHasScope(key: ApiKey | undefined, scope: ApiKeyScope): boolean; /** * Read a bearer token out of an `authorization` header. * * Only `Bearer`, and only an exact prefix match. Accepting a bare token or a case-insensitive * scheme would widen what counts as a credential for no benefit. */ export declare function bearerToken(header: string | null | undefined): string | undefined;