diff --git a/CHANGELOG.md b/CHANGELOG.md index bca360ef..6f208e3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.31.0] — 2026-10-15 + +### Added + +- A purged or deleted key keeps its attribution (#224). Migration `0036_key_tombstones` adds `api_key_tombstones` (`id, name, prefix, owner, owner_kind, tool, purged_at`; no value or ciphertext column). The janitor's revoked-key purge deletes the key and writes its tombstone in one statement, and `DELETE /api/admin/keys/:id` does the same in one statement, both stamped from the injected clock. The usage label loader names a key by its live row, else its tombstone, so `byKey` and the recent-attempts key subject keep the name (and the loader carries owner, owner kind and tool) instead of showing "deleted". The janitor sweeps tombstones at `RETENTION_USAGE_DAILY_DAYS` plus one day, since rollups age by whole UTC day and the tombstone must outlive the rollups it labels. Verification, reveal and the admin key list never read tombstones; the `GET /api/admin/usage` wire shape is unchanged. Keys purged before this release have no tombstone and stay "deleted"; there is no backfill. + ## [2.30.1] — 2026-10-15 ### Documentation @@ -1245,3 +1251,4 @@ your tooling config. [2.29.0]: https://github.com/developerz-ai/multi-ai-router/releases/tag/v2.29.0 [2.30.0]: https://github.com/developerz-ai/multi-ai-router/releases/tag/v2.30.0 [2.30.1]: https://github.com/developerz-ai/multi-ai-router/releases/tag/v2.30.1 +[2.31.0]: https://github.com/developerz-ai/multi-ai-router/releases/tag/v2.31.0 diff --git a/CLAUDE.md b/CLAUDE.md index 9834598e..3ebac443 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Self-hosted API router. A team of developers and AI agents shares one pool of AI Pooling is the product: **many Accounts of the same Provider is the normal case** (five Claude subs side by side). Nothing in the schema, UI, or routing may assume one account per provider. -Full data plane shipped: accounts, pools, keys, `/v1/messages` + `/v1/chat/completions` + `/v1/responses`, cross-dialect translation, Claude subscriptions via the Agent SDK, ChatGPT/Codex OAuth, admin CRUD, 36 migrations (through 0035), and a complete SolidJS operator console. See `docs/idea/10-roadmap.md` for per-milestone state. Spec: `docs/idea/`. Behavior change → update the spec in the same PR. Shared code inventory: `docs/reusable-code.md`. +Full data plane shipped: accounts, pools, keys, `/v1/messages` + `/v1/chat/completions` + `/v1/responses`, cross-dialect translation, Claude subscriptions via the Agent SDK, ChatGPT/Codex OAuth, admin CRUD, 37 migrations (through 0036), and a complete SolidJS operator console. See `docs/idea/10-roadmap.md` for per-milestone state. Spec: `docs/idea/`. Behavior change → update the spec in the same PR. Shared code inventory: `docs/reusable-code.md`. ## Response Rules diff --git a/Dockerfile b/Dockerfile index 52aa0ec7..4aa009bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -205,7 +205,7 @@ LABEL org.opencontainers.image.title="multi-ai-router" \ org.opencontainers.image.url="https://github.com/developerz-ai/multi-ai-router" \ org.opencontainers.image.documentation="https://github.com/developerz-ai/multi-ai-router/blob/main/README.md" \ org.opencontainers.image.licenses="MIT" \ - org.opencontainers.image.version="2.30.1" \ + org.opencontainers.image.version="2.31.0" \ org.opencontainers.image.revision="${ROUTER_REVISION}" # ---- PID 1 ---- diff --git a/apps/api/package.json b/apps/api/package.json index cec9d567..d45f8e9f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@multi-ai-router/api", - "version": "2.30.1", + "version": "2.31.0", "private": true, "type": "module", "main": "src/main.ts", diff --git a/apps/api/src/composition/admin.ts b/apps/api/src/composition/admin.ts index b61bafc6..7b9d13f9 100644 --- a/apps/api/src/composition/admin.ts +++ b/apps/api/src/composition/admin.ts @@ -2,6 +2,7 @@ import { type AccountRepository, type AdminCredentialRepository, type ApiKeyRepository, + type ApiKeyTombstoneRepository, type AuditRepository, type CredentialRefreshLock, createAdminMutationRepository, @@ -100,6 +101,7 @@ export interface AdminPlaneDeps { readonly accounts: AccountRepository readonly refreshLock: Pick readonly keys: ApiKeyRepository + readonly keyTombstones: Pick readonly pools: PoolRepository readonly auditEvents: AuditRepository readonly oauthStates: OauthStateRepository @@ -165,7 +167,8 @@ export interface AdminPlane { } export function createAdminPlane(deps: AdminPlaneDeps): AdminPlane { - const { env, logger, now, accounts, keys, cipher, catalog, health, configDirs } = deps + const { env, logger, now, accounts, keys, keyTombstones, cipher, catalog, health, configDirs } = + deps const audit = createAuditRecorder(deps.auditEvents) const mutations = createAdminMutationRepository(deps.database) @@ -388,7 +391,7 @@ export function createAdminPlane(deps: AdminPlaneDeps): AdminPlane { usage: createUsageService({ history: createUsageHistoryRepository(deps.database), recent: createUsageRecentRepository(deps.database), - labels: catalogLabels({ keys, catalog }), + labels: catalogLabels({ keys, tombstones: keyTombstones, catalog }), ...env.usageRead, now, }), diff --git a/apps/api/src/composition/index.ts b/apps/api/src/composition/index.ts index 5d87bbe9..94dd6a1f 100644 --- a/apps/api/src/composition/index.ts +++ b/apps/api/src/composition/index.ts @@ -24,6 +24,7 @@ export function createRuntime(deps: RuntimeDeps): Runtime { accounts, refreshLock, keys, + keyTombstones, pools, auditEvents, oauthStates, @@ -75,6 +76,7 @@ export function createRuntime(deps: RuntimeDeps): Runtime { refreshLock, recovery: recoveryComponents, keys, + keyTombstones, pools, auditEvents, oauthStates, diff --git a/apps/api/src/composition/runtime-repositories.ts b/apps/api/src/composition/runtime-repositories.ts index 4f786ef6..303d0f83 100644 --- a/apps/api/src/composition/runtime-repositories.ts +++ b/apps/api/src/composition/runtime-repositories.ts @@ -2,6 +2,7 @@ import { createAccountRepository, createAdminCredentialRepository, createApiKeyRepository, + createApiKeyTombstoneRepository, createAuditRepository, createCredentialRefreshLockPool, createModelCatalogRepository, @@ -21,6 +22,7 @@ export interface RuntimeRepositories { readonly schedulerLock: ReturnType readonly accounts: ReturnType readonly keys: ReturnType + readonly keyTombstones: ReturnType readonly pools: ReturnType readonly auditEvents: ReturnType readonly usageRecords: ReturnType @@ -51,6 +53,7 @@ export function createRuntimeRepositories(deps: RuntimeDeps): RuntimeRepositorie recoveryCooldownMs: env.recovery.cooldownMs, }) const keys = createApiKeyRepository(database) + const keyTombstones = createApiKeyTombstoneRepository(database) const pools = createPoolRepository(database) const auditEvents = createAuditRepository(database) const usageRecords = createUsageRecordRepository(database) @@ -76,6 +79,7 @@ export function createRuntimeRepositories(deps: RuntimeDeps): RuntimeRepositorie schedulerLock, accounts, keys, + keyTombstones, pools, auditEvents, usageRecords, diff --git a/apps/api/src/composition/runtime-scheduler.ts b/apps/api/src/composition/runtime-scheduler.ts index bfb97930..4b99ebfa 100644 --- a/apps/api/src/composition/runtime-scheduler.ts +++ b/apps/api/src/composition/runtime-scheduler.ts @@ -24,7 +24,7 @@ export function createRuntimeScheduler( sessions, usageRecords, auditEvents, - keys, + keyTombstones, oauthStates, usageHistory, accounts, @@ -44,7 +44,7 @@ export function createRuntimeScheduler( sessions, usageRecords, auditEvents, - apiKeys: keys, + keyTombstones, oauthStates, history: usageHistory, accounts, diff --git a/apps/api/src/scheduler/tasks/index.ts b/apps/api/src/scheduler/tasks/index.ts index fe825a28..d95d0460 100644 --- a/apps/api/src/scheduler/tasks/index.ts +++ b/apps/api/src/scheduler/tasks/index.ts @@ -1,7 +1,7 @@ import type { AccountRepository, AccountRow, - ApiKeyRepository, + ApiKeyTombstoneRepository, AuditRepository, ModelCatalogRepository, OauthStateRepository, @@ -51,7 +51,7 @@ export interface ScheduledTaskDeps readonly sessions: Pick readonly usageRecords: Pick readonly auditEvents: Pick - readonly apiKeys: Pick + readonly keyTombstones: Pick readonly oauthStates: Pick readonly history: Pick /** @@ -175,7 +175,7 @@ export function createScheduledTasks(deps: ScheduledTaskDeps): readonly Schedule history: deps.history, auditEvents: deps.auditEvents, taskRuns: deps.scheduledTasks, - apiKeys: deps.apiKeys, + keyTombstones: deps.keyTombstones, retention: env.retention, intervalMs: intervals.janitor_sweep, batchSize, diff --git a/apps/api/src/scheduler/tasks/janitor.ts b/apps/api/src/scheduler/tasks/janitor.ts index 75a3d165..b4f6fe44 100644 --- a/apps/api/src/scheduler/tasks/janitor.ts +++ b/apps/api/src/scheduler/tasks/janitor.ts @@ -1,5 +1,5 @@ import type { - ApiKeyRepository, + ApiKeyTombstoneRepository, AuditRepository, ScheduledTaskRepository, SessionRepository, @@ -11,21 +11,23 @@ import type { ScheduledTask } from "../types" import { runSweeps } from "./sweep" /** - * The janitor: the retention sweep for the six tables that grow without bound. + * The janitor: the retention sweep for the seven tables that grow without bound. * - * One task rather than six, because they share a schedule and an operator reads + * One task rather than seven, because they share a schedule and an operator reads * them as one answer — "the janitor ran 4 min ago and deleted 812 rows" * (docs/idea/09-deployment.md, "Cleanups & retention"). The per-category counts * are in the summary line, so "deleted 812 rows" can still be broken down. * * **Every window is `env.retention`, never a number in this file** (CLAUDE.md * non-negotiable 11). What is decided here is only the *order*, and it is - * deliberate: revoked keys go last, because deleting one cascades to its - * sessions and nulls the key on its usage rows, and doing that first would make - * the sweeps in front of it race their own cascade for no reason. + * deliberate: revoked keys go after the rest, because deleting one cascades to its + * sessions and scope rows (its usage rows keep the id, and a tombstone written in the same + * statement keeps the name), and doing that first would make the sweeps in front of it race their + * own cascade for no reason. The key-tombstone sweep goes last: it touches only its own table, and + * a tombstone written by this very tick's purge is far too young to be swept. * - * Two of the six grow slowly enough to have been overlooked rather than - * excluded, and both are here for the same reason the other four are: `usage_daily` + * Two of the seven grow slowly enough to have been overlooked rather than + * excluded, and both are here for the same reason the other five are: `usage_daily` * banks one row per distinct `(day, key, account, pool, model)` and outlives the * raw rows by design, which is not the same as outliving them forever; and * `scheduled_task_runs` takes a row per task per tick, about a thousand a day, @@ -48,7 +50,7 @@ export interface JanitorDeps { readonly history: Pick readonly auditEvents: Pick readonly taskRuns: Pick - readonly apiKeys: Pick + readonly keyTombstones: Pick /** Every retention window, straight from `env.retention`. */ readonly retention: RetentionConfig /** `JANITOR_INTERVAL_MINUTES`, in milliseconds. The runner jitters it. */ @@ -75,6 +77,10 @@ export function createJanitorTask(deps: JanitorDeps): ScheduledTask { const staleAudit = before(now, retention.auditDays * DAY_MS) const staleTaskRuns = before(now, retention.taskRunsDays * DAY_MS) const purgeableKeys = before(now, retention.revokedKeysDays * DAY_MS) + // A tombstone labels rollups, so it lives as long as they do — plus one day, because rollups + // age by whole UTC day: the purge day's own rollup is still inside the window for up to a day + // after `purged_at + usageDailyDays`. + const staleTombstones = before(now, (retention.usageDailyDays + 1) * DAY_MS) const report = await runSweeps( [ @@ -109,7 +115,12 @@ export function createJanitorTask(deps: JanitorDeps): ScheduledTask { }, { category: "revokedKeys", - deleteBatch: (limit) => deps.apiKeys.deleteRevokedOlderThan(purgeableKeys, limit), + // Delete and tombstone are one statement; `purged_at` is this tick's clock. + deleteBatch: (limit) => deps.keyTombstones.purgeRevoked(purgeableKeys, now, limit), + }, + { + category: "keyTombstones", + deleteBatch: (limit) => deps.keyTombstones.deleteOlderThan(staleTombstones, limit), }, ], { batchSize: deps.batchSize, signal }, diff --git a/apps/api/src/services/keys/mutations.ts b/apps/api/src/services/keys/mutations.ts index 70d49967..5b2174b9 100644 --- a/apps/api/src/services/keys/mutations.ts +++ b/apps/api/src/services/keys/mutations.ts @@ -156,7 +156,10 @@ export function createKeyMutations(deps: KeysServiceDeps): Pick { const current = await tx.keys.findById(id) if (current === undefined) return notFound(`no key with id "${id}"`) - if (!(await tx.keys.delete(id))) return notFound(`no key with id "${id}"`) + // One statement deletes the row and writes its tombstone (the key's name outlives its + // row for the rollups). The row is already locked FOR UPDATE by `run`. + if (!(await tx.tombstones.deleteKey(id, deps.now()))) + return notFound(`no key with id "${id}"`) await createAuditRecorder(tx.audit).record({ kind: AUDIT_KINDS.keyDeleted, subjectType: AUDIT_SUBJECTS.key, diff --git a/apps/api/src/services/usage-read/labels.ts b/apps/api/src/services/usage-read/labels.ts index 40b3fc07..c667e901 100644 --- a/apps/api/src/services/usage-read/labels.ts +++ b/apps/api/src/services/usage-read/labels.ts @@ -1,24 +1,48 @@ -import type { ApiKeyRepository } from "@multi-ai-router/db" +import type { ApiKeyRepository, ApiKeyTombstoneRepository } from "@multi-ai-router/db" import type { RoutingCatalogStore } from "../catalog" -import type { UsageLabelSets } from "./service" +import type { KeyLabel, UsageLabelSets } from "./service" /** * Naming the subjects of a usage report, from what the router already has. * * Accounts and pools come from the warm catalog, so the two axes an operator filters on cost - * nothing; keys need the one query, which is unremarkable on the admin plane and is why this is not - * a third cache. A miss is not an error — spend that happened is still spend, and a deleted subject - * keeps its totals under its id. + * nothing; keys need two queries, which is unremarkable on the admin plane and is why this is not + * a third cache. A miss is not an error — spend that happened is still spend, and a subject with + * no name anywhere keeps its totals under its id. + * + * A key is named by its live row, else by the tombstone its purge or delete left behind (the + * rollups outlive the row). Tombstones are read through their own method and merged here, never + * unioned into `keys.list()`: that list is the admin keys list, which must not show a purged key. + * + * The two reads are not one snapshot. A delete committing between them removes the row after + * `keys.list()` saw it, so for that one load the key is still named from the live read; a delete + * committing the other way round (row read as gone, tombstone read before the commit lands) shows + * a transient "deleted" for that key until the next load. Neither is worse than the label being + * decoration, so it is not worth a transaction. */ export function catalogLabels(deps: { readonly keys: Pick + readonly tombstones: Pick readonly catalog: Pick }): () => Promise { - return async () => ({ - keys: new Map((await deps.keys.list()).map((key) => [key.id, key.name])), - accounts: new Map( - deps.catalog.accounts().map((account) => [account.id, account.snapshot.label]), - ), - pools: new Map(deps.catalog.pools().map((pool) => [pool.id, pool.name])), - }) + return async () => { + const [live, stones] = await Promise.all([deps.keys.list(), deps.tombstones.listAll()]) + const keys = new Map() + // Tombstones first, so a live row wins if the two ever coexist (a delete racing a read). + for (const row of [...stones, ...live]) { + keys.set(row.id, { + name: row.name, + owner: row.owner, + ownerKind: row.ownerKind, + tool: row.tool, + }) + } + return { + keys, + accounts: new Map( + deps.catalog.accounts().map((account) => [account.id, account.snapshot.label]), + ), + pools: new Map(deps.catalog.pools().map((pool) => [pool.id, pool.name])), + } + } } diff --git a/apps/api/src/services/usage-read/recent.ts b/apps/api/src/services/usage-read/recent.ts index b35c4c6b..5cc606c8 100644 --- a/apps/api/src/services/usage-read/recent.ts +++ b/apps/api/src/services/usage-read/recent.ts @@ -2,7 +2,7 @@ import type { Dialect, EgressMode, ProviderId, UsageFault } from "@multi-ai-rout import { isSuccessOutcome, UsageOutcome, usageOutcomeFault } from "@multi-ai-router/core" import type { RecentAttemptRow } from "@multi-ai-router/db" import { z } from "zod" -import type { UsageLabelSets } from "./service" +import type { DisplayNames } from "./types" /** * The live request feed's contract: what the console may ask for, and what one @@ -93,7 +93,7 @@ export function outcomesFor(query: RecentQuery): readonly UsageOutcome[] | undef * served, the pool it was drawn from. * * Same three fields and same rules as a breakdown row: a label may be missing - * because the subject was deleted (usage rows outlive what they name) or because + * because the subject was deleted (usage rows outlive what they name; for a key, no live row and no tombstone) or because * the dimension did not apply (a key scoped `all` was placed by no pool), and * the reason is on the row so the console never has to guess which. */ @@ -150,7 +150,7 @@ export interface RecentView { export function toRecentAttemptView( row: RecentAttemptRow, - labels: UsageLabelSets, + labels: DisplayNames, ): RecentAttemptView { return { id: row.id, diff --git a/apps/api/src/services/usage-read/service.ts b/apps/api/src/services/usage-read/service.ts index 22fe88a2..e57540b4 100644 --- a/apps/api/src/services/usage-read/service.ts +++ b/apps/api/src/services/usage-read/service.ts @@ -2,11 +2,13 @@ import type { UsageHistoryRepository, UsageRecentRepository } from "@multi-ai-ro import { type AdminResult, ok } from "../admin/result" import { outcomesFor, type RecentQuery, type RecentView, toRecentAttemptView } from "./recent" import { readSummary } from "./summary" +import { keyNames } from "./summary-view" import type { UsageWindowQuery } from "./window" /** Summary facts share one database snapshot; labels are current best-effort decoration. */ export type { + KeyLabel, UsageBreakdownRow, UsageCoverage, UsageLabelSets, @@ -54,11 +56,14 @@ export function createUsageService(deps: UsageServiceDeps): UsageService { }), ]) + // Projected once, not per row: a poll costs O(rows + labels). + const names = { keys: keyNames(labels.keys), accounts: labels.accounts, pools: labels.pools } + // `limit` is echoed as asked for, never as `rows.length`: a quiet router // returning four rows has truncated nothing, and a caption reading "at // most 4" would be describing the traffic rather than the page. return ok({ - attempts: rows.map((row) => toRecentAttemptView(row, labels)), + attempts: rows.map((row) => toRecentAttemptView(row, names)), limit: query.limit, }) }, diff --git a/apps/api/src/services/usage-read/summary-view.ts b/apps/api/src/services/usage-read/summary-view.ts index ab94db7a..076995c2 100644 --- a/apps/api/src/services/usage-read/summary-view.ts +++ b/apps/api/src/services/usage-read/summary-view.ts @@ -1,6 +1,6 @@ import type { UsageGroupRow, UsageGroupSeriesPoint, UsageSeriesPoint } from "@multi-ai-router/db" import { densify } from "./axis" -import type { UsageBreakdownRow, UsageSeriesEntry } from "./types" +import type { KeyLabel, UsageBreakdownRow, UsageSeriesEntry } from "./types" /** * Puts the summary series on the same axis every breakdown row uses. @@ -25,6 +25,11 @@ export function alignSeries( }) } +/** Projects the loader's richer key entries to the display name the wire shape carries. */ +export function keyNames(keys: ReadonlyMap): ReadonlyMap { + return new Map([...keys].map(([id, key]) => [id, key.name])) +} + export function label( rows: readonly UsageGroupRow[], names: ReadonlyMap, diff --git a/apps/api/src/services/usage-read/summary.ts b/apps/api/src/services/usage-read/summary.ts index 63aa04f9..e3d43ad8 100644 --- a/apps/api/src/services/usage-read/summary.ts +++ b/apps/api/src/services/usage-read/summary.ts @@ -5,7 +5,7 @@ import type { } from "@multi-ai-router/db" import { axisBucketWidth, buildAxis } from "./axis" import { foldFailures } from "./failures" -import { alignSeries, breakdownRow, label } from "./summary-view" +import { alignSeries, breakdownRow, keyNames, label } from "./summary-view" import type { UsageLabelSets, UsageSummary } from "./types" import { resolveWindow, type UsageWindowQuery } from "./window" @@ -112,7 +112,7 @@ export async function readSummary( }, axis, series: alignSeries(axis, series), - byKey: label(keys ?? [], deps.labels.keys, axis, keySeries ?? []), + byKey: label(keys ?? [], keyNames(deps.labels.keys), axis, keySeries ?? []), byAccount: label(accounts ?? [], deps.labels.accounts, axis, accountSeries ?? []), byPool: label(pools ?? [], deps.labels.pools, axis, poolSeries ?? []), byModel: (models ?? []).map((row) => ({ diff --git a/apps/api/src/services/usage-read/types.ts b/apps/api/src/services/usage-read/types.ts index 57153236..d860c454 100644 --- a/apps/api/src/services/usage-read/types.ts +++ b/apps/api/src/services/usage-read/types.ts @@ -1,3 +1,4 @@ +import type { KeyOwnerKind } from "@multi-ai-router/core" import type { UsageTotals } from "@multi-ai-router/db" import type { UsageFailures } from "./failures" @@ -39,7 +40,7 @@ export interface UsageSeriesEntry { export interface UsageBreakdownRow { readonly id: string | null - /** Human name. `null` when the subject is gone or the dimension did not apply. */ + /** Human name. `null` when the subject is gone (no live row and, for a key, no tombstone) or the dimension did not apply. */ readonly label: string | null /** Why the label is absent, so the console never has to guess. */ readonly note: "deleted" | "none" | null @@ -52,6 +53,24 @@ export interface UsageBreakdownRow { readonly series: readonly number[] } +/** + * What the loader knows about a key: read from the live row, else its tombstone. Only `name` + * reaches the wire in this slice; owner, kind and tool ride along for the consumers that follow. + */ +export interface KeyLabel { + readonly name: string + readonly owner: string | null + readonly ownerKind: KeyOwnerKind | null + readonly tool: string | null +} + +/** `UsageLabelSets` projected to the names the wire carries; built once per request. */ +export interface DisplayNames { + readonly keys: ReadonlyMap + readonly accounts: ReadonlyMap + readonly pools: ReadonlyMap +} + /** * Display names for every id a breakdown can carry, read once per summary. * @@ -59,7 +78,7 @@ export interface UsageBreakdownRow { * separately is how an admin screen quietly becomes N queries. */ export interface UsageLabelSets { - readonly keys: ReadonlyMap + readonly keys: ReadonlyMap readonly accounts: ReadonlyMap readonly pools: ReadonlyMap } diff --git a/apps/api/test/integration/key-tombstones.test.ts b/apps/api/test/integration/key-tombstones.test.ts new file mode 100644 index 00000000..d8e22397 --- /dev/null +++ b/apps/api/test/integration/key-tombstones.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, test } from "bun:test" +import { routerKeyDisplayPrefix } from "@multi-ai-router/core" +import { + createAdminMutationRepository, + createApiKeyRepository, + createApiKeyTombstoneRepository, + createAuditRepository, + createUsageRecentRepository, +} from "@multi-ai-router/db" +import { + historyAttempt, + historyTerminal, + historyUrl, + usageHistoryFixture, +} from "../../../../packages/db/test/integration/usage-history-fixture" +import type { RetentionConfig } from "../../src/config/env" +import { createJanitorTask } from "../../src/scheduler" +import { createAuditRecorder } from "../../src/services/admin/audit" +import { createKeysService } from "../../src/services/keys" +import { catalogLabels, createUsageService } from "../../src/services/usage-read" + +/** + * Attribution outlives the key row (issue #224). Real Postgres, real rollup path, real janitor and + * real operator delete: usage is recorded at real time and ONLY the janitor tick is advanced, past + * RETENTION_REVOKED_KEYS_DAYS but inside the raw-usage window, so the raw rows feeding the + * recent-attempts leg survive alongside the rollup. + */ +const fixture = usageHistoryFixture() + +const RETENTION: RetentionConfig = { + sessionsHours: 24, + usageDays: 90, + usageDailyDays: 730, + auditDays: 365, + taskRunsDays: 30, + revokedKeysDays: 30, + oauthStateMinutes: 10, + orphanConfigDirHours: 24, +} +const DAY = 86_400_000 +const none = async () => 0 + +function janitor() { + const db = fixture.db() + return createJanitorTask({ + sessions: { deleteIdleBefore: none }, + usageRecords: { deleteRetainedBatch: none }, + history: { deleteRetainedHistory: none }, + auditEvents: { deleteOlderThan: none }, + taskRuns: { deleteOlderThan: none }, + keyTombstones: createApiKeyTombstoneRepository(db), + retention: RETENTION, + intervalMs: 60_000, + batchSize: 100, + }) +} + +async function tick(now: Date) { + return janitor().run({ + now, + logger: { info() {}, warn() {}, error() {}, debug() {} } as never, + signal: new AbortController().signal, + }) +} + +function services(nowMs: () => Date) { + const db = fixture.db() + const keys = createApiKeyRepository(db) + const tombstones = createApiKeyTombstoneRepository(db) + const keysService = createKeysService({ + keys, + mutations: createAdminMutationRepository(db), + onCommitted: () => {}, + cipher: { encrypt: (s) => `enc:${s}`, decrypt: (s) => s.replace(/^enc:/, "") }, + audit: createAuditRecorder(createAuditRepository(db)), + now: nowMs, + }) + const usage = createUsageService({ + history: fixture.history(), + recent: createUsageRecentRepository(db), + labels: catalogLabels({ + keys, + tombstones, + catalog: { accounts: () => [], pools: () => [] }, + }), + now: nowMs, + breakdownMaxRows: 100, + }) + return { keys, tombstones, keysService, usage } +} + +async function recordUsage(apiKeyId: string) { + const attempt = historyAttempt({ + apiKeyId, + createdAt: new Date(Date.now() - 3_000), + model: "tomb-model", + }) + await fixture.usage().insertBatch({ + attempts: [attempt], + terminals: [historyTerminal(attempt, { settledAt: new Date(Date.now() - 2_000) })], + }) +} + +async function readKeyRow(usage: ReturnType["usage"], id: string) { + const summary = await usage.summary({ window: "lifetime" } as never) + if (!summary.ok) throw new Error("summary failed") + const recent = await usage.recent({ limit: 50 } as never) + if (!recent.ok) throw new Error("recent failed") + return { + byKey: summary.value.byKey.find((row) => row.id === id), + subject: recent.value.attempts.find((a) => a.key.id === id)?.key, + } +} + +describe.skipIf(!historyUrl)("a purged key keeps its attribution", () => { + test("janitor purge: byKey and the recent subject keep the name, the loader keeps owner/ownerKind/tool", async () => { + const { keysService, keys, tombstones, usage } = services(() => new Date()) + const created = await keysService.create({ + name: "alice/editor", + owner: "alice", + ownerKind: "person", + tool: "editor", + } as never) + if (!created.ok) throw new Error("create failed") + const id = created.value.id + await recordUsage(id) + const revoked = await keysService.revoke(id) + expect(revoked.ok).toBe(true) + + // Revoked just now; the tick is 40 days on: past revokedKeysDays (30), inside usageDays (90). + const tickAt = new Date(Date.now() + 40 * DAY) + const swept = await tick(tickAt) + expect(swept.outcome).toBe("success") + expect(await keys.findById(id)).toBeUndefined() + + const { byKey, subject } = await readKeyRow(usage, id) + expect(byKey).toMatchObject({ id, label: "alice/editor", note: null }) + expect(subject).toEqual({ id, label: "alice/editor", note: null }) + + const loaded = await catalogLabels({ + keys, + tombstones, + catalog: { accounts: () => [], pools: () => [] }, + })() + expect(loaded.keys.get(id)).toEqual({ + name: "alice/editor", + owner: "alice", + ownerKind: "person", + tool: "editor", + }) + // One clock: purged_at is the janitor tick's date, not the database's now(). + expect((await tombstones.listAll()).find((row) => row.id === id)?.purgedAt).toEqual(tickAt) + }) + + test("operator delete writes the same tombstone, and the label survives", async () => { + const deletedAt = new Date(Date.now() - 1000) + const { keysService, keys, usage, tombstones } = services(() => deletedAt) + const created = await keysService.create({ + name: "ci/agent", + owner: "ci", + ownerKind: "service", + tool: "agent", + } as never) + if (!created.ok) throw new Error("create failed") + const id = created.value.id + await recordUsage(id) + + const removed = await keysService.remove(id) + expect(removed.ok).toBe(true) + expect(await keys.findById(id)).toBeUndefined() + + const { byKey, subject } = await readKeyRow(usage, id) + expect(byKey).toMatchObject({ id, label: "ci/agent", note: null }) + expect(subject).toMatchObject({ id, label: "ci/agent", note: null }) + const loaded = await catalogLabels({ + keys, + tombstones, + catalog: { accounts: () => [], pools: () => [] }, + })() + expect(loaded.keys.get(id)).toEqual({ + name: "ci/agent", + owner: "ci", + ownerKind: "service", + tool: "agent", + }) + // One clock on the operator path too: purged_at is the service's injected now. + expect((await tombstones.listAll()).find((row) => row.id === id)?.purgedAt).toEqual(deletedAt) + }) + + test("a tombstone survives inside the usage-daily window and is swept once older", async () => { + const { keysService, tombstones } = services(() => new Date()) + const created = await keysService.create({ name: "window/key" } as never) + if (!created.ok) throw new Error("create failed") + const id = created.value.id + const t0 = new Date() + await keysService.remove(id) + + await tick(new Date(t0.getTime() + 700 * DAY)) + expect((await tombstones.listAll()).some((row) => row.id === id)).toBe(true) + + // 730 days of rollups plus the one-day margin: still kept at +731, swept past it. + await tick(new Date(t0.getTime() + 731 * DAY)) + expect((await tombstones.listAll()).some((row) => row.id === id)).toBe(true) + await tick(new Date(t0.getTime() + 732 * DAY)) + expect((await tombstones.listAll()).some((row) => row.id === id)).toBe(false) + }) + + for (const path of ["operator delete", "janitor purge"] as const) { + test(`verification, reveal and the admin list never see a tombstone (${path})`, async () => { + const { keysService, keys, tombstones } = services(() => new Date()) + const created = await keysService.create({ name: `gone/${path.replace(" ", "-")}` } as never) + if (!created.ok) throw new Error("create failed") + const { id, value } = created.value + // The stored prefix is the 17-char display prefix, so the lookup must use the same function. + const prefix = routerKeyDisplayPrefix(value) + if (prefix === null) throw new Error("no display prefix") + + // Positive control: the lookup and the list DO find the key before it goes, so the empty + // results below mean "gone", not "wrong prefix". + expect((await keys.findUsableByPrefix(prefix, new Date())).map((row) => row.id)).toEqual([id]) + const before = await keysService.list() + if (!before.ok) throw new Error("list failed") + expect(before.value.some((view) => view.id === id)).toBe(true) + + if (path === "operator delete") { + expect((await keysService.remove(id)).ok).toBe(true) + } else { + expect((await keysService.revoke(id)).ok).toBe(true) + await tick(new Date(Date.now() + 40 * DAY)) + } + // The tombstone exists, and still nothing but the label loader can see it. + expect((await tombstones.listAll()).some((row) => row.id === id)).toBe(true) + + expect(await keys.findUsableByPrefix(prefix, new Date())).toEqual([]) + const revealed = await keysService.reveal(id) + expect(revealed.ok).toBe(false) + if (!revealed.ok) + expect([revealed.failure.status, revealed.failure.code]).toEqual([404, "not_found"]) + const listed = await keysService.list() + if (!listed.ok) throw new Error("list failed") + expect(listed.value.some((view) => view.id === id)).toBe(false) + }) + } + + test("a key with no row and no tombstone is still reported deleted", async () => { + const { usage } = services(() => new Date()) + const ghost = crypto.randomUUID() + await recordUsage(ghost) + const { byKey, subject } = await readKeyRow(usage, ghost) + expect(byKey).toMatchObject({ id: ghost, label: null, note: "deleted" }) + expect(subject).toEqual({ id: ghost, label: null, note: "deleted" }) + }) +}) diff --git a/apps/api/test/integration/usage-admin.test.ts b/apps/api/test/integration/usage-admin.test.ts index b4c38a06..dc5355a6 100644 --- a/apps/api/test/integration/usage-admin.test.ts +++ b/apps/api/test/integration/usage-admin.test.ts @@ -71,7 +71,7 @@ function harness(rows: readonly RecentAttemptRow[] = [attemptRow()], guard = stu recent: { recent: async () => [...rows] }, labels: async () => ({ - keys: new Map([["key-1", "dev-laptops"]]), + keys: new Map([["key-1", { name: "dev-laptops", owner: null, ownerKind: null, tool: null }]]), accounts: new Map([["acct-1", "claude-max-01"]]), pools: new Map(), }), diff --git a/apps/api/test/support/admin-mutation-database.ts b/apps/api/test/support/admin-mutation-database.ts index deef3bf3..d78adcae 100644 --- a/apps/api/test/support/admin-mutation-database.ts +++ b/apps/api/test/support/admin-mutation-database.ts @@ -4,6 +4,7 @@ import { createAccountRepository, createAdminMutationRepository, createApiKeyRepository, + createApiKeyTombstoneRepository, createAuditRepository, createDatabase, createPoolRepository, @@ -17,6 +18,7 @@ export function adminMutationDatabase(url: string, maxConnections = 2) { const handle = createDatabase({ url, maxConnections }) const accounts = createAccountRepository(handle.db) const keys = createApiKeyRepository(handle.db) + const tombstones = createApiKeyTombstoneRepository(handle.db) const pools = createPoolRepository(handle.db) const audit = createAuditRepository(handle.db) const repository = createAdminMutationRepository(handle.db) @@ -90,6 +92,8 @@ export function adminMutationDatabase(url: string, maxConnections = 2) { pools: poolRows, targets: await keys.listTargetsForKeys(keyRows.map((row) => row.id)), members: await pools.listMembersForPools(poolRows.map((row) => row.id)), + // A rolled-back remove must leave no tombstone either. + tombstones: (await tombstones.listAll()).filter((row) => subjects.has(row.id)), audit: (await Promise.all([...subjects].map((id) => audit.listForSubject(id, 100)))).flat(), } }, @@ -97,7 +101,9 @@ export function adminMutationDatabase(url: string, maxConnections = 2) { try { for (const id of subjects) { await handle.sql`delete from audit_events where subject_id = ${id}` - await keys.delete(id) + // Test cleanup only: production deletes go through the tombstone repository. + await handle.sql`delete from api_keys where id = ${id}` + await handle.sql`delete from api_key_tombstones where id = ${id}` } for (const id of subjects) await pools.delete(id) for (const id of accountIds) await accounts.delete(id) diff --git a/apps/api/test/support/memory-keys.ts b/apps/api/test/support/memory-keys.ts index adb5f212..4a5ac055 100644 --- a/apps/api/test/support/memory-keys.ts +++ b/apps/api/test/support/memory-keys.ts @@ -4,7 +4,7 @@ import type { ApiKeyRepository, ApiKeyRow, } from "@multi-ai-router/db" -import { drop, remove, replace } from "./memory-row-operations" +import { drop, replace } from "./memory-row-operations" /** * The admin-plane key store of `createMemoryStore`, over arrays — the same @@ -26,7 +26,6 @@ export function memoryKeys( | "findById" | "findByName" | "update" - | "delete" | "markRevoked" | "listPoolTargets" | "listAccountTargets" @@ -70,7 +69,6 @@ export function memoryKeys( findById: async (id) => keys.find((row) => row.id === id), findByName: async (name) => keys.find((row) => row.name === name), update: async (id, patch, now) => replace(keys, id, patch, now), - delete: async (id) => remove(keys, id), markRevoked: async (id, now) => { replace(keys, id, { revoked: true, revokedAt: now }, now) }, diff --git a/apps/api/test/support/memory-mutations.ts b/apps/api/test/support/memory-mutations.ts index 962869df..d44a7583 100644 --- a/apps/api/test/support/memory-mutations.ts +++ b/apps/api/test/support/memory-mutations.ts @@ -1,4 +1,5 @@ import type { AdminMutationRepository, AdminMutationScope } from "@multi-ai-router/db" +import { remove } from "./memory-row-operations" import type { MemoryStore } from "./memory-store" /** Serial transactions with rollback, over the same rows existing service fixtures inspect. */ @@ -27,6 +28,8 @@ export function createMemoryMutations(store: MemoryStore): AdminMutationReposito }) const scope: AdminMutationScope = { keys: store.keys, + // The in-memory store keeps no tombstones; the live path is covered against Postgres. + tombstones: { deleteKey: async (id) => remove(store.rows.keys, id) }, pools: store.pools, accounts: store.accounts, audit: store.audit, diff --git a/apps/api/test/unit/scheduler/janitor.test.ts b/apps/api/test/unit/scheduler/janitor.test.ts index fb6f040b..42ba25f6 100644 --- a/apps/api/test/unit/scheduler/janitor.test.ts +++ b/apps/api/test/unit/scheduler/janitor.test.ts @@ -5,9 +5,9 @@ import { createJanitorTask } from "../../../src/scheduler" import { ageBucket, NOW, silentLogger } from "./fixtures" /** - * `createJanitorTask` is `runSweeps` (`sweep.ts`) plumbed to six categories. + * `createJanitorTask` is `runSweeps` (`sweep.ts`) plumbed to seven categories. * These tests exercise it end to end with in-memory `AgeBucket`s standing in - * for the six repositories, rather than re-testing `runSweeps` in isolation — + * for the seven repositories, rather than re-testing `runSweeps` in isolation — * the contract that matters is the whole task's, not the loop's. */ @@ -25,6 +25,15 @@ const RETENTION: RetentionConfig = { /** Older than every cutoff `RETENTION` computes off `NOW`. */ const OLD = new Date(NOW.getTime() - 2 * 24 * 60 * 60 * 1000) +/** `purgeRevoked` and `deleteOlderThan` stand in as two age buckets: revoked keys, then tombstones. */ +function tombstones(revoked: ReturnType, stones: ReturnType) { + return { + purgeRevoked: (cutoff: Date, _purgedAt: Date, limit: number) => + revoked.deleteBatch(cutoff, limit), + deleteOlderThan: stones.deleteBatch, + } +} + type Report = TaskOutcome & { readonly counts: Record } describe("the janitor sweep", () => { @@ -35,6 +44,7 @@ describe("the janitor sweep", () => { const auditEvents = ageBucket([OLD]) const taskRuns = ageBucket([OLD, OLD, OLD]) const apiKeys = ageBucket([]) + const keyTombstones = ageBucket([]) const task = createJanitorTask({ sessions: { deleteIdleBefore: sessions.deleteBatch }, @@ -48,7 +58,7 @@ describe("the janitor sweep", () => { }, auditEvents: { deleteOlderThan: auditEvents.deleteBatch }, taskRuns: { deleteOlderThan: taskRuns.deleteBatch }, - apiKeys: { deleteRevokedOlderThan: apiKeys.deleteBatch }, + keyTombstones: tombstones(apiKeys, keyTombstones), retention: RETENTION, intervalMs: 60_000, batchSize: 2, @@ -61,7 +71,7 @@ describe("the janitor sweep", () => { }) expect(first.outcome).toBe("success") expect(first.itemsProcessed).toBe(13) - // Every one of the six tables that grows without bound is named, including + // Every one of the seven tables that grows without bound is named, including // the two that are only aggregates and bookkeeping — nothing is left off. expect((first as Report).counts).toEqual({ sessions: 3, @@ -70,6 +80,7 @@ describe("the janitor sweep", () => { auditEvents: 1, taskRuns: 3, revokedKeys: 0, + keyTombstones: 0, }) const second = await task.run({ @@ -86,6 +97,7 @@ describe("the janitor sweep", () => { auditEvents: 0, taskRuns: 0, revokedKeys: 0, + keyTombstones: 0, }) }) @@ -94,6 +106,7 @@ describe("the janitor sweep", () => { // three different windows, one `now`, so a row is judged by the window that // names it and not by whichever sweep happens to run first. const cutoffs: Record = {} + let purgedAt: Date | undefined const record = (category: string) => async (cutoff: Date, _limit: number): Promise => { @@ -113,7 +126,13 @@ describe("the janitor sweep", () => { }, auditEvents: { deleteOlderThan: record("auditEvents") }, taskRuns: { deleteOlderThan: record("taskRuns") }, - apiKeys: { deleteRevokedOlderThan: record("revokedKeys") }, + keyTombstones: { + purgeRevoked: async (cutoff, at, limit) => { + purgedAt = at + return record("revokedKeys")(cutoff, limit) + }, + deleteOlderThan: record("keyTombstones"), + }, retention: { ...RETENTION, usageDays: 90, usageDailyDays: 730, taskRunsDays: 30 }, intervalMs: 60_000, batchSize: 2, @@ -126,6 +145,11 @@ describe("the janitor sweep", () => { expect(daysBack("usageRecords")).toBe(90) expect(daysBack("usageDaily")).toBe(730) expect(daysBack("taskRuns")).toBe(30) + // Tombstones outlive the rollups they label by one day: rollups age by whole UTC day. + expect(daysBack("keyTombstones")).toBe(731) + // The tombstone's purge time is this tick's clock, not the database's. + expect(purgedAt).toEqual(NOW) + expect(daysBack("revokedKeys")).toBe(1) }) test("a shutdown mid-drain reports partial, and the next tick finishes it", async () => { @@ -155,7 +179,7 @@ describe("the janitor sweep", () => { }, auditEvents: { deleteOlderThan: empty.deleteBatch }, taskRuns: { deleteOlderThan: empty.deleteBatch }, - apiKeys: { deleteRevokedOlderThan: empty.deleteBatch }, + keyTombstones: tombstones(empty, empty), retention: RETENTION, intervalMs: 60_000, batchSize: 2, diff --git a/apps/api/test/unit/usage/usage-read.test.ts b/apps/api/test/unit/usage/usage-read.test.ts index c138eb1f..2352b595 100644 --- a/apps/api/test/unit/usage/usage-read.test.ts +++ b/apps/api/test/unit/usage/usage-read.test.ts @@ -89,7 +89,7 @@ function service(rows: readonly UsageGroupRow[], stub: Stub = {}) { }, }, labels: async () => ({ - keys: new Map([["key-1", "dev-laptops"]]), + keys: new Map([["key-1", { name: "dev-laptops", owner: null, ownerKind: null, tool: null }]]), accounts: new Map([["acct-1", "claude-max-01"]]), pools: new Map([["pool-1", "default"]]), }), diff --git a/apps/api/test/unit/usage/usage-recent.test.ts b/apps/api/test/unit/usage/usage-recent.test.ts index 0f8ceb39..fade0d2c 100644 --- a/apps/api/test/unit/usage/usage-recent.test.ts +++ b/apps/api/test/unit/usage/usage-recent.test.ts @@ -78,7 +78,7 @@ function service(rows: readonly RecentAttemptRow[]) { }, labels: async () => ({ - keys: new Map([["key-1", "dev-laptops"]]), + keys: new Map([["key-1", { name: "dev-laptops", owner: null, ownerKind: null, tool: null }]]), accounts: new Map([["acct-1", "claude-max-01"]]), pools: new Map([["pool-1", "default"]]), }), diff --git a/apps/web/package.json b/apps/web/package.json index 690c12c9..aa82563a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@multi-ai-router/web", - "version": "2.30.1", + "version": "2.31.0", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/lib/api/usage.ts b/apps/web/src/lib/api/usage.ts index 50d46eec..6ad9f135 100644 --- a/apps/web/src/lib/api/usage.ts +++ b/apps/web/src/lib/api/usage.ts @@ -9,7 +9,7 @@ import type { FailureCount } from "../failure-classes" // them as JSON numbers would round money through a float. They are parsed // once here, at the edge, so no component has to know. // - **A label may be null**, with a `note` saying why: `deleted` when the key -// or account is gone (usage rows outlive what they name), `none` when the +// or account is gone (usage rows outlive what they name; for a key: no live row and no tombstone), `none` when the // dimension did not apply — a key scoped `all` was placed by no pool. Both // still render, because spend that happened is still spend and hiding a row // would make the breakdown stop adding up to the total printed above it. diff --git a/apps/web/src/routes/keys/KeyDangerDialogs.tsx b/apps/web/src/routes/keys/KeyDangerDialogs.tsx index f313ade3..da917e3d 100644 --- a/apps/web/src/routes/keys/KeyDangerDialogs.tsx +++ b/apps/web/src/routes/keys/KeyDangerDialogs.tsx @@ -71,7 +71,7 @@ function revokeConsequences(key: ApiKeyView): readonly string[] { function deleteConsequences(key: ApiKeyView): readonly string[] { return [ `"${key.name}" is removed outright — clients presenting it fail immediately.`, - "Its usage rows survive, but they no longer name a key.", - "Revoke is the softer door: it stops the key while keeping the row and its scope visible.", + "Its usage stays attributed to its name, owner and tool until the usage history it labels ages out.", + "Revoke is the softer door: it stops the key while keeping the row and its scope visible for the revoked-key grace period.", ] } diff --git a/docs/KEYS.md b/docs/KEYS.md index 5edabf6b..b35c5a66 100644 --- a/docs/KEYS.md +++ b/docs/KEYS.md @@ -97,14 +97,15 @@ Console: **Revoke** on the key's row. API: `POST /api/admin/keys/:id/revoke`. - In-flight requests finish. A stream is not torn down. - Revocation is one-way. A revoked key is never reactivated. - A revoked key keeps its row, scope and usage attribution until `RETENTION_REVOKED_KEYS_DAYS` - (default 30) after revocation. Then it is purged, and its usage rows keep an id that no longer - resolves to a name, owner or tool. + (default 30) after revocation. Then the row is purged and a tombstone keeps its name, owner and tool, + so its usage stays attributed for `RETENTION_USAGE_DAILY_DAYS` (default 730) plus one day. ### Hand back is revoke -When a key is handed back, revoke it. Do not use **Delete** (`DELETE /api/admin/keys/:id`): a hard -delete removes the row at once, so its past usage stops resolving to a name, owner or tool -immediately. +When a key is handed back, revoke it. Do not use **Delete** (`DELETE /api/admin/keys/:id`) for this: +a revoked key stays listed, with its scope and limits visible, for `RETENTION_REVOKED_KEYS_DAYS`, +while a delete removes the row at once. Either way its usage stays attributed through its +tombstone. ## Requester diff --git a/docs/idea/02-domain-model.md b/docs/idea/02-domain-model.md index da8f7afe..27cfa8be 100644 --- a/docs/idea/02-domain-model.md +++ b/docs/idea/02-domain-model.md @@ -446,7 +446,8 @@ means a restarted conversation, not a relocated one. See the table under | **created** | The operator names the key and binds it to Pools. The value is generated and stored encrypted | | **active** | Verifies on the data plane. The value is viewable and copyable from the admin UI at any time, as often as needed — viewing is audited and does not rotate the key | | **revoked** | Fails verification at once on the replica that handled the revoke, and on the others within `KEY_CACHE_TTL_SECONDS`. Historical `UsageRecord` rows stay joinable | -| **purged** | 30 days after revocation the row is deleted by the janitor | +| **purged** | 30 days after revocation the row is deleted by the janitor. The same statement writes an `api_key_tombstones` row (id, name, display prefix, owner, owner kind, tool, purge time; never the value), and an operator delete writes the same row in its transaction. Usage rows keep the key id, so reports still name the key | +| **tombstone swept** | `RETENTION_USAGE_DAILY_DAYS` (730) plus one day after the purge, the horizon of the rollups it labels. Only the usage label loader reads a tombstone: verification, reveal and the admin key list never do. Keys purged before tombstones existed stay "deleted" | ## Session lifecycle diff --git a/docs/idea/09-deployment.md b/docs/idea/09-deployment.md index 191bdb65..001b5d82 100644 --- a/docs/idea/09-deployment.md +++ b/docs/idea/09-deployment.md @@ -258,7 +258,8 @@ One background janitor service, one schedule, every window env-tunable. | Audit events | 365 days | `RETENTION_AUDIT_DAYS` | Compliance-ish; never contains secrets. | | Scheduled task runs | 30 days, finished runs only | `RETENTION_TASK_RUNS_DAYS` | ~1000 rows/day across six tasks. An **unfinished** run is never swept: it is the only evidence a task wedged or a process died mid-sweep. | | Expired/consumed OAuth state & PKCE verifiers | 10 min | `RETENTION_OAUTH_STATE_MINUTES` | One-shot values. | -| Revoked / expired API keys | 30 days after revocation, then purged | `RETENTION_REVOKED_KEYS_DAYS` | Keeps historical usage joinable for a while. | +| Revoked / expired API keys | 30 days after revocation, then purged | `RETENTION_REVOKED_KEYS_DAYS` | Keeps historical usage joinable for a while. The purge writes a tombstone in the same statement (below). | +| API key tombstones (`api_key_tombstones`: id, name, prefix, owner, owner kind, tool, purge time; **no value or ciphertext**) | 730 days + 1 day after the key was purged or deleted | `RETENTION_USAGE_DAILY_DAYS` | A purged key's usage rows keep its id for as long as the daily rollups do, so the tombstone keeps its name and attribution for the same horizon (plus one day, since rollups age by whole UTC day). Written by the janitor purge and by the operator delete; read only by the usage label loader, never by verification, reveal or the key list. | | Rate-limit & circuit-breaker state | expires with its reset window | — | Derived state, not durable state. | | Orphaned `CLAUDE_CONFIG_DIR`s on the volume | 24 h unclaimed | `RETENTION_ORPHAN_CONFIG_DIR_HOURS` | **Not a disk-space sweep.** Each holds a subscription's OAuth credentials in cleartext; one whose account no longer exists is a credential nothing will ever rotate or revoke. Only names that are account ids are candidates, and only past the grace — the directory is created *before* its row, so a young one may be a login still being made. | | Agent-SDK session transcripts on the volume | 24 h since the transcript's last turn | `RETENTION_SDK_TRANSCRIPT_HOURS` | The `claude` CLI writes `projects//.jsonl` (+ `/`) per SDK session under each Account's `CLAUDE_CONFIG_DIR` and never removes them — production measured 250 MB per account, all of it older than any row that could resume it. Only those two artifact shapes are ever candidates; credentials, settings, and the CLI's own state in the same directory are never looked at, symlinks are never followed, and a resume onto a swept transcript is replayed in place rather than failed. Cadence: `SDK_TRANSCRIPT_SWEEP_INTERVAL_MINUTES`. | diff --git a/package.json b/package.json index b9d40e47..2ea58259 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-ai-router", - "version": "2.30.1", + "version": "2.31.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/core/package.json b/packages/core/package.json index 0e7e64bd..003d498a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@multi-ai-router/core", - "version": "2.30.1", + "version": "2.31.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/core/src/version.ts b/packages/core/src/version.ts index 09e8410b..af726e11 100644 --- a/packages/core/src/version.ts +++ b/packages/core/src/version.ts @@ -11,7 +11,7 @@ * settings endpoint and the console footer. Five surfaces, one string: a bug report that quotes a * version quotes the same build every time. */ -export const VERSION = "2.30.1" +export const VERSION = "2.31.0" /** * What `router_build_info{revision}` and the boot log report when nothing told the build which diff --git a/packages/db/README.md b/packages/db/README.md index 801c908a..53e169aa 100644 --- a/packages/db/README.md +++ b/packages/db/README.md @@ -54,6 +54,7 @@ test/integration/ needs a live Postgres, skips cleanly without one | `usage_daily` | Hourly rollup, idempotent per (day, key, account, model) | | `audit_events` | Append-only admin-plane mutations. Never credential material | | `scheduled_task_runs` | One row per periodic-task run — how anyone knows a sweep happened | +| `api_key_tombstones` | What a purged or deleted key leaves behind: id, name, display prefix, owner, owner kind, tool, purge time. Never the value. Read only by the usage label loader; swept on `RETENTION_USAGE_DAILY_DAYS` + 1 day | | `oauth_states` | One-shot OAuth `state` + PKCE verifier, short TTL | Modeling decisions worth knowing: @@ -61,7 +62,7 @@ Modeling decisions worth knowing: | Decision | Why | |---|---| | Scope targets are join tables, not a JSONB array | Scope is enforced as an intersection on every request. A join keeps that a query, and a deleted pool cannot leave a dangling id behind | -| `usage_records.api_key_id` / `account_id` are nullable, `ON DELETE SET NULL` | A revoked key is purged 30 days later, and its historical rows must stay joinable until then and readable after. An attempt that failed before selection has no account at all | +| `usage_records.api_key_id` / `account_id` are nullable and carry **no foreign key** (dropped in `0030`) | A purged key or deleted account must not erase or null its historical rows; the id stays on every usage row and `api_key_tombstones` keeps a purged key's name. An attempt that failed before selection has no account at all | | `usage_daily` carries **no** foreign keys | Raw rows expire on the retention window; the rollup does not. A lifetime total has to outlive the key that earned it | | `cost_metered` and `cost_notional` are separate columns | A subscription is a flat fee, so its per-request cost is an attribution, not a charge. The docs forbid summing the two | | `sessions.account_id` is `ON DELETE SET NULL` | A binding is *invalidated*, never migrated: another account cannot resume an SDK session id. The FK is the backstop; the service clears `sdk_session_id` and lineage with it | diff --git a/packages/db/migrations/0036_key_tombstones.sql b/packages/db/migrations/0036_key_tombstones.sql new file mode 100644 index 00000000..91a73100 --- /dev/null +++ b/packages/db/migrations/0036_key_tombstones.sql @@ -0,0 +1,11 @@ +CREATE TABLE "api_key_tombstones" ( + "id" uuid PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "prefix" text NOT NULL, + "owner" text, + "owner_kind" "key_owner_kind", + "tool" text, + "purged_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE INDEX "api_key_tombstones_purged_at_idx" ON "api_key_tombstones" USING btree ("purged_at"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0036_snapshot.json b/packages/db/migrations/meta/0036_snapshot.json new file mode 100644 index 00000000..3989b7c2 --- /dev/null +++ b/packages/db/migrations/meta/0036_snapshot.json @@ -0,0 +1,3171 @@ +{ + "id": "6046e495-a06a-469e-ab56-4ab3aaaa0b28", + "prevId": "dd676de2-6c98-4d49-8b0b-33f9caf3c83d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account_operator_checks": { + "name": "account_operator_checks", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "claim_token": { + "name": "claim_token", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_operator_checks_account_id_accounts_id_fk": { + "name": "account_operator_checks_account_id_accounts_id_fk", + "tableFrom": "account_operator_checks", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_recoveries": { + "name": "account_recoveries", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle_version": { + "name": "lifecycle_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credential_fingerprint": { + "name": "credential_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_boot_id": { + "name": "owner_boot_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ownership_epoch": { + "name": "ownership_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "preparation_lease_until": { + "name": "preparation_lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "permit_id": { + "name": "permit_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome_at": { + "name": "outcome_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "next_allowed_at": { + "name": "next_allowed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "quota_revisions": { + "name": "quota_revisions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_recoveries_account_id_accounts_id_fk": { + "name": "account_recoveries_account_id_accounts_id_fk", + "tableFrom": "account_recoveries", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "account_recoveries_revision_nonnegative": { + "name": "account_recoveries_revision_nonnegative", + "value": "\"account_recoveries\".\"revision\" >= 0" + }, + "account_recoveries_epoch_nonnegative": { + "name": "account_recoveries_epoch_nonnegative", + "value": "\"account_recoveries\".\"ownership_epoch\" >= 0" + }, + "account_recoveries_state_valid": { + "name": "account_recoveries_state_valid", + "value": "\"account_recoveries\".\"state\" in ('pending','issued','succeeded','failed','uncertain','cancelled')" + }, + "account_recoveries_permit_valid": { + "name": "account_recoveries_permit_valid", + "value": "(\"account_recoveries\".\"state\" = 'pending' and \"account_recoveries\".\"permit_id\" is null and \"account_recoveries\".\"issued_at\" is null) or (\"account_recoveries\".\"state\" in ('issued','succeeded','failed','uncertain') and \"account_recoveries\".\"permit_id\" is not null and \"account_recoveries\".\"issued_at\" is not null and \"account_recoveries\".\"owner_boot_id\" is not null) or \"account_recoveries\".\"state\" = 'cancelled'" + } + }, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "provider_id", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "account_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "lifecycle_version": { + "name": "lifecycle_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "health_recovery_version": { + "name": "health_recovery_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "auth_recovery_version": { + "name": "auth_recovery_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "authorization_attempt_id": { + "name": "authorization_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing": { + "name": "billing", + "type": "account_billing", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'metered'" + }, + "auth_material": { + "name": "auth_material", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config_dir": { + "name": "config_dir", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dialect": { + "name": "dialect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_aliases": { + "name": "model_aliases", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "supported_models": { + "name": "supported_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "window_token_limits": { + "name": "window_token_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_idx": { + "name": "accounts_provider_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_status_idx": { + "name": "accounts_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_last_used_at_idx": { + "name": "accounts_last_used_at_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_config_dir_key": { + "name": "accounts_config_dir_key", + "columns": [ + { + "expression": "config_dir", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "accounts_lifecycle_version_nonnegative": { + "name": "accounts_lifecycle_version_nonnegative", + "value": "\"accounts\".\"lifecycle_version\" >= 0" + }, + "accounts_health_recovery_version_nonnegative": { + "name": "accounts_health_recovery_version_nonnegative", + "value": "\"accounts\".\"health_recovery_version\" >= 0" + }, + "accounts_auth_recovery_version_nonnegative": { + "name": "accounts_auth_recovery_version_nonnegative", + "value": "\"accounts\".\"auth_recovery_version\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.admin_credentials": { + "name": "admin_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_sessions": { + "name": "admin_sessions", + "schema": "", + "columns": { + "id_hash": { + "name": "id_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "csrf_token": { + "name": "csrf_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "idle_expiry_at": { + "name": "idle_expiry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "absolute_expiry_at": { + "name": "absolute_expiry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "admin_sessions_idle_expiry_at_idx": { + "name": "admin_sessions_idle_expiry_at_idx", + "columns": [ + { + "expression": "idle_expiry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_sessions_absolute_expiry_at_idx": { + "name": "admin_sessions_absolute_expiry_at_idx", + "columns": [ + { + "expression": "absolute_expiry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key_accounts": { + "name": "api_key_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_key_accounts_key_account_key": { + "name": "api_key_accounts_key_account_key", + "columns": [ + { + "expression": "api_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_accounts_account_idx": { + "name": "api_key_accounts_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_accounts_api_key_id_api_keys_id_fk": { + "name": "api_key_accounts_api_key_id_api_keys_id_fk", + "tableFrom": "api_key_accounts", + "tableTo": "api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_accounts_account_id_accounts_id_fk": { + "name": "api_key_accounts_account_id_accounts_id_fk", + "tableFrom": "api_key_accounts", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key_pools": { + "name": "api_key_pools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pool_id": { + "name": "pool_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_key_pools_key_pool_key": { + "name": "api_key_pools_key_pool_key", + "columns": [ + { + "expression": "api_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_pools_pool_idx": { + "name": "api_key_pools_pool_idx", + "columns": [ + { + "expression": "pool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_pools_api_key_id_api_keys_id_fk": { + "name": "api_key_pools_api_key_id_api_keys_id_fk", + "tableFrom": "api_key_pools", + "tableTo": "api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_pools_pool_id_pools_id_fk": { + "name": "api_key_pools_pool_id_pools_id_fk", + "tableFrom": "api_key_pools", + "tableTo": "pools", + "columnsFrom": [ + "pool_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key_tombstones": { + "name": "api_key_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_kind": { + "name": "owner_kind", + "type": "key_owner_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purged_at": { + "name": "purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "api_key_tombstones_purged_at_idx": { + "name": "api_key_tombstones_purged_at_idx", + "columns": [ + { + "expression": "purged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "key_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_kind": { + "name": "owner_kind", + "type": "key_owner_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rate_limit_requests": { + "name": "rate_limit_requests", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_window_seconds": { + "name": "rate_limit_window_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_keys_prefix_idx": { + "name": "api_keys_prefix_idx", + "columns": [ + { + "expression": "prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_revoked_at_idx": { + "name": "api_keys_revoked_at_idx", + "columns": [ + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_kind_created_idx": { + "name": "audit_events_kind_created_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_subject_idx": { + "name": "audit_events_subject_idx", + "columns": [ + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_catalog": { + "name": "model_catalog", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_tokens": { + "name": "context_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "context_source": { + "name": "context_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "listing_source": { + "name": "listing_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'upstream'" + }, + "resolved_model": { + "name": "resolved_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshed_at": { + "name": "refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "model_catalog_model_id_idx": { + "name": "model_catalog_model_id_idx", + "columns": [ + { + "expression": "model_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "model_catalog_account_id_accounts_id_fk": { + "name": "model_catalog_account_id_accounts_id_fk", + "tableFrom": "model_catalog", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "model_catalog_account_id_model_id_pk": { + "name": "model_catalog_account_id_model_id_pk", + "columns": [ + "account_id", + "model_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_states": { + "name": "oauth_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "authorization_lifecycle_version": { + "name": "authorization_lifecycle_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_states_state_key": { + "name": "oauth_states_state_key", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_states_expires_at_idx": { + "name": "oauth_states_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_states_account_id_accounts_id_fk": { + "name": "oauth_states_account_id_accounts_id_fk", + "tableFrom": "oauth_states", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pool_members": { + "name": "pool_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pool_id": { + "name": "pool_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pool_members_pool_account_key": { + "name": "pool_members_pool_account_key", + "columns": [ + { + "expression": "pool_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pool_members_account_idx": { + "name": "pool_members_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pool_members_pool_id_pools_id_fk": { + "name": "pool_members_pool_id_pools_id_fk", + "tableFrom": "pool_members", + "tableTo": "pools", + "columnsFrom": [ + "pool_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pool_members_account_id_accounts_id_fk": { + "name": "pool_members_account_id_accounts_id_fk", + "tableFrom": "pool_members", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pools": { + "name": "pools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policy": { + "name": "policy", + "type": "routing_policy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'sticky'" + }, + "overflow_account_id": { + "name": "overflow_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pools_name_key": { + "name": "pools_name_key", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pools_overflow_account_id_accounts_id_fk": { + "name": "pools_overflow_account_id_accounts_id_fk", + "tableFrom": "pools", + "tableTo": "accounts", + "columnsFrom": [ + "overflow_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.price_overrides": { + "name": "price_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "provider_id", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_per_mtok": { + "name": "input_per_mtok", + "type": "numeric(12, 6)", + "primaryKey": false, + "notNull": true + }, + "output_per_mtok": { + "name": "output_per_mtok", + "type": "numeric(12, 6)", + "primaryKey": false, + "notNull": true + }, + "cache_read_per_mtok": { + "name": "cache_read_per_mtok", + "type": "numeric(12, 6)", + "primaryKey": false, + "notNull": true + }, + "cache_write_per_mtok": { + "name": "cache_write_per_mtok", + "type": "numeric(12, 6)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "price_overrides_account_id_accounts_id_fk": { + "name": "price_overrides_account_id_accounts_id_fk", + "tableFrom": "price_overrides", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "price_overrides_account_provider_model_key": { + "name": "price_overrides_account_provider_model_key", + "nullsNotDistinct": true, + "columns": [ + "account_id", + "provider", + "model" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quota_windows": { + "name": "quota_windows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "window": { + "name": "window", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "evidence_state": { + "name": "evidence_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'current'" + }, + "blocks_routing": { + "name": "blocks_routing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "advisory": { + "name": "advisory", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retired_at": { + "name": "retired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "utilization": { + "name": "utilization", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "utilization_source": { + "name": "utilization_source", + "type": "utilization_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "resets_at": { + "name": "resets_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reset_source": { + "name": "reset_source", + "type": "reset_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quota_windows_account_window_key": { + "name": "quota_windows_account_window_key", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quota_windows_resets_at_idx": { + "name": "quota_windows_resets_at_idx", + "columns": [ + { + "expression": "resets_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quota_windows_account_id_accounts_id_fk": { + "name": "quota_windows_account_id_accounts_id_fk", + "tableFrom": "quota_windows", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "quota_windows_revision_nonnegative": { + "name": "quota_windows_revision_nonnegative", + "value": "\"quota_windows\".\"revision\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.scheduled_task_runs": { + "name": "scheduled_task_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task": { + "name": "task", + "type": "scheduled_task", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "scheduled_task_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "items_processed": { + "name": "items_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scheduled_task_runs_task_started_idx": { + "name": "scheduled_task_runs_task_started_idx", + "columns": [ + { + "expression": "task", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scheduled_task_runs_started_at_idx": { + "name": "scheduled_task_runs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sdk_session_id": { + "name": "sdk_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lineage_state": { + "name": "lineage_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fingerprint_source": { + "name": "fingerprint_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_api_key_key_key": { + "name": "sessions_api_key_key_key", + "columns": [ + { + "expression": "api_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_account_idx": { + "name": "sessions_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_last_used_at_idx": { + "name": "sessions_last_used_at_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_api_key_id_api_keys_id_fk": { + "name": "sessions_api_key_id_api_keys_id_fk", + "tableFrom": "sessions", + "tableTo": "api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_account_id_accounts_id_fk": { + "name": "sessions_account_id_accounts_id_fk", + "tableFrom": "sessions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_attempt_daily_v2": { + "name": "usage_attempt_daily_v2", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pool_id": { + "name": "pool_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "basis": { + "name": "basis", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requests": { + "name": "requests", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tokens_in": { + "name": "tokens_in", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tokens_out": { + "name": "tokens_out", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_metered": { + "name": "cost_metered", + "type": "numeric(24, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "cost_notional": { + "name": "cost_notional", + "type": "numeric(24, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "unpriced_attempts": { + "name": "unpriced_attempts", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "usage_attempt_daily_v2_grain": { + "name": "usage_attempt_daily_v2_grain", + "nullsNotDistinct": true, + "columns": [ + "day", + "api_key_id", + "account_id", + "pool_id", + "model", + "basis" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_request_daily_v2": { + "name": "usage_request_daily_v2", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pool_id": { + "name": "pool_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "basis": { + "name": "basis", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requests": { + "name": "requests", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tokens_in": { + "name": "tokens_in", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tokens_out": { + "name": "tokens_out", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_metered": { + "name": "cost_metered", + "type": "numeric(24, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "cost_notional": { + "name": "cost_notional", + "type": "numeric(24, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "usage_request_daily_v2_grain": { + "name": "usage_request_daily_v2_grain", + "nullsNotDistinct": true, + "columns": [ + "day", + "api_key_id", + "account_id", + "pool_id", + "model", + "basis" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_contributions": { + "name": "usage_contributions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "ingested_at": { + "name": "ingested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_contributions_day_idx": { + "name": "usage_contributions_day_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_contributions_source_idx": { + "name": "usage_contributions_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_contributions_kind_id_pk": { + "name": "usage_contributions_kind_id_pk", + "columns": [ + "kind", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_daily": { + "name": "usage_daily", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pool_id": { + "name": "pool_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requests": { + "name": "requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tokens_in": { + "name": "tokens_in", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tokens_out": { + "name": "tokens_out", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_metered": { + "name": "cost_metered", + "type": "numeric(16, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "cost_notional": { + "name": "cost_notional", + "type": "numeric(16, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_daily_grain_key": { + "name": "usage_daily_grain_key", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "api_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"pool_id\", '00000000-0000-0000-0000-000000000000'::uuid)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_daily_day_idx": { + "name": "usage_daily_day_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_daily_account_day_idx": { + "name": "usage_daily_account_day_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_history_state": { + "name": "usage_history_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "retention_before_day": { + "name": "retention_before_day", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "unpriced_since": { + "name": "unpriced_since", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_records": { + "name": "usage_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "correlation_id": { + "name": "correlation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_request_id": { + "name": "client_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pool_id": { + "name": "pool_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "provider_id", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "session_key": { + "name": "session_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "upstream_model": { + "name": "upstream_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ingress_dialect": { + "name": "ingress_dialect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "egress_mode": { + "name": "egress_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_estimate": { + "name": "cost_estimate", + "type": "numeric(14, 6)", + "primaryKey": false, + "notNull": false + }, + "cost_basis": { + "name": "cost_basis", + "type": "cost_basis", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "router_overhead_ms": { + "name": "router_overhead_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streamed": { + "name": "streamed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "http_status": { + "name": "http_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "error_class": { + "name": "error_class", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingested_at": { + "name": "ingested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "clock_timestamp()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_records_correlation_idx": { + "name": "usage_records_correlation_idx", + "columns": [ + { + "expression": "correlation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_client_request_idx": { + "name": "usage_records_client_request_idx", + "columns": [ + { + "expression": "client_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_records\".\"client_request_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_created_at_idx": { + "name": "usage_records_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_request_terminals": { + "name": "usage_request_terminals", + "schema": "", + "columns": { + "correlation_id": { + "name": "correlation_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "winner_event_id": { + "name": "winner_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pool_id": { + "name": "pool_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "provider_id", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "upstream_model": { + "name": "upstream_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_class": { + "name": "error_class", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "http_status": { + "name": "http_status", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "attribution_kind": { + "name": "attribution_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ingested_at": { + "name": "ingested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_request_terminals_settled_idx": { + "name": "usage_request_terminals_settled_idx", + "columns": [ + { + "expression": "settled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_billing": { + "name": "account_billing", + "schema": "public", + "values": [ + "metered", + "subscription" + ] + }, + "public.account_status": { + "name": "account_status", + "schema": "public", + "values": [ + "active", + "disabled", + "cooling_down", + "exhausted", + "needs_reauth" + ] + }, + "public.cost_basis": { + "name": "cost_basis", + "schema": "public", + "values": [ + "metered", + "notional", + "unknown" + ] + }, + "public.key_owner_kind": { + "name": "key_owner_kind", + "schema": "public", + "values": [ + "person", + "service" + ] + }, + "public.key_scope": { + "name": "key_scope", + "schema": "public", + "values": [ + "all", + "pools", + "accounts" + ] + }, + "public.provider_id": { + "name": "provider_id", + "schema": "public", + "values": [ + "anthropic-oauth", + "anthropic-api", + "openai-oauth", + "openai-api", + "openrouter", + "zai", + "kimi", + "minimax", + "gemini", + "groq", + "deepseek", + "xai", + "mistral", + "together", + "cerebras", + "ollama", + "openai-compatible", + "anthropic-compatible" + ] + }, + "public.reset_source": { + "name": "reset_source", + "schema": "public", + "values": [ + "provider-reported", + "estimated", + "unknown" + ] + }, + "public.routing_policy": { + "name": "routing_policy", + "schema": "public", + "values": [ + "sticky", + "round-robin", + "weighted", + "least-used", + "priority-failover", + "quota-aware" + ] + }, + "public.scheduled_task": { + "name": "scheduled_task", + "schema": "public", + "values": [ + "janitor_sweep", + "usage_rollup", + "oauth_state_purge", + "quota_floor_refresh", + "config_dir_reap", + "admin_session_purge", + "idle_account_probe", + "model_catalog_refresh", + "sdk_transcript_sweep", + "credential_keepalive", + "login_lifetime_watch" + ] + }, + "public.scheduled_task_outcome": { + "name": "scheduled_task_outcome", + "schema": "public", + "values": [ + "success", + "failed", + "partial" + ] + }, + "public.utilization_source": { + "name": "utilization_source", + "schema": "public", + "values": [ + "continuous", + "threshold-triggered", + "none" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index eee8b34f..1b2f6300 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -253,6 +253,13 @@ "when": 1791997674761, "tag": "0035_unpriced_attempts", "breakpoints": true + }, + { + "idx": 36, + "version": "7", + "when": 1792041117859, + "tag": "0036_key_tombstones", + "breakpoints": true } ] } diff --git a/packages/db/package.json b/packages/db/package.json index 04a6e028..49cecd1b 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@multi-ai-router/db", - "version": "2.30.1", + "version": "2.31.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 690d672b..3a000a79 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -64,6 +64,8 @@ export type { AdminSessionRepository } from "./repositories/admin-session-reposi export { createAdminSessionRepository } from "./repositories/admin-session-repository" export type { ApiKeyRepository } from "./repositories/api-key-repository" export { createApiKeyRepository } from "./repositories/api-key-repository" +export type { ApiKeyTombstoneRepository } from "./repositories/api-key-tombstone-repository" +export { createApiKeyTombstoneRepository } from "./repositories/api-key-tombstone-repository" export type { AppendAuditEventInput, AuditRepository, @@ -190,6 +192,8 @@ export type { NewApiKeyPoolRow, } from "./schema/api-key-scope" export { apiKeyAccounts, apiKeyPools } from "./schema/api-key-scope" +export type { ApiKeyTombstoneRow } from "./schema/api-key-tombstones" +export { apiKeyTombstones } from "./schema/api-key-tombstones" export type { ApiKeyRow, NewApiKeyRow } from "./schema/api-keys" export { apiKeys } from "./schema/api-keys" export type { AuditDetail, AuditEventRow, NewAuditEventRow } from "./schema/audit-events" diff --git a/packages/db/src/repositories/admin-mutation-repository.ts b/packages/db/src/repositories/admin-mutation-repository.ts index bd20a927..42c9b577 100644 --- a/packages/db/src/repositories/admin-mutation-repository.ts +++ b/packages/db/src/repositories/admin-mutation-repository.ts @@ -6,6 +6,10 @@ import { pools } from "../schema/pools" import { type AccountRepository, createAccountRepository } from "./account-repository" import { withAdminMutationRetry } from "./admin-mutation-conflict" import { type ApiKeyRepository, createApiKeyRepository } from "./api-key-repository" +import { + type ApiKeyTombstoneRepository, + createApiKeyTombstoneRepository, +} from "./api-key-tombstone-repository" import { type AuditRepository, createAuditRepository } from "./audit-repository" import { createPoolRepository, type PoolRepository } from "./pool-repository" @@ -23,13 +27,13 @@ export interface AdminMutationScope { | "findById" | "findByName" | "update" - | "delete" | "markRevoked" | "listPoolTargets" | "listAccountTargets" | "listKeysScopedToPool" | "replaceScopeTargets" > + readonly tombstones: Pick readonly pools: PoolRepository readonly accounts: Pick readonly audit: Pick @@ -74,6 +78,7 @@ export function createAdminMutationRepository(db: Database): AdminMutationReposi } return work({ keys: createApiKeyRepository(tx), + tombstones: createApiKeyTombstoneRepository(tx), pools: createPoolRepository(tx), accounts: createAccountRepository(tx), audit: createAuditRepository(tx), diff --git a/packages/db/src/repositories/api-key-repository.ts b/packages/db/src/repositories/api-key-repository.ts index 5644b28d..e834bd86 100644 --- a/packages/db/src/repositories/api-key-repository.ts +++ b/packages/db/src/repositories/api-key-repository.ts @@ -8,7 +8,6 @@ import { apiKeyPools, } from "../schema/api-key-scope" import { type ApiKeyRow, apiKeys } from "../schema/api-keys" -import { deleteOldestBatch } from "./bounded-delete" /** * Repositories own SQL. Services call these methods and never write a query @@ -42,21 +41,8 @@ export interface ApiKeyRepository { findByName(name: string): Promise /** Never touches `value` or `prefix`: editing a key does not rotate it. */ update(id: string, patch: UpdateApiKeyInput, now: Date): Promise - /** `true` when a row was removed. Scope rows cascade. */ - delete(id: string): Promise - /** - * Purges keys revoked before `cutoff` in one bounded batch, oldest revocation - * first, and returns how many went. Exactly `limit` means there is more to do - * and the run should report `partial`. - * - * Revocation already excluded these keys from verification; this only reclaims - * the rows a configured window later. Usage history survives — - * `usage_records.api_key_id` is ON DELETE SET NULL — and scope rows cascade. - * - * A revoked row whose `revoked_at` is NULL has no measurable age and is never - * swept, which is the safe reading: unpurged is recoverable, purged is not. - */ - deleteRevokedOlderThan(cutoff: Date, limit: number): Promise + // There is deliberately no `delete`: removing an `api_keys` row writes a tombstone in the same + // statement, and that lives in `ApiKeyTombstoneRepository` (`deleteKey`, `purgeRevoked`). listPoolTargets(apiKeyId: string): Promise listAccountTargets(apiKeyId: string): Promise @@ -222,26 +208,6 @@ export function createApiKeyRepository(db: DatabaseExecutor): ApiKeyRepository { return rows[0] }, - delete: async (id) => { - const rows = await db.delete(apiKeys).where(eq(apiKeys.id, id)).returning({ id: apiKeys.id }) - return rows.length > 0 - }, - - // Rides `api_keys_revoked_at_idx`. The `revoked` predicate is not redundant - // with a non-null `revoked_at`: the flag is what `findUsableByPrefix` reads, - // so narrowing on it means the sweep can only ever reach a key verification - // already refuses — whatever a stray timestamp on a live row might say. - deleteRevokedOlderThan: (cutoff, limit) => - deleteOldestBatch({ - db, - table: apiKeys, - id: apiKeys.id, - agedBy: apiKeys.revokedAt, - cutoff, - limit, - narrowedBy: eq(apiKeys.revoked, true), - }), - listPoolTargets: (apiKeyId) => poolTargetsWhere(eq(apiKeyPools.apiKeyId, apiKeyId)), listAccountTargets: (apiKeyId) => diff --git a/packages/db/src/repositories/api-key-tombstone-repository.ts b/packages/db/src/repositories/api-key-tombstone-repository.ts new file mode 100644 index 00000000..8ca71a49 --- /dev/null +++ b/packages/db/src/repositories/api-key-tombstone-repository.ts @@ -0,0 +1,119 @@ +import { asc, type SQL, sql } from "drizzle-orm" +import type { DatabaseExecutor } from "../client" +import { type ApiKeyTombstoneRow, apiKeyTombstones } from "../schema/api-key-tombstones" +import { apiKeys } from "../schema/api-keys" +import { deleteOldestBatch } from "./bounded-delete" + +/** + * Tombstones for purged API keys: attribution without the secret (issue #224). + * + * Kept out of `api-key-repository.ts` on purpose. Nothing on the request path, no reveal and no + * admin key list reads this table; the only reader is the usage label loader, through `listAll`. + * Every `purged_at` comes from the caller's injected clock, never the database's `now()`. + * + * **This is the only production code that deletes an `api_keys` row** (bench and test cleanup aside). Both deletes (the janitor purge and the + * operator delete) are one statement that also writes the tombstone, so a key's name cannot be lost + * with its row. + */ +export interface ApiKeyTombstoneRepository { + /** + * Deletes keys revoked before `cutoff` — one bounded batch, oldest revocation first — and writes + * their tombstones in the same statement. Returns how many keys went; exactly `limit` means there + * is more. A revoked row whose `revoked_at` is NULL has no measurable age and is never swept. + */ + purgeRevoked(cutoff: Date, purgedAt: Date, limit: number): Promise + /** + * The operator delete: removes one key and writes its tombstone in the same statement. + * `true` when a row was removed. A concurrent janitor purge cannot double-insert (the row lock lets + * one DELETE return the row); `ON CONFLICT DO NOTHING` is belt and braces for a tombstone that + * already exists for the id. Scope rows cascade. + */ + deleteKey(id: string, purgedAt: Date): Promise + /** Every tombstone. Small by construction: one row per key rotated away in the retention window. */ + listAll(): Promise + /** Sweeps tombstones with `purged_at` before `cutoff`, bounded, oldest first. */ + deleteOlderThan(cutoff: Date, limit: number): Promise +} + +/** The `api_keys` columns a tombstone copies, in the order of `TOMBSTONE_COLUMNS`. */ +const COPIED = [ + apiKeys.id, + apiKeys.name, + apiKeys.prefix, + apiKeys.owner, + apiKeys.ownerKind, + apiKeys.tool, +] +const TOMBSTONE_COLUMNS = sql.join( + [ + apiKeyTombstones.id, + apiKeyTombstones.name, + apiKeyTombstones.prefix, + apiKeyTombstones.owner, + apiKeyTombstones.ownerKind, + apiKeyTombstones.tool, + apiKeyTombstones.purgedAt, + ].map((column) => sql.identifier(column.name)), + sql`, `, +) + +/** + * `DELETE ... RETURNING` feeding `INSERT ... ON CONFLICT DO NOTHING`, counted from the delete so a + * skipped insert cannot understate how many keys went. Dates travel as ISO text with an explicit + * cast: this is raw SQL, so no column encoder runs. + */ +async function deleteAndTombstone( + db: DatabaseExecutor, + where: SQL, + purgedAt: Date, +): Promise { + const returned = sql.join(COPIED, sql`, `) + const [row] = await db.execute<{ deleted: number }>(sql` + WITH gone AS ( + DELETE FROM ${apiKeys} WHERE ${where} RETURNING ${returned} + ), written AS ( + INSERT INTO ${apiKeyTombstones} (${TOMBSTONE_COLUMNS}) + SELECT ${sql.join( + COPIED.map((column) => sql.identifier(column.name)), + sql`, `, + )}, ${purgedAt.toISOString()}::timestamptz + FROM gone + ON CONFLICT (${sql.identifier(apiKeyTombstones.id.name)}) DO NOTHING + ) + SELECT count(*)::int AS deleted FROM gone`) + return row?.deleted ?? 0 +} + +export function createApiKeyTombstoneRepository(db: DatabaseExecutor): ApiKeyTombstoneRepository { + return { + // Rides `api_keys_revoked_at_idx`. The `revoked` predicate is not redundant with a non-null + // `revoked_at`: the flag is what `findUsableByPrefix` reads, so the sweep can only reach a key + // verification already refuses. + purgeRevoked: (cutoff, purgedAt, limit) => + deleteAndTombstone( + db, + sql`${apiKeys.id} IN ( + SELECT ${apiKeys.id} FROM ${apiKeys} + WHERE ${apiKeys.revoked} = true AND ${apiKeys.revokedAt} < ${cutoff.toISOString()}::timestamptz + ORDER BY ${apiKeys.revokedAt} ASC + LIMIT ${limit} + )`, + purgedAt, + ), + + deleteKey: async (id, purgedAt) => + (await deleteAndTombstone(db, sql`${apiKeys.id} = ${id}::uuid`, purgedAt)) > 0, + + listAll: () => db.select().from(apiKeyTombstones).orderBy(asc(apiKeyTombstones.purgedAt)), + + deleteOlderThan: (cutoff, limit) => + deleteOldestBatch({ + db, + table: apiKeyTombstones, + id: apiKeyTombstones.id, + agedBy: apiKeyTombstones.purgedAt, + cutoff, + limit, + }), + } +} diff --git a/packages/db/src/repositories/index.ts b/packages/db/src/repositories/index.ts index 1016e5b3..a465957b 100644 --- a/packages/db/src/repositories/index.ts +++ b/packages/db/src/repositories/index.ts @@ -4,6 +4,7 @@ export { AdminMutationConflictError } from "./admin-mutation-conflict" export * from "./admin-mutation-repository" export * from "./admin-session-repository" export * from "./api-key-repository" +export * from "./api-key-tombstone-repository" export * from "./audit-repository" export * from "./bounded-delete" export * from "./catalog-snapshot-repository" diff --git a/packages/db/src/schema/api-key-tombstones.ts b/packages/db/src/schema/api-key-tombstones.ts new file mode 100644 index 00000000..cf9fd53b --- /dev/null +++ b/packages/db/src/schema/api-key-tombstones.ts @@ -0,0 +1,33 @@ +import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core" +import { keyOwnerKind } from "./enums" + +/** + * What a purged key leaves behind: its attribution, never its secret. + * + * `usage_records` and the rollups keep a key's id long after the `api_keys` row is gone (no FK + * since 0030), so a name source that outlives the row is what lets a report still say whose spend + * a month-old rotation was. Written in the same statement as the delete (janitor purge and operator + * delete) and swept at `RETENTION_USAGE_DAILY_DAYS` plus one day: rollups age by whole UTC day, and + * the tombstone must outlive the rollups it labels. + * + * Deliberately has no `value`/ciphertext column, and nothing on the request path reads this table: + * verification, reveal and the admin key list all stay on `api_keys`. The display prefix is + * non-secret (docs/idea/07-security.md). + */ +export const apiKeyTombstones = pgTable( + "api_key_tombstones", + { + /** The purged key's id — no default and no FK: it names a row that no longer exists. */ + id: uuid("id").primaryKey(), + name: text("name").notNull(), + prefix: text("prefix").notNull(), + owner: text("owner"), + ownerKind: keyOwnerKind("owner_kind"), + tool: text("tool"), + /** From the caller's injected clock, never the database's `now()`. */ + purgedAt: timestamp("purged_at", { withTimezone: true, mode: "date" }).notNull(), + }, + (table) => [index("api_key_tombstones_purged_at_idx").on(table.purgedAt)], +) + +export type ApiKeyTombstoneRow = typeof apiKeyTombstones.$inferSelect diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index bdead620..a3a175f5 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -9,6 +9,7 @@ export * from "./accounts" export * from "./admin-credentials" export * from "./admin-sessions" export * from "./api-key-scope" +export * from "./api-key-tombstones" export * from "./api-keys" export * from "./audit-events" export * from "./enums" diff --git a/packages/db/test/integration/api-key-tombstones.test.ts b/packages/db/test/integration/api-key-tombstones.test.ts new file mode 100644 index 00000000..c52c816d --- /dev/null +++ b/packages/db/test/integration/api-key-tombstones.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from "bun:test" +import { eq } from "drizzle-orm" +import { createApiKeyRepository } from "../../src/repositories/api-key-repository" +import { createApiKeyTombstoneRepository } from "../../src/repositories/api-key-tombstone-repository" +import { apiKeys } from "../../src/schema/api-keys" +import { historyUrl, usageHistoryFixture } from "./usage-history-fixture" + +/** + * A purged key keeps its attribution (not its secret) so usage rollups that outlive it still name + * who spent the money. Real Postgres: the purge is one data-modifying CTE and only the planner can + * say whether the delete and the tombstone insert are the same statement. + */ +const fixture = usageHistoryFixture() + +const REVOKED_AT = new Date("1999-01-01T00:00:00.000Z") +const CUTOFF = new Date("2000-01-01T00:00:00.000Z") +const PURGED_AT = new Date("2031-05-06T07:08:09.000Z") + +async function revokedKey(name: string, revokedAt: Date, extra: { owner?: string; tool?: string }) { + const repo = createApiKeyRepository(fixture.db()) + const row = await repo.create({ + name, + value: "ciphertext-envelope", + prefix: `mar_live_${name.slice(0, 4)}`, + owner: extra.owner ?? null, + ownerKind: extra.owner === undefined ? null : "person", + tool: extra.tool ?? null, + }) + await repo.markRevoked(row.id, revokedAt) + return row +} + +describe.skipIf(!historyUrl)("api key tombstones against a live database", () => { + test("the table has exactly the attribution columns and no value or ciphertext", async () => { + const rows = await fixture + .get() + .sql.unsafe( + "select column_name from information_schema.columns where table_name = 'api_key_tombstones' order by column_name", + ) + expect(rows.map((row) => row.column_name)).toEqual([ + "id", + "name", + "owner", + "owner_kind", + "prefix", + "purged_at", + "tool", + ]) + }) + + test("purging deletes the key and writes its tombstone in one statement, at the injected clock", async () => { + const swept = await revokedKey("sweptkey", REVOKED_AT, { owner: "alice", tool: "editor" }) + const live = await createApiKeyRepository(fixture.db()).create({ + name: "livekey", + value: "envelope", + prefix: "mar_live_live", + }) + const tombstones = createApiKeyTombstoneRepository(fixture.db()) + + expect(await tombstones.purgeRevoked(CUTOFF, PURGED_AT, 100)).toBe(1) + expect(await tombstones.purgeRevoked(CUTOFF, PURGED_AT, 100)).toBe(0) + + expect(await createApiKeyRepository(fixture.db()).findById(swept.id)).toBeUndefined() + expect(await createApiKeyRepository(fixture.db()).findById(live.id)).toBeDefined() + const found = (await tombstones.listAll()).find((row) => row.id === swept.id) + expect(found).toEqual({ + id: swept.id, + name: "sweptkey", + prefix: "mar_live_swep", + owner: "alice", + ownerKind: "person", + tool: "editor", + purgedAt: PURGED_AT, + }) + expect((await tombstones.listAll()).some((row) => row.id === live.id)).toBe(false) + }) + + test("a purge batch is bounded and oldest revocation first", async () => { + const older = await revokedKey("olderkey", new Date("1998-01-01T00:00:00.000Z"), {}) + const newer = await revokedKey("newerkey", new Date("1998-06-01T00:00:00.000Z"), {}) + const tombstones = createApiKeyTombstoneRepository(fixture.db()) + expect(await tombstones.purgeRevoked(CUTOFF, PURGED_AT, 1)).toBe(1) + const ids = (await tombstones.listAll()).map((row) => row.id) + expect(ids).toContain(older.id) + expect(ids).not.toContain(newer.id) + expect(await tombstones.purgeRevoked(CUTOFF, PURGED_AT, 1)).toBe(1) + expect((await tombstones.listAll()).map((row) => row.id)).toContain(newer.id) + }) + + test("deleting a key writes its tombstone in one statement; a repeat on the same id keeps the first", async () => { + const key = await revokedKey("twicekey", REVOKED_AT, { owner: "bob" }) + const tombstones = createApiKeyTombstoneRepository(fixture.db()) + expect(await tombstones.deleteKey(key.id, PURGED_AT)).toBe(true) + expect(await createApiKeyRepository(fixture.db()).findById(key.id)).toBeUndefined() + // Gone already: nothing to delete, nothing written. + expect(await tombstones.deleteKey(key.id, new Date("2040-01-01T00:00:00.000Z"))).toBe(false) + + // A row with the same id coming back (a delete racing a purge) must not collide on the PK. + await fixture + .db() + .insert(apiKeys) + .values({ ...key }) + expect(await tombstones.deleteKey(key.id, new Date("2040-01-01T00:00:00.000Z"))).toBe(true) + const rows = (await tombstones.listAll()).filter((row) => row.id === key.id) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ name: "twicekey", owner: "bob", purgedAt: PURGED_AT }) + }) + + test("purge boundary: revoked_at exactly at the cutoff is kept", async () => { + const atCutoff = await revokedKey("boundary1", CUTOFF, {}) + const justBefore = await revokedKey("boundary2", new Date(CUTOFF.getTime() - 1), {}) + const tombstones = createApiKeyTombstoneRepository(fixture.db()) + expect(await tombstones.purgeRevoked(CUTOFF, PURGED_AT, 100)).toBe(1) + const repo = createApiKeyRepository(fixture.db()) + expect(await repo.findById(atCutoff.id)).toBeDefined() + expect(await repo.findById(justBefore.id)).toBeUndefined() + }) + + test("tombstones are swept by purged_at only: young survive, old go, bounded", async () => { + const tombstones = createApiKeyTombstoneRepository(fixture.db()) + const young = await revokedKey("youngone", REVOKED_AT, {}) + const old = await revokedKey("oldoneey", REVOKED_AT, {}) + await tombstones.deleteKey(young.id, new Date("2030-01-01T00:00:00.000Z")) + await tombstones.deleteKey(old.id, new Date("2001-01-01T00:00:00.000Z")) + const horizon = new Date("2020-01-01T00:00:00.000Z") + expect(await tombstones.deleteOlderThan(horizon, 100)).toBe(1) + const ids = (await tombstones.listAll()).map((row) => row.id) + expect(ids).toContain(young.id) + expect(ids).not.toContain(old.id) + expect(await tombstones.deleteOlderThan(horizon, 100)).toBe(0) + }) +}) diff --git a/packages/db/test/integration/retention.test.ts b/packages/db/test/integration/retention.test.ts index ca93ea6e..36b61083 100644 --- a/packages/db/test/integration/retention.test.ts +++ b/packages/db/test/integration/retention.test.ts @@ -4,11 +4,13 @@ import { createDatabase, type Database, type DatabaseHandle } from "../../src/cl import { defaultMigrationsFolder, runMigrations } from "../../src/migrate" import { createAccountRepository } from "../../src/repositories/account-repository" import { createApiKeyRepository } from "../../src/repositories/api-key-repository" +import { createApiKeyTombstoneRepository } from "../../src/repositories/api-key-tombstone-repository" import { createAuditRepository } from "../../src/repositories/audit-repository" import { createScheduledTaskRepository } from "../../src/repositories/scheduled-task-repository" import { createUsageDailyRepository } from "../../src/repositories/usage-daily-repository" import { createUsageRecordRepository } from "../../src/repositories/usage-repository" import { accounts } from "../../src/schema/accounts" +import { apiKeyTombstones } from "../../src/schema/api-key-tombstones" import { apiKeys } from "../../src/schema/api-keys" import { auditEvents } from "../../src/schema/audit-events" import { scheduledTaskRuns } from "../../src/schema/scheduled-task-runs" @@ -58,6 +60,7 @@ afterAll(async () => { if (handle !== undefined) { // Anything the sweeps under test left behind, plus the rows they are not // allowed to touch. A shared dev database must look untouched afterwards. + await db.delete(apiKeyTombstones).where(inArray(apiKeyTombstones.id, keyIds)) if (keyIds.length > 0) await db.delete(apiKeys).where(inArray(apiKeys.id, keyIds)) if (accountIds.length > 0) await db.delete(accounts).where(inArray(accounts.id, accountIds)) await db.delete(auditEvents).where(eq(auditEvents.kind, "test.retention")) @@ -123,7 +126,7 @@ describe.skipIf(!runnable)("bounded retention deletes against a live database", expect(left).toHaveLength(1) }) - test("revoked keys: purged by revocation age, and only when revoked", async () => { + test("revoked keys: purged (with a tombstone) by revocation age, and only when revoked", async () => { const repository = createApiKeyRepository(db) const swept = await repository.create({ name: "test-retention-swept", @@ -152,10 +155,16 @@ describe.skipIf(!runnable)("bounded retention deletes against a live database", // Revoked with no stamp: no measurable age, so it stays until one exists. await db.update(apiKeys).set({ revoked: true }).where(eq(apiKeys.id, noStamp.id)) - expect(await repository.deleteRevokedOlderThan(CUTOFF, 100)).toBe(1) + const tombstones = createApiKeyTombstoneRepository(db) + expect(await tombstones.purgeRevoked(CUTOFF, new Date("2031-01-01T00:00:00.000Z"), 100)).toBe(1) expect(await repository.findById(swept.id)).toBeUndefined() expect(await repository.findById(liveButStamped.id)).toBeDefined() expect(await repository.findById(noStamp.id)).toBeDefined() + // A tombstone exists for the swept key only. + const stoneIds = (await tombstones.listAll()).map((row) => row.id) + expect(stoneIds).toContain(swept.id) + expect(stoneIds).not.toContain(liveButStamped.id) + expect(stoneIds).not.toContain(noStamp.id) }) test("daily usage: aggregates go by day, and the cutoff's own day stays", async () => { diff --git a/packages/db/test/unit/repositories/bounded-delete.test.ts b/packages/db/test/unit/repositories/bounded-delete.test.ts index fc64a829..60316fc2 100644 --- a/packages/db/test/unit/repositories/bounded-delete.test.ts +++ b/packages/db/test/unit/repositories/bounded-delete.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { Database } from "../../../src/client" -import { createApiKeyRepository } from "../../../src/repositories/api-key-repository" +import { createApiKeyTombstoneRepository } from "../../../src/repositories/api-key-tombstone-repository" import { createAuditRepository } from "../../../src/repositories/audit-repository" import { createOauthStateRepository } from "../../../src/repositories/oauth-state-repository" import { createSessionRepository } from "../../../src/repositories/session-repository" @@ -37,10 +37,10 @@ const SWEEPS: readonly Sweep[] = [ run: (db, limit) => createAuditRepository(db).deleteOlderThan(CUTOFF, limit), }, { - name: "revoked keys", - table: "api_keys", - ageColumn: "revoked_at", - run: (db, limit) => createApiKeyRepository(db).deleteRevokedOlderThan(CUTOFF, limit), + name: "key tombstones", + table: "api_key_tombstones", + ageColumn: "purged_at", + run: (db, limit) => createApiKeyTombstoneRepository(db).deleteOlderThan(CUTOFF, limit), }, { name: "idle sessions", @@ -98,18 +98,17 @@ describe("every sweep deletes a bounded batch, oldest first", () => { describe("what each sweep is allowed to reach", () => { test("revoked keys: the flag narrows the batch, not just the timestamp", async () => { const stub = harness() - await createApiKeyRepository(stub.db).deleteRevokedOlderThan(CUTOFF, 100) + await createApiKeyTombstoneRepository(stub.db).purgeRevoked(CUTOFF, CUTOFF, 100) // `revoked` is what verification reads, so requiring it means the sweep can // only ever reach a key that is already refused. - const { sql, params } = stub.only() - expect(sql).toContain('"api_keys"."revoked" = $2') - expect(params).toContain(true) + const { sql } = stub.only() + expect(sql).toMatch(/"revoked"\s*=\s*true/) }) test("revoked keys: a revocation with no timestamp has no age and is never purged", async () => { const stub = harness() - await createApiKeyRepository(stub.db).deleteRevokedOlderThan(CUTOFF, 100) + await createApiKeyTombstoneRepository(stub.db).purgeRevoked(CUTOFF, CUTOFF, 100) // `revoked_at < cutoff` excludes NULL in SQL, and nothing here coalesces it // into a date. Unpurged is recoverable; purged is not. @@ -117,6 +116,20 @@ describe("what each sweep is allowed to reach", () => { expect(sql).not.toMatch(/coalesce|is null/i) }) + test("revoked keys: strict age, oldest first, bounded, in one statement with the tombstone", async () => { + const stub = harness() + await createApiKeyTombstoneRepository(stub.db).purgeRevoked(CUTOFF, CUTOFF, 100) + + const { sql, params } = stub.only() + expect(sql).toMatch(/"api_keys"\."revoked_at"\s*<\s*\$\d+::timestamptz/) + expect(sql).not.toMatch(/revoked_at"\s*<=/) + expect(sql).toMatch(/order by\s+"api_keys"\."revoked_at"\s+asc\s+limit/i) + expect(sql).toMatch(/insert into "api_key_tombstones"/i) + expect(sql).toMatch(/on conflict \("id"\) do nothing/i) + expect(params).toContain(CUTOFF_PARAM) + expect(params).toContain(100) + }) + test("audit events: age is the only predicate a caller can supply", async () => { const stub = harness() await createAuditRepository(stub.db).deleteOlderThan(CUTOFF, 100)