import { env } from "cloudflare:workers"; import { all, first } from "./db"; import { accountDb, repoDb } from "./scoped-db"; import { badRequest, notFoundError } from "./errors"; import { queueEmail, emailTemplates } from "./email"; import { publicUrl } from "./events/emit"; import { notifyUsers } from "./notify/deliver"; import { stripe, stripeEnabled } from "./billing/config"; import { ACTIVE_STATUSES, customerFor } from "./billing/checkout"; import { domainAddon } from "./billing/domain-addon"; import { resumeHosting, suspendHostingFor } from "./sites/suspend"; import { dropMcpAccess } from "./access-revocation"; import { stopAllAccountContainers } from "./editor/container-admin"; import { cancelRunRow } from "./ci/run-control"; import type { WorkflowRunRow } from "./ci/jobs"; import { accountDisableState, clearAccountDisabled, disabledNotice, markAccountDisabled } from "./account-disable"; import type { DisableReason } from "~/lib/account-disable"; const MAX_RUNS_CANCELLED = 200; const NOTIFIED_TTL_SECONDS = 90 * 24 * 60 * 60; type Disable = { accountId: string; reason: DisableReason; note: string | null; by: string }; /** Stripe answers a deleted subscription with resource_missing; anything else must make the trigger retry. */ function alreadyGone(error: unknown): boolean { return typeof error === "object" && error !== null && Reflect.get(error, "code") === "resource_missing"; } /** Ends the plan and add-on subscriptions now, with no proration credit, so nothing is charged again. */ export async function cancelAccountSubscriptions(accountId: string): Promise { if (!stripeEnabled()) return; const [customer, addon] = await Promise.all([customerFor(accountId), domainAddon(accountId)]); const ids = [ ACTIVE_STATUSES.has(customer?.subscription_status ?? "") ? customer?.subscription_id : null, ACTIVE_STATUSES.has(addon?.status ?? "") ? addon?.subscription_id : null, ].filter((id): id is string => typeof id === "string" && id !== ""); for (const id of ids) { try { const live = await stripe().subscriptions.retrieve(id); if (live.status === "canceled" || live.status === "incomplete_expired") continue; await stripe().subscriptions.cancel(id, { prorate: false, invoice_now: false }); } catch (error) { if (!alreadyGone(error)) throw error; } } } function suspendedByDisableKey(accountId: string): string { return `account-disable-suspended:${accountId}`; } /** Leaves an operator's earlier hosting suspension alone, so re-enabling only lifts the one the disable made. */ async function suspendHostingOnce(accountId: string): Promise { if (await env.KV.get(suspendedByDisableKey(accountId))) return; const existing = await accountDb(accountId).first<{ at: number | null }>( "SELECT hosting_suspended_at AS at FROM account_billing WHERE account_id = ?", accountId, ); if (existing?.at) return; await suspendHostingFor(accountId); await env.KV.put(suspendedByDisableKey(accountId), "1"); } async function cancelAccountRuns(accountId: string): Promise { const repos = await all<{ id: string }>("SELECT id FROM repos WHERE owner_id = ?", accountId); let budget = MAX_RUNS_CANCELLED; for (const repo of repos) { if (budget <= 0) break; const runs = await repoDb(repo.id).all( `SELECT * FROM workflow_runs WHERE repo_id = ? AND status != 'completed' LIMIT ${budget}`, repo.id, ); budget -= runs.length; for (const runRow of runs) await cancelRunRow(runRow); } } /** The people told about it: the user, or an org's owners. */ async function accountRecipients(accountId: string): Promise<{ name: string; email: string }[]> { return all<{ name: string; email: string }>( `SELECT a.name, u.email FROM users u JOIN accounts a ON a.id = u.account_id WHERE u.account_id = ?1 UNION SELECT a.name, u.email FROM org_members m JOIN users u ON u.account_id = m.user_id JOIN accounts a ON a.id = u.account_id WHERE m.org_id = ?1 AND m.role = 'owner'`, accountId, ); } async function siteAdmins(): Promise { const rows = await all<{ account_id: string }>("SELECT account_id FROM users WHERE is_admin = 1 LIMIT 50"); return rows.map((row) => row.account_id); } /** Tells every site admin; failures are logged, never thrown. `path` is the forge page the notification links to. */ export async function notifyAdmins(title: string, details: string[], key: string, path = "/admin"): Promise { try { await notifyUsers({ category: "quota", repoId: null, accountId: null, access: "account", recipients: await siteAdmins(), title, details, url: publicUrl(path), dedupeKey: key, }); } catch (error) { console.error("Admin notification failed", key, error); } } export async function notifyAdminsAboutAccount(_accountId: string, title: string, details: string[], key: string) { await notifyAdmins(title, details, key); } /** Cached MCP access to the account's repositories is re-derived, so takedowns and re-enables apply at once. */ async function dropAccountMcpAccess(accountId: string): Promise { const repos = await all<{ id: string }>("SELECT id FROM repos WHERE owner_id = ?", accountId); for (const repo of repos) await dropMcpAccess(repo.id); } /** * Disables an account and stops everything that costs money. Every step is idempotent and re-runs on a retried * trigger (a failed Stripe call makes the webhook retry). The emails go out once per disable, after every step * succeeded, so a retry that follows a failure still sends them. */ export async function disableAccount(input: Disable): Promise { const fresh = await markAccountDisabled(input.accountId, input.reason, input.note); await cancelAccountSubscriptions(input.accountId); await suspendHostingOnce(input.accountId); await stopAllAccountContainers(input.accountId, "account disabled"); await cancelAccountRuns(input.accountId); await dropAccountMcpAccess(input.accountId); const state = await accountDisableState(input.accountId); if (!state) return fresh; if (!fresh && state.reason !== input.reason) { await notifyAdminsAboutAccount( input.accountId, `Account ${input.accountId} is already disabled (${state.reason}) and was disabled again for ${input.reason}`, [`By: ${input.by}`, ...(input.note ? [`Note: ${input.note}`] : [])], `account-disabled-again:${input.accountId}:${input.reason}:${input.note ?? ""}`, ); } const marker = `account-disabled-notified:${input.accountId}:${state.at}`; if (await env.KV.get(marker)) return fresh; await notifyDisabled({ ...input, reason: state.reason, note: state.note }); await env.KV.put(marker, "1", { expirationTtl: NOTIFIED_TTL_SECONDS }); return fresh; } async function notifyDisabled(input: { accountId: string; reason: string; note: string | null; by: string }) { const account = await first<{ name: string }>("SELECT name FROM accounts WHERE id = ?", input.accountId); const name = account?.name ?? input.accountId; const { message } = disabledNotice(input.reason); for (const recipient of await accountRecipients(input.accountId)) { await queueEmail(recipient.email, emailTemplates.accountDisabled(recipient.name, name, message)).catch( (error: unknown) => console.error("Disabled-account email failed", input.accountId, error), ); } await notifyAdminsAboutAccount( input.accountId, `Account ${name} was disabled (${input.reason})`, [`By: ${input.by}`, ...(input.note ? [`Note: ${input.note}`] : [])], `account-disabled:${input.accountId}:${input.reason}:${input.note ?? ""}`, ); } /** Clears the flag and resumes hosting; subscriptions stay cancelled until the customer subscribes again. */ export async function enableAccount(accountId: string, by: string): Promise { const cleared = await clearAccountDisabled(accountId); if (!cleared) return false; await dropAccountMcpAccess(accountId); if (await env.KV.get(suspendedByDisableKey(accountId))) { await resumeHosting(accountId); await env.KV.delete(suspendedByDisableKey(accountId)); } const account = await first<{ name: string }>("SELECT name FROM accounts WHERE id = ?", accountId); console.warn("Account re-enabled", accountId, by); await notifyAdminsAboutAccount( accountId, `Account ${account?.name ?? accountId} was re-enabled`, [`By: ${by}`], `account-enabled:${accountId}:${new Date().toISOString().slice(0, 16)}`, ); return true; } /** Admin action by account name; site admins cannot disable themselves (or every admin could be locked out). */ export async function adminDisableAccount(name: string, note: string | null, adminId: string): Promise { const account = await first<{ id: string }>("SELECT id FROM accounts WHERE name = ? COLLATE NOCASE", name); if (!account) throw notFoundError("No such account"); if (account.id === adminId) throw badRequest("You cannot disable your own account"); await disableAccount({ accountId: account.id, reason: "admin", note, by: `admin:${adminId}` }); return account.id; }