import type * as Db from '../Db.js' import type * as db_Schema from '../Schema.js' /** Columns of the `api_key_revocations` table. */ export type Table = db_Schema.ApiKeyRevocation /** Retains fences beyond KV propagation and the one-minute in-process auth cache. */ export const retentionMs = 7 * 24 * 60 * 60_000 /** Non-expiring fence used until KV deletion is confirmed. */ export const pendingExpiresAt = '9999-12-31T23:59:59.999Z' /** Maximum expired fences reclaimed by one revocation. */ export const pruneLimit = 1_000 /** Returns whether one stable API-key id has been revoked. */ export async function exists(db: Db.Db, id: string): Promise { return ( (await db.kysely .selectFrom('api_key_revocations') .select('id') .where('id', '=', id) .where('expiresAt', '>', new Date().toISOString()) .executeTakeFirst()) !== undefined ) } /** Fences one API-key id through the propagation window. Rotations use a new id. */ export async function mark(db: Db.Db, id: string, orgId: string | null): Promise { const time = new Date() await prune(db, time) const organization = orgId ? await db.kysely .selectFrom('organizations') .select('id') .where('id', '=', orgId) .executeTakeFirst() : undefined await db.kysely .insertInto('api_key_revocations') .values({ expiresAt: new Date(time.getTime() + retentionMs).toISOString(), id, orgId: organization?.id ?? null, revokedAt: time.toISOString(), }) .onConflict((conflict) => conflict.column('id').doUpdateSet({ expiresAt: new Date(time.getTime() + retentionMs).toISOString(), }), ) .execute() } /** Fences one API-key id without releasing its slot until KV deletion succeeds. */ export async function markPending(db: Db.Db, id: string, orgId: string | null): Promise { const time = new Date() const now = time.toISOString() const organization = orgId ? await db.kysely .selectFrom('organizations') .select('id') .where('id', '=', orgId) .executeTakeFirst() : undefined await db.kysely .insertInto('api_key_revocations') .values({ expiresAt: pendingExpiresAt, id, orgId: organization?.id ?? null, revokedAt: time.toISOString(), }) .onConflict((conflict) => conflict .column('id') .doUpdateSet({ expiresAt: pendingExpiresAt }) .where('api_key_revocations.expiresAt', '<=', now), ) .execute() } /** Deletes pending fences whose admission slots are being removed with an owner. */ export async function deletePending(db: Db.Db, ids: readonly string[]): Promise { if (ids.length === 0) return await db.kysely .deleteFrom('api_key_revocations') .where('id', 'in', ids) .where('expiresAt', '=', pendingExpiresAt) .execute() } /** Reclaims a bounded batch of fences after stale KV and auth entries have expired. */ export async function prune(db: Db.Db, time = new Date()): Promise { const result = await db.kysely .with( (cte) => cte('expired').materialized(), (qb) => qb .selectFrom('api_key_revocations') .select('id') .where('expiresAt', '<=', time.toISOString()) .orderBy('expiresAt') .orderBy('id') .limit(pruneLimit) .forUpdate() .skipLocked(), ) .with('deleted_admissions', (qb) => qb .deleteFrom('api_key_admissions') .using('expired') .whereRef('api_key_admissions.id', '=', 'expired.id'), ) .deleteFrom('api_key_revocations') .using('expired') .whereRef('api_key_revocations.id', '=', 'expired.id') .returning('api_key_revocations.id') .execute() return result.length }