/* RFC-1325: provisional workers.dev host birth support — resolve the account's workers.dev subdomain, verify the passed --domain lives on the account, refuse worker-name collisions before the first write, and derive the deployment block written into system-config.yaml. The resolution result is cached in the git-tracked werkstatt.cloudflare.json so later births and adopters need no re-probe. Worker upload/deploy — leitstand + cloudflare-workers.ts own that. Domain adoption after birth — site.domain.adopt owns that. workerName convention: main = systemId, dev = dev-<systemId>, alt = alt-<systemId> — matches the channelHost() provisional derivation (worker name IS the host label). werkstatt.cloudflare.json lives at the workspace root and is git-tracked — it describes the workshop's single Cloudflare account, not a per-system secret. RFC-1325: initial — deriveProvisionalDeployment, account cache read/write, collision refusal. RFC-1325: provisional workers.dev as first-class site host Single host predicate module in werkstatt-shared; provisional registration derives dev/alt/main workers.dev channels from the account subdomain; first dev deploy auto-protects provisional hosts; provisional main responses carry noindex; dns-aid skips provisional sites; site.domain.adopt cuts over to a custom domain. RFC-1325: review r1 — pin-carrying probes, AC-3 write ordering, dns-aid validate parity, hardened host lint */ import { z } from "zod"; import { join } from "node:path"; import type { WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; import { channelHost } from "@warpgogol/werkstatt-shared/middleware/site-host"; import { getWorkersSubdomain, listWorkerNames } from "../leitstand/adapters/cloudflare-api.ts"; const CACHE_FILE = "werkstatt.cloudflare.json"; const cloudflareAccountCacheSchema = z.object({ accountId: z.string().min(1), workersSubdomain: z.string().min(1), }); export type CloudflareAccountCache = z.infer; export interface ProvisionalDeployment { adapter: "cloudflare-workers"; channels: { dev: { workerName: string; url: string }; alt: { workerName: string; url: string }; main: { workerName: string; url: string }; }; } /** Schema-checked read of the cached account record; null when absent. */ export async function readCloudflareAccountCache( io: WorkspaceIO, workspaceRoot: string, ): Promise { const path = join(workspaceRoot, CACHE_FILE); if (!(await io.exists(path))) return null; const parsed = cloudflareAccountCacheSchema.safeParse(JSON.parse(await io.readFile(path))); if (!parsed.success) { throw new Error( `[sternsystem.register] ${CACHE_FILE} is malformed — fix or delete it so register can re-resolve (${parsed.error.issues[0]?.message ?? "schema mismatch"})`, ); } return parsed.data; } async function writeCloudflareAccountCache( io: WorkspaceIO, workspaceRoot: string, cache: CloudflareAccountCache, ): Promise { await io.writeFile(join(workspaceRoot, CACHE_FILE), `${JSON.stringify(cache, null, 2)}\n`); } /** * Resolve and verify a provisional birth domain, then derive the deployment * block. Throws before ANY file write (AC-3 — including the account cache): * - env missing: `CLOUDFLARE_ACCOUNT_ID` / `CLOUDFLARE_API_TOKEN` (named); * - the domain's account slug disagrees with the live API subdomain; * - ``, `dev-`, or `alt-` already exists as a Worker script. * On success the account record is (re)cached at workspaceRoot — the cache * write happens only after every refusal check has passed. */ export async function deriveProvisionalDeployment( systemId: string, domain: string, io: WorkspaceIO, workspaceRoot: string, env: Record, ): Promise { const parts = domain.split("."); if (parts.length !== 4 || parts[2] !== "workers" || parts[3] !== "dev") { throw new Error( `[sternsystem.register] provisional --domain '${domain}' must have the ..workers.dev shape`, // site-host: operator-facing error text, not host classification ); } const [workerLabel, domainSubdomain] = parts; if (workerLabel !== systemId) { throw new Error( `[sternsystem.register] provisional --domain '${domain}' names worker '${workerLabel}' but --id is '${systemId}' — on workers.dev the worker name IS the host label; pass --domain ${systemId}.${domainSubdomain}.workers.dev`, // site-host: operator-facing error text, not host classification ); } const accountId = env["CLOUDFLARE_ACCOUNT_ID"]; const apiToken = env["CLOUDFLARE_API_TOKEN"]; const missing = [ ...(!accountId ? ["CLOUDFLARE_ACCOUNT_ID"] : []), ...(!apiToken ? ["CLOUDFLARE_API_TOKEN"] : []), ]; if (missing.length > 0) { throw new Error( `[sternsystem.register] provisional host '${domain}' needs ${missing.join(" and ")} in the environment to verify the account subdomain and check worker-name collisions`, ); } const cached = await readCloudflareAccountCache(io, workspaceRoot); let workersSubdomain = cached !== null && cached.accountId === accountId ? cached.workersSubdomain : null; // The API resolution is cached only after every refusal check below passes — // a mismatch/collision failure must leave zero files touched (AC-3). const resolvedFromApi = workersSubdomain === null; if (resolvedFromApi) { workersSubdomain = await getWorkersSubdomain(accountId!, apiToken!); } if (workersSubdomain !== domainSubdomain) { throw new Error( `[sternsystem.register] --domain '${domain}' lives on account subdomain '${domainSubdomain}' but CLOUDFLARE_ACCOUNT_ID resolves to '${workersSubdomain}' — pass the matching account's credentials or fix --domain`, ); } const wanted = { dev: `dev-${workerLabel}`, alt: `alt-${workerLabel}`, main: workerLabel, }; const existing = new Set(await listWorkerNames(accountId!, apiToken!)); const collisions = Object.entries(wanted).filter(([, name]) => existing.has(name)); if (collisions.length > 0) { const listed = collisions.map(([ch, name]) => `${ch}: '${name}'`).join(", "); throw new Error( `[sternsystem.register] worker-name collision on account '${workersSubdomain}' — ${listed} already exist; choose another --domain/--id or delete the stale Workers`, ); } if (resolvedFromApi) { await writeCloudflareAccountCache(io, workspaceRoot, { accountId: accountId!, workersSubdomain: workersSubdomain!, }); } const host = (channel: "dev" | "alt" | "main") => channelHost("provisional-workers-dev", domain, channel, wanted[channel]); return { adapter: "cloudflare-workers", channels: { dev: { workerName: wanted.dev, url: `https://${host("dev")}` }, alt: { workerName: wanted.alt, url: `https://${host("alt")}` }, main: { workerName: wanted.main, url: `https://${domain}` }, }, }; }