import { chunks, placeholders } from "./db"; import { accountDb, stmt, type ScopedValue } from "./scoped-db"; import { forgetLocator } from "./scoped-db/locators"; import { revokeDeployTokensMintedBy } from "./deploy-token-index"; /** * Revokes personal access tokens of one account with everything they minted: REST children * (`access_tokens.parent_token_id`), MCP delegations (`mcp_token_agents.parent_token_id`), their refresh tokens, * and deploy tokens they created. D1 did this in the `access_tokens_revoke_mcp` trigger; the trigger reached into * `deploy_tokens`, which now lives in each repository's RepoState, so the cascade runs here. Returns the ids * removed (roots included). */ export async function revokeAccessTokens(userId: string, rootIds: string[]): Promise { if (rootIds.length === 0) return []; const db = accountDb(userId); const edges = await db.all<{ child: string; parent: string }>( `SELECT id AS child, parent_token_id AS parent FROM access_tokens WHERE parent_token_id IS NOT NULL UNION SELECT token_id, parent_token_id FROM mcp_token_agents WHERE parent_token_id IS NOT NULL`, ); const ids = new Set(rootIds); let grew = true; while (grew) { grew = false; for (const edge of edges) { if (ids.has(edge.parent) && !ids.has(edge.child)) { ids.add(edge.child); grew = true; } } } const allIds = [...ids]; const removed: { id: string; token_hash: string }[] = []; for (const group of chunks(allIds)) { removed.push( ...(await db.all<{ id: string; token_hash: string }>( `SELECT id, token_hash FROM access_tokens WHERE user_id = ? AND id IN (${placeholders(group.length)})`, userId, ...group, )), ); } if (removed.length === 0) return []; const removedIds = removed.map((row) => row.id); const refreshHashes: string[] = []; for (const group of chunks(removedIds)) { const rows = await db.all<{ token_hash: string }>( `SELECT token_hash FROM oauth_refresh_tokens WHERE access_token_id IN (${placeholders(group.length)})`, ...group, ); refreshHashes.push(...rows.map((row) => row.token_hash)); } await db.batch( chunks(removedIds).flatMap((group) => { const params: ScopedValue[] = group; const list = placeholders(group.length); return [ stmt(`DELETE FROM oauth_refresh_tokens WHERE access_token_id IN (${list})`, ...params), stmt(`DELETE FROM access_token_repos WHERE token_id IN (${list})`, ...params), stmt(`DELETE FROM mcp_token_agents WHERE token_id IN (${list})`, ...params), stmt(`DELETE FROM access_tokens WHERE id IN (${list})`, ...params), ]; }), ); await Promise.all([ ...removed.flatMap((row) => [ forgetLocator("access_token", row.token_hash), forgetLocator("access_token_id", row.id), ]), ...refreshHashes.map((hash) => forgetLocator("oauth_refresh", hash)), ]); await revokeDeployTokensMintedBy(userId, removedIds); return removedIds; } /** Revokes every token of `userId` matching `where` (SQL over `access_tokens`, no alias). */ export async function revokeAccessTokensWhere( userId: string, where: string, ...params: ScopedValue[] ): Promise { const rows = await accountDb(userId).all<{ id: string }>( `SELECT id FROM access_tokens WHERE user_id = ? AND (${where})`, userId, ...params, ); return revokeAccessTokens( userId, rows.map((row) => row.id), ); }