import { all, chunks, placeholders } from "./db"; import { accountDb, repoDb, stmt } from "./scoped-db"; import { forgetLocator } from "./scoped-db/locators"; import { forEachScope } from "./account-scope"; import { backfillDone, backfillRepos } from "./backfill"; /** * Deploy tokens live in their repository's RepoState, but are revoked by who made them: the personal token that * minted them, or their creator on a password reset. The creator's AccountState keeps `deploy_token_index` so * those revocations visit only the repositories that can hold such a token, never every repository. The index * may hold stale rows (a token revoked another way); visiting one is a harmless no-op. */ export const DEPLOY_TOKEN_INDEX = "deploy-token-index"; export async function recordDeployToken( creatorId: string, token: { id: string; repoId: string; parentTokenId: string | null }, ): Promise { await accountDb(creatorId).run( "INSERT OR REPLACE INTO deploy_token_index (token_id, repo_id, parent_token_id, created_at) VALUES (?, ?, ?, ?)", token.id, token.repoId, token.parentTokenId, Date.now(), ); } /** Repositories to visit: from the index once it covers every older token, else (until then) all of them. */ async function candidateRepos(userId: string, where: string, params: string[]): Promise { if (!(await backfillDone(DEPLOY_TOKEN_INDEX))) { return (await all<{ id: string }>("SELECT id FROM repos")).map((row) => row.id); } const rows = await accountDb(userId).all<{ repo_id: string }>( `SELECT DISTINCT repo_id FROM deploy_token_index WHERE ${where}`, ...params, ); return rows.map((row) => row.repo_id); } async function revokeIn(repoIds: string[], where: string, params: string[]): Promise { await forEachScope(repoIds, async (repoId) => { const rows = await repoDb(repoId).all<{ token_hash: string }>( `DELETE FROM deploy_tokens WHERE ${where} RETURNING token_hash`, ...params, ); await Promise.all(rows.map((row) => forgetLocator("deploy_token", row.token_hash))); }); } /** Deploy tokens minted by these personal tokens of `userId`; they go with them. */ export async function revokeDeployTokensMintedBy(userId: string, tokenIds: string[]): Promise { for (const group of chunks(tokenIds)) { const list = placeholders(group.length); const repoIds = await candidateRepos(userId, `parent_token_id IN (${list})`, group); await revokeIn(repoIds, `parent_token_id IN (${list})`, group); await accountDb(userId).run(`DELETE FROM deploy_token_index WHERE parent_token_id IN (${list})`, ...group); } } /** Every deploy token `userId` created, wherever (they may administer repositories they don't own). */ export async function revokeAllDeployTokensCreatedBy(userId: string): Promise { const repoIds = await candidateRepos(userId, "1 = 1", []); await revokeIn(repoIds, "created_by = ?", [userId]); await accountDb(userId).run("DELETE FROM deploy_token_index"); } /** Cron step: indexes deploy tokens created before the index existed, a page of repositories at a time. */ export async function backfillDeployTokenIndex(): Promise { await backfillRepos(DEPLOY_TOKEN_INDEX, async (repoId) => { const tokens = await repoDb(repoId).all<{ id: string; created_by: string | null; parent_token_id: string | null; created_at: number; }>("SELECT id, created_by, parent_token_id, created_at FROM deploy_tokens WHERE created_by IS NOT NULL"); const byCreator = new Map(); for (const token of tokens) { const list = byCreator.get(token.created_by ?? "") ?? []; list.push(token); byCreator.set(token.created_by ?? "", list); } for (const [creatorId, list] of byCreator) { await accountDb(creatorId).batch( list.map((token) => stmt( `INSERT OR IGNORE INTO deploy_token_index (token_id, repo_id, parent_token_id, created_at) VALUES (?, ?, ?, ?)`, token.id, repoId, token.parent_token_id, token.created_at, ), ), ); } }); }