import { env } from "cloudflare:workers"; import { all, chunks, first, placeholders } from "../db"; import { accountDb } from "../scoped-db"; import { notFoundError } from "../errors"; import { hostingSuspendedKey } from "./metering"; import { purgeAccountHostingCache } from "./cache-purge"; export type HostingSuspension = { accountId: string; name: string; suspendedAt: number }; async function accountByName(name: string): Promise<{ id: string; name: string }> { const account = await first<{ id: string; name: string }>( "SELECT id, name FROM accounts WHERE name = ? COLLATE NOCASE", name, ); if (!account) throw notFoundError("No such account"); return account; } /** Operator action: stops every Site of the account until resumed; plan changes leave it set. */ export async function suspendHosting(name: string): Promise { const { id } = await accountByName(name); await suspendHostingFor(id); return id; } export async function suspendHostingFor(accountId: string): Promise { const now = Date.now(); await accountDb(accountId).run( `INSERT INTO account_billing (account_id, hosting_suspended_at, updated_at) VALUES (?1, ?2, ?2) ON CONFLICT (account_id) DO UPDATE SET hosting_suspended_at = excluded.hosting_suspended_at, updated_at = excluded.updated_at`, accountId, now, ); await env.KV.put(hostingSuspendedKey(accountId), String(now), { metadata: { suspendedAt: now } }); // KV reads are edge-cached for up to a minute; purging the CDN at least stops cached assets from outliving that. await purgeAccountHostingCache(accountId).catch((error: unknown) => console.warn("Hosting cache purge failed", accountId, error), ); } export async function resumeHosting(accountId: string): Promise { await accountDb(accountId).run( "UPDATE account_billing SET hosting_suspended_at = NULL, updated_at = ? WHERE account_id = ?", Date.now(), accountId, ); await env.KV.delete(hostingSuspendedKey(accountId)); } const LISTED_SUSPENSIONS = 200; const MAX_SCANNED_SUSPENSIONS = 10_000; /** * Suspended accounts, newest first, from the KV markers the hosting gate already reads (so the admin page never * walks every account). Markers written before metadata existed are read once for their timestamp. */ export async function listHostingSuspensions(): Promise { const prefix = hostingSuspendedKey(""); const keys: KVNamespaceListKey<{ suspendedAt?: number }>[] = []; let cursor: string | undefined; // Suspensions are rare operator actions; list them all (keys come in id order) before picking the newest. do { const page = await env.KV.list<{ suspendedAt?: number }>({ prefix, cursor }); keys.push(...page.keys); cursor = page.list_complete ? undefined : page.cursor; } while (cursor && keys.length < MAX_SCANNED_SUSPENSIONS); const entries = await Promise.all( keys.map(async (key) => ({ accountId: key.name.slice(prefix.length), suspendedAt: key.metadata?.suspendedAt ?? Number((await env.KV.get(key.name)) ?? 0), })), ); if (entries.length === 0) return []; const names = new Map(); for (const part of chunks(entries.map((entry) => entry.accountId))) { const rows = await all<{ id: string; name: string }>( `SELECT id, name FROM accounts WHERE id IN (${placeholders(part.length)})`, ...part, ); for (const row of rows) names.set(row.id, row.name); } return entries .flatMap((entry) => { const name = names.get(entry.accountId); return name ? [{ ...entry, name }] : []; }) .sort((left, right) => right.suspendedAt - left.suspendedAt) .slice(0, LISTED_SUSPENSIONS); }