import { all, batch, first, stmt } from "./db"; import { badRequest, conflict, forbidden, notFoundError } from "./errors"; import { canAdministerAccount, loadRepoById } from "./repos"; import { deleteRepository } from "./repo-ops"; import { deleteRunnerRow } from "./ci/runner-registration"; import { disableSite } from "./sites/site"; import { forgetSite, siteDb, sitesOwnedBy } from "./sites/store"; import { accountDb } from "./scoped-db"; import { dropAccountState } from "./account-scope"; import { cancelDomainAddon } from "./billing/domain-addon"; import type { Viewer } from "~/lib/types"; import { assertMayDeleteOrg } from "./verified-writes"; export const MAX_ORG_REPOS_DELETED = 100; /** * Deletes an organization. Owners only, with the org name typed as confirmation. Refuses while repositories * remain unless `deleteRepos`, in which case each goes through `deleteRepository`. The org's current and * previous names are retired so they can never be re-registered (GIT-9); everything keyed to the account * (memberships, runners, registration tokens, secrets, sites, projects, LFS usage) cascades with the row. */ export async function deleteOrganization(input: { org: string; confirm: string; deleteRepos: boolean; viewer: Viewer; }): Promise<{ deletedRepos: string[] }> { const org = await first<{ id: string; name: string; disabled_at: number | null }>( "SELECT id, name, disabled_at FROM accounts WHERE name = ? AND kind = 'org'", input.org, ); if (!org) throw notFoundError("Organization not found"); if (org.disabled_at && !input.viewer.isAdmin) throw forbidden("This organization is disabled and can't be deleted"); await assertMayDeleteOrg(org.id, input.viewer); if (!(await canAdministerAccount(org.id, input.viewer))) { const member = await first( "SELECT 1 AS ok FROM org_members WHERE org_id = ? AND user_id = ?", org.id, input.viewer.id, ); throw member ? forbidden("Only organization owners can delete it") : notFoundError("Organization not found"); } if (input.confirm !== org.name) throw badRequest(`Type ${org.name} to confirm`); const repos = await all<{ id: string; name: string }>( "SELECT id, name FROM repos WHERE owner_id = ? ORDER BY name", org.id, ); if (repos.length > 0 && !input.deleteRepos) { throw conflict(`Delete or transfer its repositories first: ${repos.map((repo) => repo.name).join(", ")}`); } if (repos.length > MAX_ORG_REPOS_DELETED) { throw conflict(`Too many repositories to delete at once (${repos.length}); delete some first`); } await cancelDomainAddon(org.id); for (const { id } of repos) { const repo = await loadRepoById(id); if (repo) await deleteRepository(repo, input.viewer); } const runners = await accountDb(org.id).all<{ id: string }>("SELECT id FROM runners WHERE owner_id = ?", org.id); for (const runner of runners) await deleteRunnerRow(runner.id); // Sites without a repository (claimed uploads) are not torn down by deleteRepository. const sites = await sitesOwnedBy(org.id); for (const site of sites) { await disableSite(site); await siteDb(site).run("DELETE FROM sites WHERE id = ?", site.id); await forgetSite(site); } const now = Date.now(); const previousNames = await all<{ old_name: string }>( "SELECT old_name FROM account_redirects WHERE account_id = ?", org.id, ); await batch([ ...[org.name, ...previousNames.map((row) => row.old_name)].map((name) => stmt("INSERT OR IGNORE INTO retired_account_names (name, retired_at) VALUES (?, ?)", name, now), ), stmt("DELETE FROM integrations WHERE scope_type = 'org' AND scope_id = ?", org.id), stmt("DELETE FROM org_members WHERE org_id = ?", org.id), stmt("DELETE FROM accounts WHERE id = ?", org.id), ]); // Invitations were mirrored into each invitee's AccountState; the org's own object goes whole. const invitees = await accountDb(org.id).all<{ user_id: string }>( "SELECT user_id FROM org_invitations WHERE org_id = ?", org.id, ); for (const { user_id } of invitees) { await accountDb(user_id).run("DELETE FROM org_invitations WHERE org_id = ?", org.id); } await dropAccountState(org.id); return { deletedRepos: repos.map((repo) => repo.name) }; }