From fe0d4d91bf3891289a8ce8a419ef75d0703f244f Mon Sep 17 00:00:00 2001 From: ivndev001 Date: Thu, 15 Oct 2026 05:11:29 +0000 Subject: [PATCH 1/9] test(keys): failing tests for key tombstones (#224) A purged or deleted key must keep its attribution for the usage-daily horizon: purge writes the tombstone in the same statement as the delete, the label loader falls back to it, verification/reveal/list never read it. Co-Authored-By: Claude Sonnet 5.5 --- .../test/integration/key-tombstones.test.ts | 213 ++++++++++++++++++ .../integration/api-key-tombstones.test.ts | 115 ++++++++++ 2 files changed, 328 insertions(+) create mode 100644 apps/api/test/integration/key-tombstones.test.ts create mode 100644 packages/db/test/integration/api-key-tombstones.test.ts 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..6992e7cb --- /dev/null +++ b/apps/api/test/integration/key-tombstones.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, test } from "bun:test" +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 swept = await tick(new Date(Date.now() + 40 * DAY)) + 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", + }) + }) + + test("operator delete writes the same tombstone, and the label survives", async () => { + const { keysService, keys, usage, tombstones } = services(() => new Date()) + 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 stone = (await tombstones.listAll()).find((row) => row.id === id) + expect(stone).toMatchObject({ owner: "ci", ownerKind: "service", tool: "agent" }) + }) + + 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) + + await tick(new Date(t0.getTime() + 731 * DAY)) + expect((await tombstones.listAll()).some((row) => row.id === id)).toBe(false) + }) + + test("verification, reveal and the admin list never see a tombstone", async () => { + const { keysService, keys } = services(() => new Date()) + const created = await keysService.create({ name: "gone/key" } as never) + if (!created.ok) throw new Error("create failed") + const { id, value } = created.value + await keysService.remove(id) + + const prefix = value.slice(0, 13) + expect(await keys.findUsableByPrefix(prefix, new Date())).toEqual([]) + const revealed = await keysService.reveal(id) + expect(revealed.ok).toBe(false) + 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/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..dcfcff2e --- /dev/null +++ b/packages/db/test/integration/api-key-tombstones.test.ts @@ -0,0 +1,115 @@ +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("recording an existing tombstone again is a no-op, not a primary-key collision", async () => { + const key = await revokedKey("twicekey", REVOKED_AT, { owner: "bob" }) + const tombstones = createApiKeyTombstoneRepository(fixture.db()) + await tombstones.record(key, PURGED_AT) + await tombstones.record(key, new Date("2040-01-01T00:00:00.000Z")) + const rows = (await tombstones.listAll()).filter((row) => row.id === key.id) + expect(rows).toHaveLength(1) + expect(rows[0]?.purgedAt).toEqual(PURGED_AT) + // record() never deletes the key: that is the caller's statement, in the same transaction. + expect(await fixture.db().select().from(apiKeys).where(eq(apiKeys.id, key.id))).toHaveLength(1) + }) + + 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.record(young, new Date("2030-01-01T00:00:00.000Z")) + await tombstones.record(old, 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) + }) +}) From d5cca27b9493765a5c9754ed69a9403d615c0dfb Mon Sep 17 00:00:00 2001 From: ivndev001 Date: Thu, 15 Oct 2026 05:23:45 +0000 Subject: [PATCH 2/9] feat(keys): keep a key's attribution after its row is purged (#224) Janitor purge deletes the key and writes api_key_tombstones in one CTE statement; operator delete writes it in the same transaction. The usage label loader names a key by live row else tombstone (carrying owner, owner kind, tool); the wire shape is unchanged. Tombstones are swept at RETENTION_USAGE_DAILY_DAYS. Migration 0036, spec, changelog, v2.31.0. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 7 + CLAUDE.md | 2 +- Dockerfile | 2 +- apps/api/package.json | 2 +- apps/api/src/composition/admin.ts | 7 +- apps/api/src/composition/index.ts | 2 + .../src/composition/runtime-repositories.ts | 4 + apps/api/src/composition/runtime-scheduler.ts | 4 +- apps/api/src/scheduler/tasks/index.ts | 6 +- apps/api/src/scheduler/tasks/janitor.ts | 22 +- apps/api/src/services/keys/mutations.ts | 2 + apps/api/src/services/usage-read/labels.ts | 42 +- apps/api/src/services/usage-read/recent.ts | 3 +- apps/api/src/services/usage-read/service.ts | 1 + .../src/services/usage-read/summary-view.ts | 7 +- apps/api/src/services/usage-read/summary.ts | 4 +- apps/api/src/services/usage-read/types.ts | 14 +- apps/api/test/integration/usage-admin.test.ts | 2 +- apps/api/test/support/memory-mutations.ts | 2 + apps/api/test/unit/scheduler/janitor.test.ts | 27 +- apps/api/test/unit/usage/usage-read.test.ts | 2 +- apps/api/test/unit/usage/usage-recent.test.ts | 2 +- apps/web/package.json | 2 +- docs/idea/02-domain-model.md | 3 +- docs/idea/09-deployment.md | 3 +- package.json | 2 +- packages/core/package.json | 2 +- packages/core/src/version.ts | 2 +- .../db/migrations/0036_key_tombstones.sql | 11 + .../db/migrations/meta/0036_snapshot.json | 3171 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 9 +- packages/db/package.json | 2 +- packages/db/src/index.ts | 7 + .../repositories/admin-mutation-repository.ts | 6 + .../db/src/repositories/api-key-repository.ts | 8 +- .../api-key-tombstone-repository.ts | 94 + packages/db/src/repositories/index.ts | 1 + packages/db/src/schema/api-key-tombstones.ts | 33 + packages/db/src/schema/index.ts | 1 + 39 files changed, 3470 insertions(+), 53 deletions(-) create mode 100644 packages/db/migrations/0036_key_tombstones.sql create mode 100644 packages/db/migrations/meta/0036_snapshot.json create mode 100644 packages/db/src/repositories/api-key-tombstone-repository.ts create mode 100644 packages/db/src/schema/api-key-tombstones.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3956ebc2..562a4486 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` writes it in the same transaction, 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`, the horizon of the rollups they label. 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.0] — 2026-10-15 ### Added @@ -1238,3 +1244,4 @@ your tooling config. [2.28.1]: https://github.com/developerz-ai/multi-ai-router/releases/tag/v2.28.1 [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.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 3577cd36..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.0" \ + 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 ec460b66..d45f8e9f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@multi-ai-router/api", - "version": "2.30.0", + "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..d46c7270 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: ApiKeyTombstoneRepository 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..46657fb6 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,7 +11,7 @@ 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 * them as one answer — "the janitor ran 4 min ago and deleted 812 rows" @@ -21,10 +21,11 @@ import { runSweeps } from "./sweep" * **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. + * 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. * - * Two of the six grow slowly enough to have been overlooked rather than + * Two of the seven grow slowly enough to have been overlooked rather than * excluded, and both are here for the same reason the other four 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 @@ -48,7 +49,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 +76,8 @@ 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. + const staleTombstones = before(now, retention.usageDailyDays * DAY_MS) const report = await runSweeps( [ @@ -109,7 +112,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..4c1da2a5 100644 --- a/apps/api/src/services/keys/mutations.ts +++ b/apps/api/src/services/keys/mutations.ts @@ -157,6 +157,8 @@ export function createKeyMutations(deps: KeysServiceDeps): 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..7756e748 100644 --- a/apps/api/src/services/usage-read/recent.ts +++ b/apps/api/src/services/usage-read/recent.ts @@ -3,6 +3,7 @@ import { isSuccessOutcome, UsageOutcome, usageOutcomeFault } from "@multi-ai-rou import type { RecentAttemptRow } from "@multi-ai-router/db" import { z } from "zod" import type { UsageLabelSets } from "./service" +import { keyNames } from "./summary-view" /** * The live request feed's contract: what the console may ask for, and what one @@ -157,7 +158,7 @@ export function toRecentAttemptView( correlationId: row.correlationId, clientRequestId: row.clientRequestId, attempt: row.attempt, - key: subject(row.apiKeyId, labels.keys), + key: subject(row.apiKeyId, keyNames(labels.keys)), account: subject(row.accountId, labels.accounts), pool: subject(row.poolId, labels.pools), provider: row.provider, diff --git a/apps/api/src/services/usage-read/service.ts b/apps/api/src/services/usage-read/service.ts index 22fe88a2..48614617 100644 --- a/apps/api/src/services/usage-read/service.ts +++ b/apps/api/src/services/usage-read/service.ts @@ -7,6 +7,7 @@ import type { UsageWindowQuery } from "./window" /** Summary facts share one database snapshot; labels are current best-effort decoration. */ export type { + KeyLabel, UsageBreakdownRow, UsageCoverage, UsageLabelSets, 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..cc50f354 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" @@ -52,6 +53,17 @@ 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 +} + /** * Display names for every id a breakdown can carry, read once per summary. * @@ -59,7 +71,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/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/memory-mutations.ts b/apps/api/test/support/memory-mutations.ts index 962869df..633575c2 100644 --- a/apps/api/test/support/memory-mutations.ts +++ b/apps/api/test/support/memory-mutations.ts @@ -27,6 +27,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: { record: async () => {} }, 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..b4294f82 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, }) }) @@ -113,7 +125,10 @@ describe("the janitor sweep", () => { }, auditEvents: { deleteOlderThan: record("auditEvents") }, taskRuns: { deleteOlderThan: record("taskRuns") }, - apiKeys: { deleteRevokedOlderThan: record("revokedKeys") }, + keyTombstones: { + purgeRevoked: async (cutoff, _at, limit) => record("revokedKeys")(cutoff, limit), + deleteOlderThan: record("keyTombstones"), + }, retention: { ...RETENTION, usageDays: 90, usageDailyDays: 730, taskRunsDays: 30 }, intervalMs: 60_000, batchSize: 2, @@ -155,7 +170,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 6972499b..aa82563a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@multi-ai-router/web", - "version": "2.30.0", + "version": "2.31.0", "private": true, "type": "module", "scripts": { diff --git a/docs/idea/02-domain-model.md b/docs/idea/02-domain-model.md index 34cc7c1c..13a8c778 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 immediately. 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) 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 a2aafe10..f236a2d0 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 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. 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 3d633a82..2ea58259 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-ai-router", - "version": "2.30.0", + "version": "2.31.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/core/package.json b/packages/core/package.json index 96d75eb7..003d498a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@multi-ai-router/core", - "version": "2.30.0", + "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 9799da0f..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.0" +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/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..e32112e9 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 } ] -} +} \ No newline at end of file diff --git a/packages/db/package.json b/packages/db/package.json index 7b975644..49cecd1b 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@multi-ai-router/db", - "version": "2.30.0", + "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..105daa6b 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -64,6 +64,11 @@ 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, + TombstoneSource, +} from "./repositories/api-key-tombstone-repository" +export { createApiKeyTombstoneRepository } from "./repositories/api-key-tombstone-repository" export type { AppendAuditEventInput, AuditRepository, @@ -190,6 +195,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..3949ce40 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" @@ -30,6 +34,7 @@ export interface AdminMutationScope { | "listKeysScopedToPool" | "replaceScopeTargets" > + readonly tombstones: Pick readonly pools: PoolRepository readonly accounts: Pick readonly audit: Pick @@ -74,6 +79,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..ccd50a69 100644 --- a/packages/db/src/repositories/api-key-repository.ts +++ b/packages/db/src/repositories/api-key-repository.ts @@ -50,8 +50,12 @@ export interface ApiKeyRepository { * 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. + * the rows a configured window later. Usage history survives — `usage_records.api_key_id` has + * had no FK since 0030, so the id stays on every usage row — and scope rows cascade. + * + * **Does not write a tombstone.** The janitor purges through + * `ApiKeyTombstoneRepository.purgeRevoked`, which does it in the same statement; this method + * would lose the key's name. * * 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. 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..69c1c135 --- /dev/null +++ b/packages/db/src/repositories/api-key-tombstone-repository.ts @@ -0,0 +1,94 @@ +import type { KeyOwnerKind } from "@multi-ai-router/core" +import { asc, sql } from "drizzle-orm" +import type { DatabaseExecutor } from "../client" +import { type ApiKeyTombstoneRow, apiKeyTombstones } from "../schema/api-key-tombstones" +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()`. + */ +export interface ApiKeyTombstoneRepository { + /** + * Deletes keys revoked before `cutoff` — one bounded batch, oldest revocation first — and writes + * their tombstones in the same statement, so a crash cannot lose a key's name with its row. + * 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 + /** + * Records one key's tombstone, for the operator delete (which removes the row itself in the same + * transaction). `ON CONFLICT DO NOTHING`: a delete racing a janitor purge must not collide. + */ + record(key: TombstoneSource, 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 +} + +export interface TombstoneSource { + readonly id: string + readonly name: string + readonly prefix: string + readonly owner: string | null + readonly ownerKind: KeyOwnerKind | null + readonly tool: string | null +} + +export function createApiKeyTombstoneRepository(db: DatabaseExecutor): ApiKeyTombstoneRepository { + return { + purgeRevoked: async (cutoff, purgedAt, limit) => { + // Dates go as ISO text with an explicit cast: this is raw SQL, so no column encoder runs. + const rows = await db.execute(sql` + WITH gone AS ( + DELETE FROM api_keys + WHERE id IN ( + SELECT id FROM api_keys + WHERE revoked = true AND revoked_at < ${cutoff.toISOString()}::timestamptz + ORDER BY revoked_at ASC + LIMIT ${limit} + ) + RETURNING id, name, prefix, owner, owner_kind, tool + ), written AS ( + INSERT INTO api_key_tombstones (id, name, prefix, owner, owner_kind, tool, purged_at) + SELECT id, name, prefix, owner, owner_kind, tool, ${purgedAt.toISOString()}::timestamptz + FROM gone + ON CONFLICT (id) DO NOTHING + ) + SELECT count(*)::int AS deleted FROM gone`) + const first = (rows as unknown as readonly { deleted: number }[])[0] + return first?.deleted ?? 0 + }, + + record: async (key, purgedAt) => { + await db + .insert(apiKeyTombstones) + .values({ + id: key.id, + name: key.name, + prefix: key.prefix, + owner: key.owner, + ownerKind: key.ownerKind, + tool: key.tool, + purgedAt, + }) + .onConflictDoNothing({ target: apiKeyTombstones.id }) + }, + + 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..3e520c4a --- /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) or the same + * transaction (operator delete) and swept on `RETENTION_USAGE_DAILY_DAYS`, the horizon of 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" From 265547c84b92e90a3df09cb65c39e6b6a93238f8 Mon Sep 17 00:00:00 2001 From: ivndev001 Date: Thu, 15 Oct 2026 05:27:26 +0000 Subject: [PATCH 3/9] test(keys): move revoked-key purge safety tests onto purgeRevoked (#224) ApiKeyRepository.deleteRevokedOlderThan is about to be removed because it purges without a tombstone. The tests that pinned it are moved, not dropped: the retention integration test keeps its three cases (revoked+stamped swept, stamped-but-not-revoked kept, revoked-without-stamp kept) via purgeRevoked and adds that a tombstone exists for the swept key only. In the bounded-delete unit harness the revoked-keys sweep entry becomes a key-tombstones entry (deleteOlderThan on purged_at), and the two 'allowed to reach' tests assert the same properties on purgeRevoked's raw SQL (requires revoked = true; no coalesce or is null on revoked_at). The harness captures db.execute, so no assertion moved to the integration test. Co-Authored-By: Claude Sonnet 5.5 --- .../db/test/integration/retention.test.ts | 13 +++++++++++-- .../unit/repositories/bounded-delete.test.ts | 19 +++++++++---------- 2 files changed, 20 insertions(+), 12 deletions(-) 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..0773adfc 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. From 5c52a4f58cb960817c37323aa0380dcf02351222 Mon Sep 17 00:00:00 2001 From: ivndev001 Date: Thu, 15 Oct 2026 05:29:36 +0000 Subject: [PATCH 4/9] refactor(keys): remove ApiKeyRepository.deleteRevokedOlderThan (#224) It purged revoked keys without writing a tombstone, so any caller would lose the key's attribution. The janitor uses ApiKeyTombstoneRepository.purgeRevoked, which deletes and tombstones in one statement. Its safety tests were moved onto purgeRevoked in the previous commit. Co-Authored-By: Claude Sonnet 5.5 --- .../db/src/repositories/api-key-repository.ts | 33 ------------------- .../api-key-tombstone-repository.ts | 3 ++ 2 files changed, 3 insertions(+), 33 deletions(-) diff --git a/packages/db/src/repositories/api-key-repository.ts b/packages/db/src/repositories/api-key-repository.ts index ccd50a69..aab5642c 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 @@ -44,23 +43,6 @@ export interface ApiKeyRepository { 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` has - * had no FK since 0030, so the id stays on every usage row — and scope rows cascade. - * - * **Does not write a tombstone.** The janitor purges through - * `ApiKeyTombstoneRepository.purgeRevoked`, which does it in the same statement; this method - * would lose the key's name. - * - * 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 listPoolTargets(apiKeyId: string): Promise listAccountTargets(apiKeyId: string): Promise @@ -231,21 +213,6 @@ export function createApiKeyRepository(db: DatabaseExecutor): ApiKeyRepository { 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 index 69c1c135..740207ca 100644 --- a/packages/db/src/repositories/api-key-tombstone-repository.ts +++ b/packages/db/src/repositories/api-key-tombstone-repository.ts @@ -41,6 +41,9 @@ export interface TombstoneSource { 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: async (cutoff, purgedAt, limit) => { // Dates go as ISO text with an explicit cast: this is raw SQL, so no column encoder runs. const rows = await db.execute(sql` From 9d5fce4d9eaf59014774a8a51c634e2a91ef0f95 Mon Sep 17 00:00:00 2001 From: ivndev001 Date: Thu, 15 Oct 2026 06:04:57 +0000 Subject: [PATCH 5/9] fix(keys): review round 1 on tombstones (#224) Operator delete is now one statement in ApiKeyTombstoneRepository.deleteKey (DELETE ... RETURNING feeding INSERT ... ON CONFLICT DO NOTHING, purged_at from deps.now()), wired through AdminMutationScope; ApiKeyRepository.delete is removed since nothing else called it, so no code path deletes a key without its tombstone. Tombstones are swept at RETENTION_USAGE_DAILY_DAYS + 1 day (rollups age by whole UTC day). The recent feed projects key names once per poll. The tombstone SQL interpolates the drizzle tables and columns and destructures the execute result. Comment, README and docs drift fixed; journal newline restored. The in-memory test support follows the new scope shape. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/composition/admin.ts | 2 +- apps/api/src/scheduler/tasks/janitor.ts | 15 ++- apps/api/src/services/keys/mutations.ts | 7 +- apps/api/src/services/usage-read/labels.ts | 6 + apps/api/src/services/usage-read/recent.ts | 9 +- apps/api/src/services/usage-read/service.ts | 6 +- apps/api/src/services/usage-read/types.ts | 9 +- apps/api/test/support/memory-keys.ts | 4 +- apps/api/test/support/memory-mutations.ts | 3 +- apps/web/src/lib/api/usage.ts | 2 +- docs/idea/02-domain-model.md | 2 +- docs/idea/09-deployment.md | 2 +- packages/db/README.md | 3 +- packages/db/migrations/meta/_journal.json | 2 +- packages/db/src/index.ts | 5 +- .../repositories/admin-mutation-repository.ts | 3 +- .../db/src/repositories/api-key-repository.ts | 9 +- .../api-key-tombstone-repository.ts | 123 ++++++++++-------- 18 files changed, 122 insertions(+), 90 deletions(-) diff --git a/apps/api/src/composition/admin.ts b/apps/api/src/composition/admin.ts index d46c7270..7b9d13f9 100644 --- a/apps/api/src/composition/admin.ts +++ b/apps/api/src/composition/admin.ts @@ -101,7 +101,7 @@ export interface AdminPlaneDeps { readonly accounts: AccountRepository readonly refreshLock: Pick readonly keys: ApiKeyRepository - readonly keyTombstones: ApiKeyTombstoneRepository + readonly keyTombstones: Pick readonly pools: PoolRepository readonly auditEvents: AuditRepository readonly oauthStates: OauthStateRepository diff --git a/apps/api/src/scheduler/tasks/janitor.ts b/apps/api/src/scheduler/tasks/janitor.ts index 46657fb6..b4f6fe44 100644 --- a/apps/api/src/scheduler/tasks/janitor.ts +++ b/apps/api/src/scheduler/tasks/janitor.ts @@ -13,20 +13,21 @@ import { runSweeps } from "./sweep" /** * 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 + * 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. + * 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 seven grow slowly enough to have been overlooked rather than - * excluded, and both are here for the same reason the other four are: `usage_daily` + * 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, @@ -76,8 +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. - const staleTombstones = before(now, retention.usageDailyDays * 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( [ diff --git a/apps/api/src/services/keys/mutations.ts b/apps/api/src/services/keys/mutations.ts index 4c1da2a5..5b2174b9 100644 --- a/apps/api/src/services/keys/mutations.ts +++ b/apps/api/src/services/keys/mutations.ts @@ -156,9 +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}"`) - // Same transaction as the delete: the key's name outlives its row for the rollups. - await tx.tombstones.record(current, deps.now()) + // 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 6407e032..c667e901 100644 --- a/apps/api/src/services/usage-read/labels.ts +++ b/apps/api/src/services/usage-read/labels.ts @@ -13,6 +13,12 @@ import type { KeyLabel, UsageLabelSets } from "./service" * 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 diff --git a/apps/api/src/services/usage-read/recent.ts b/apps/api/src/services/usage-read/recent.ts index 7756e748..5cc606c8 100644 --- a/apps/api/src/services/usage-read/recent.ts +++ b/apps/api/src/services/usage-read/recent.ts @@ -2,8 +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 { keyNames } from "./summary-view" +import type { DisplayNames } from "./types" /** * The live request feed's contract: what the console may ask for, and what one @@ -94,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. */ @@ -151,14 +150,14 @@ export interface RecentView { export function toRecentAttemptView( row: RecentAttemptRow, - labels: UsageLabelSets, + labels: DisplayNames, ): RecentAttemptView { return { id: row.id, correlationId: row.correlationId, clientRequestId: row.clientRequestId, attempt: row.attempt, - key: subject(row.apiKeyId, keyNames(labels.keys)), + key: subject(row.apiKeyId, labels.keys), account: subject(row.accountId, labels.accounts), pool: subject(row.poolId, labels.pools), provider: row.provider, diff --git a/apps/api/src/services/usage-read/service.ts b/apps/api/src/services/usage-read/service.ts index 48614617..e57540b4 100644 --- a/apps/api/src/services/usage-read/service.ts +++ b/apps/api/src/services/usage-read/service.ts @@ -2,6 +2,7 @@ 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. */ @@ -55,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/types.ts b/apps/api/src/services/usage-read/types.ts index cc50f354..d860c454 100644 --- a/apps/api/src/services/usage-read/types.ts +++ b/apps/api/src/services/usage-read/types.ts @@ -40,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 @@ -64,6 +64,13 @@ export interface KeyLabel { 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. * 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 633575c2..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. */ @@ -28,7 +29,7 @@ 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: { record: async () => {} }, + tombstones: { deleteKey: async (id) => remove(store.rows.keys, id) }, pools: store.pools, accounts: store.accounts, audit: store.audit, 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/docs/idea/02-domain-model.md b/docs/idea/02-domain-model.md index 13a8c778..38fa73c5 100644 --- a/docs/idea/02-domain-model.md +++ b/docs/idea/02-domain-model.md @@ -447,7 +447,7 @@ means a restarted conversation, not a relocated one. See the table under | **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 immediately. Historical `UsageRecord` rows stay joinable | | **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) 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" | +| **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 f236a2d0..ef933543 100644 --- a/docs/idea/09-deployment.md +++ b/docs/idea/09-deployment.md @@ -259,7 +259,7 @@ One background janitor service, one schedule, every window env-tunable. | 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. 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 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. 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. | +| 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/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/meta/_journal.json b/packages/db/migrations/meta/_journal.json index e32112e9..1b2f6300 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -262,4 +262,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 105daa6b..3a000a79 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -64,10 +64,7 @@ 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, - TombstoneSource, -} from "./repositories/api-key-tombstone-repository" +export type { ApiKeyTombstoneRepository } from "./repositories/api-key-tombstone-repository" export { createApiKeyTombstoneRepository } from "./repositories/api-key-tombstone-repository" export type { AppendAuditEventInput, diff --git a/packages/db/src/repositories/admin-mutation-repository.ts b/packages/db/src/repositories/admin-mutation-repository.ts index 3949ce40..42c9b577 100644 --- a/packages/db/src/repositories/admin-mutation-repository.ts +++ b/packages/db/src/repositories/admin-mutation-repository.ts @@ -27,14 +27,13 @@ export interface AdminMutationScope { | "findById" | "findByName" | "update" - | "delete" | "markRevoked" | "listPoolTargets" | "listAccountTargets" | "listKeysScopedToPool" | "replaceScopeTargets" > - readonly tombstones: Pick + readonly tombstones: Pick readonly pools: PoolRepository readonly accounts: Pick readonly audit: Pick diff --git a/packages/db/src/repositories/api-key-repository.ts b/packages/db/src/repositories/api-key-repository.ts index aab5642c..e834bd86 100644 --- a/packages/db/src/repositories/api-key-repository.ts +++ b/packages/db/src/repositories/api-key-repository.ts @@ -41,8 +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 + // 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 @@ -208,11 +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 - }, - 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 index 740207ca..980b0b2c 100644 --- a/packages/db/src/repositories/api-key-tombstone-repository.ts +++ b/packages/db/src/repositories/api-key-tombstone-repository.ts @@ -1,7 +1,7 @@ -import type { KeyOwnerKind } from "@multi-ai-router/core" -import { asc, sql } from "drizzle-orm" +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" /** @@ -10,33 +10,77 @@ import { deleteOldestBatch } from "./bounded-delete" * 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 code that deletes an `api_keys` row.** 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, so a crash cannot lose a key's name with its row. - * 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. + * 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 /** - * Records one key's tombstone, for the operator delete (which removes the row itself in the same - * transaction). `ON CONFLICT DO NOTHING`: a delete racing a janitor purge must not collide. + * The operator delete: removes one key and writes its tombstone in the same statement. + * `true` when a row was removed. `ON CONFLICT DO NOTHING`, so a delete racing a janitor purge + * cannot collide on the primary key. Scope rows cascade. */ - record(key: TombstoneSource, purgedAt: Date): Promise + 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 } -export interface TombstoneSource { - readonly id: string - readonly name: string - readonly prefix: string - readonly owner: string | null - readonly ownerKind: KeyOwnerKind | null - readonly tool: string | null +/** 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 { @@ -44,43 +88,20 @@ export function createApiKeyTombstoneRepository(db: DatabaseExecutor): ApiKeyTom // 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: async (cutoff, purgedAt, limit) => { - // Dates go as ISO text with an explicit cast: this is raw SQL, so no column encoder runs. - const rows = await db.execute(sql` - WITH gone AS ( - DELETE FROM api_keys - WHERE id IN ( - SELECT id FROM api_keys - WHERE revoked = true AND revoked_at < ${cutoff.toISOString()}::timestamptz - ORDER BY revoked_at ASC - LIMIT ${limit} - ) - RETURNING id, name, prefix, owner, owner_kind, tool - ), written AS ( - INSERT INTO api_key_tombstones (id, name, prefix, owner, owner_kind, tool, purged_at) - SELECT id, name, prefix, owner, owner_kind, tool, ${purgedAt.toISOString()}::timestamptz - FROM gone - ON CONFLICT (id) DO NOTHING - ) - SELECT count(*)::int AS deleted FROM gone`) - const first = (rows as unknown as readonly { deleted: number }[])[0] - return first?.deleted ?? 0 - }, + 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, + ), - record: async (key, purgedAt) => { - await db - .insert(apiKeyTombstones) - .values({ - id: key.id, - name: key.name, - prefix: key.prefix, - owner: key.owner, - ownerKind: key.ownerKind, - tool: key.tool, - purgedAt, - }) - .onConflictDoNothing({ target: apiKeyTombstones.id }) - }, + deleteKey: async (id, purgedAt) => + (await deleteAndTombstone(db, sql`${apiKeys.id} = ${id}::uuid`, purgedAt)) > 0, listAll: () => db.select().from(apiKeyTombstones).orderBy(asc(apiKeyTombstones.purgedAt)), From 3ccd893a75cd15afe1b466c8311adcb9b60d9681 Mon Sep 17 00:00:00 2001 From: ivndev001 Date: Thu, 15 Oct 2026 06:04:58 +0000 Subject: [PATCH 6/9] test(keys): tighten tombstone tests after review (#224) Why the tests changed: the AC6 test used value.slice(0,13) but the stored prefix is routerKeyDisplayPrefix(value) (17 chars), so its empty-lookup assertion was vacuous; it now uses the real prefix, adds a positive control before removal, asserts reveal is 404 not_found, and runs after both an operator delete and a janitor purge. Re-added the purge boundary pins lost when the revoked-key tests moved (strict revoked_at <, ORDER BY revoked_at ASC, LIMIT, cutoff and limit params, and revoked_at == cutoff is kept). Pinned the one clock (janitor passes the tick as purged_at; tombstone window is 731 days). The sweep-window test moves from +731 to +732 days because the sweep horizon is now usageDailyDays + 1 day. Operator-delete test asserts owner/kind/tool through the loader. The tests that used the removed record() now use deleteKey(). Co-Authored-By: Claude Sonnet 5.5 --- .../test/integration/key-tombstones.test.ts | 73 ++++++++++++++----- apps/api/test/unit/scheduler/janitor.test.ts | 11 ++- .../integration/api-key-tombstones.test.ts | 33 +++++++-- .../unit/repositories/bounded-delete.test.ts | 16 +++- 4 files changed, 105 insertions(+), 28 deletions(-) diff --git a/apps/api/test/integration/key-tombstones.test.ts b/apps/api/test/integration/key-tombstones.test.ts index 6992e7cb..346b303c 100644 --- a/apps/api/test/integration/key-tombstones.test.ts +++ b/apps/api/test/integration/key-tombstones.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { routerKeyDisplayPrefix } from "@multi-ai-router/core" import { createAdminMutationRepository, createApiKeyRepository, @@ -127,7 +128,8 @@ describe.skipIf(!historyUrl)("a purged key keeps its attribution", () => { expect(revoked.ok).toBe(true) // Revoked just now; the tick is 40 days on: past revokedKeysDays (30), inside usageDays (90). - const swept = await tick(new Date(Date.now() + 40 * DAY)) + const tickAt = new Date(Date.now() + 40 * DAY) + const swept = await tick(tickAt) expect(swept.outcome).toBe("success") expect(await keys.findById(id)).toBeUndefined() @@ -146,6 +148,8 @@ describe.skipIf(!historyUrl)("a purged key keeps its attribution", () => { 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 () => { @@ -167,8 +171,17 @@ describe.skipIf(!historyUrl)("a purged key keeps its attribution", () => { 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 stone = (await tombstones.listAll()).find((row) => row.id === id) - expect(stone).toMatchObject({ owner: "ci", ownerKind: "service", tool: "agent" }) + const loaded = await catalogLabels({ + keys, + tombstones, + catalog: { accounts: () => [], pools: () => [] }, + })() + expect(loaded.keys.get(id)).toEqual({ + name: "ci/agent", + owner: "ci", + ownerKind: "service", + tool: "agent", + }) }) test("a tombstone survives inside the usage-daily window and is swept once older", async () => { @@ -182,25 +195,49 @@ describe.skipIf(!historyUrl)("a purged key keeps its attribution", () => { 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) }) - test("verification, reveal and the admin list never see a tombstone", async () => { - const { keysService, keys } = services(() => new Date()) - const created = await keysService.create({ name: "gone/key" } as never) - if (!created.ok) throw new Error("create failed") - const { id, value } = created.value - await keysService.remove(id) - - const prefix = value.slice(0, 13) - expect(await keys.findUsableByPrefix(prefix, new Date())).toEqual([]) - const revealed = await keysService.reveal(id) - expect(revealed.ok).toBe(false) - const listed = await keysService.list() - if (!listed.ok) throw new Error("list failed") - expect(listed.value.some((view) => view.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()) diff --git a/apps/api/test/unit/scheduler/janitor.test.ts b/apps/api/test/unit/scheduler/janitor.test.ts index b4294f82..42ba25f6 100644 --- a/apps/api/test/unit/scheduler/janitor.test.ts +++ b/apps/api/test/unit/scheduler/janitor.test.ts @@ -106,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 => { @@ -126,7 +127,10 @@ describe("the janitor sweep", () => { auditEvents: { deleteOlderThan: record("auditEvents") }, taskRuns: { deleteOlderThan: record("taskRuns") }, keyTombstones: { - purgeRevoked: async (cutoff, _at, limit) => record("revokedKeys")(cutoff, limit), + purgeRevoked: async (cutoff, at, limit) => { + purgedAt = at + return record("revokedKeys")(cutoff, limit) + }, deleteOlderThan: record("keyTombstones"), }, retention: { ...RETENTION, usageDays: 90, usageDailyDays: 730, taskRunsDays: 30 }, @@ -141,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 () => { diff --git a/packages/db/test/integration/api-key-tombstones.test.ts b/packages/db/test/integration/api-key-tombstones.test.ts index dcfcff2e..c52c816d 100644 --- a/packages/db/test/integration/api-key-tombstones.test.ts +++ b/packages/db/test/integration/api-key-tombstones.test.ts @@ -87,24 +87,41 @@ describe.skipIf(!historyUrl)("api key tombstones against a live database", () => expect((await tombstones.listAll()).map((row) => row.id)).toContain(newer.id) }) - test("recording an existing tombstone again is a no-op, not a primary-key collision", async () => { + 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()) - await tombstones.record(key, PURGED_AT) - await tombstones.record(key, new Date("2040-01-01T00:00:00.000Z")) + 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]?.purgedAt).toEqual(PURGED_AT) - // record() never deletes the key: that is the caller's statement, in the same transaction. - expect(await fixture.db().select().from(apiKeys).where(eq(apiKeys.id, key.id))).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.record(young, new Date("2030-01-01T00:00:00.000Z")) - await tombstones.record(old, new Date("2001-01-01T00:00:00.000Z")) + 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) diff --git a/packages/db/test/unit/repositories/bounded-delete.test.ts b/packages/db/test/unit/repositories/bounded-delete.test.ts index 0773adfc..60316fc2 100644 --- a/packages/db/test/unit/repositories/bounded-delete.test.ts +++ b/packages/db/test/unit/repositories/bounded-delete.test.ts @@ -103,7 +103,7 @@ describe("what each sweep is allowed to reach", () => { // `revoked` is what verification reads, so requiring it means the sweep can // only ever reach a key that is already refused. const { sql } = stub.only() - expect(sql).toMatch(/revoked\s*=\s*true/) + expect(sql).toMatch(/"revoked"\s*=\s*true/) }) test("revoked keys: a revocation with no timestamp has no age and is never purged", async () => { @@ -116,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) From 89022b60e4732ccfb7c33a071ff71d9b4716c537 Mon Sep 17 00:00:00 2001 From: ivndev001 Date: Thu, 15 Oct 2026 06:13:17 +0000 Subject: [PATCH 7/9] test(keys): admin-mutation test cleanup no longer uses removed ApiKeyRepository.delete (#224) The Postgres admin-mutation fixture deleted its seeded keys through ApiKeyRepository.delete, which the previous commit removed. Cleanup now issues raw SQL against api_keys and api_key_tombstones; it is test teardown only, and no assertion changed. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/test/support/admin-mutation-database.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/api/test/support/admin-mutation-database.ts b/apps/api/test/support/admin-mutation-database.ts index deef3bf3..954c61a4 100644 --- a/apps/api/test/support/admin-mutation-database.ts +++ b/apps/api/test/support/admin-mutation-database.ts @@ -97,7 +97,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) From ef75789e310e9a5371065f86e42923d549d1e56d Mon Sep 17 00:00:00 2001 From: ivndev001 Date: Thu, 15 Oct 2026 07:04:28 +0000 Subject: [PATCH 8/9] docs(keys): #245 review round 2: delete dialog, KEYS.md and comments match tombstones - The delete confirmation no longer says usage stops naming the key: it stays attributed until the usage history it labels ages out. Revoke is still the softer door (row and scope stay listed through the revoked-key grace period). - docs/KEYS.md: a purged or deleted key keeps its attribution through its tombstone for RETENTION_USAGE_DAILY_DAYS plus one day; 'hand back is revoke' is re-justified. - CHANGELOG and the tombstone schema comment: the operator delete is one statement, and the sweep is RETENTION_USAGE_DAILY_DAYS plus one day. - Tombstone repository comments: only production code deletes api_keys rows there; ON CONFLICT is belt and braces, not race handling. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- apps/web/src/routes/keys/KeyDangerDialogs.tsx | 4 ++-- docs/KEYS.md | 11 ++++++----- .../src/repositories/api-key-tombstone-repository.ts | 7 ++++--- packages/db/src/schema/api-key-tombstones.ts | 6 +++--- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6157488..6f208e3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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` writes it in the same transaction, 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`, the horizon of the rollups they label. 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. +- 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 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/packages/db/src/repositories/api-key-tombstone-repository.ts b/packages/db/src/repositories/api-key-tombstone-repository.ts index 980b0b2c..8ca71a49 100644 --- a/packages/db/src/repositories/api-key-tombstone-repository.ts +++ b/packages/db/src/repositories/api-key-tombstone-repository.ts @@ -11,7 +11,7 @@ import { deleteOldestBatch } from "./bounded-delete" * 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 code that deletes an `api_keys` row.** Both deletes (the janitor purge and the + * **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. */ @@ -24,8 +24,9 @@ export interface ApiKeyTombstoneRepository { 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. `ON CONFLICT DO NOTHING`, so a delete racing a janitor purge - * cannot collide on the primary key. Scope rows cascade. + * `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. */ diff --git a/packages/db/src/schema/api-key-tombstones.ts b/packages/db/src/schema/api-key-tombstones.ts index 3e520c4a..cf9fd53b 100644 --- a/packages/db/src/schema/api-key-tombstones.ts +++ b/packages/db/src/schema/api-key-tombstones.ts @@ -6,9 +6,9 @@ import { keyOwnerKind } from "./enums" * * `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) or the same - * transaction (operator delete) and swept on `RETENTION_USAGE_DAILY_DAYS`, the horizon of the - * rollups it labels. + * 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 From 1f8e074b25cd5b8d7c1a85e5af2d7574317b541f Mon Sep 17 00:00:00 2001 From: ivndev001 Date: Thu, 15 Oct 2026 07:04:30 +0000 Subject: [PATCH 9/9] test(keys): #245 review round 2: pin the operator-delete clock; snapshot tombstones on rollback The operator-delete test now injects a fixed clock and asserts the tombstone's purged_at equals it (changing the service to new Date() previously passed every test). The admin mutation rollback snapshot includes the subject's tombstones, so a rolled-back remove must leave none. Both additions tighten coverage; no assertion is relaxed. Co-Authored-By: Claude Opus 5.5 --- apps/api/test/integration/key-tombstones.test.ts | 5 ++++- apps/api/test/support/admin-mutation-database.ts | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/api/test/integration/key-tombstones.test.ts b/apps/api/test/integration/key-tombstones.test.ts index 346b303c..d8e22397 100644 --- a/apps/api/test/integration/key-tombstones.test.ts +++ b/apps/api/test/integration/key-tombstones.test.ts @@ -153,7 +153,8 @@ describe.skipIf(!historyUrl)("a purged key keeps its attribution", () => { }) test("operator delete writes the same tombstone, and the label survives", async () => { - const { keysService, keys, usage, tombstones } = services(() => new Date()) + const deletedAt = new Date(Date.now() - 1000) + const { keysService, keys, usage, tombstones } = services(() => deletedAt) const created = await keysService.create({ name: "ci/agent", owner: "ci", @@ -182,6 +183,8 @@ describe.skipIf(!historyUrl)("a purged key keeps its attribution", () => { 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 () => { diff --git a/apps/api/test/support/admin-mutation-database.ts b/apps/api/test/support/admin-mutation-database.ts index 954c61a4..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(), } },