/** * The pure half of `pnpm onboard`: the manifest schema, the per-step decisions * computed from observed state, dependency skipping, and the report. * * No node:* imports and no subprocesses, so all of it runs in the Workers Vitest * pool (same split as ./operator-session-core.ts). The IO — wrangler, public DNS, * the control plane, 1Password, the generated config file — lives in * scripts/onboard.ts, which observes, calls a `decide*` function here, and only * then (with --apply) acts. * * Outcome vocabulary is src/lib/provision.ts's — `already | done | skipped | * blocked | failed`, every `blocked` carrying a remedy — plus `would_do`, which is * what a dry run says instead of `done`. */ import { z } from "zod"; import { createTransactionalApiKeySchema, syncTransactionalTemplatesSchema, transactionalApiKeyScopeSchema, } from "../../src/api/schemas"; import { normalizeTxtContent } from "../../src/lib/dns-gate"; import type { StepOutcome } from "../../src/lib/provision"; import { validateRecipientPolicy } from "../../src/lib/transactional-send"; import { parseDmarcTags } from "./dmarc"; import { canonicalHost, type MxAnswer } from "./dns-lookup"; import { describeFeedbackVerdict, type FeedbackSubscriptionVerdict } from "./event-subscriptions"; import { literalRoutingRuleArgs, type RoutingRule, type RoutingSettings, ruleDeliversToWorker, ruleMatchesAddress, } from "./routing"; import type { SendingConfigUpdate } from "./sending-config"; import { type DmarcPlan, normalizeZone, type ParsedDnsRecord, resolveDmarcPlan, resolveSendingTarget, type SendingTarget, SPF_VALUE, selectProviderRecords, } from "./sending-plan"; // --------------------------------------------------------------------------- // Manifest // --------------------------------------------------------------------------- const email = z .string() .trim() .email() .transform((value) => value.toLowerCase()); const dmarcSchema = z .object({ policy: z.enum(["none", "quarantine", "reject"]), rua: email.optional(), alignment: z.enum(["relaxed", "strict"]).optional(), }) .strict(); const onePasswordSchema = z .object({ vault: z.string().trim().min(1), title: z.string().trim().min(1), /** Extra text for the item's notes. Never put a secret here. */ notes: z.string().optional(), }) .strict(); const keySchema = z .object({ /** Manifest-local handle. The server has no key names; this only labels the step. */ name: z .string() .trim() .regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/, "use letters, digits, - and _ (max 48)"), environment: z.enum(["live", "test"]).default("live"), sender: email, senderName: z.string().optional(), scopes: z.array(transactionalApiKeyScopeSchema).min(1), /** `"all"` = every id in the templates file; or an explicit list of ids from it. */ templates: z.union([z.literal("all"), z.array(z.string().min(1)).min(1)]).optional(), recipientPolicy: z.string().optional(), quotaMax: z.number().int().positive().optional(), store: z.object({ onePassword: onePasswordSchema }).strict().optional(), }) .strict(); export const onboardManifestSchema = z .object({ zone: z.string().min(1), /** Optional; otherwise discovered (event subscriptions in the zone, or CLOUDFLARE_API_TOKEN). */ zoneId: z .string() .trim() .regex(/^[0-9a-f]{32}$/, "a 32-character hex Cloudflare zone id") .optional(), sending: z .object({ /** Label under the zone (default `send`); `false` provisions no subdomain. */ subdomain: z.union([z.string().min(1), z.literal(false)]).optional(), /** Also provision the zone apex, so the mailbox replies as itself. */ apex: z.boolean().optional(), /** Applied to every provisioned name. Required: the apex has no safe default. */ dmarc: dmarcSchema, }) .strict(), mailbox: z .object({ address: email, displayName: z.string().trim().min(1).max(120).optional(), aliases: z.array(email).optional(), }) .strict(), templates: z .object({ /** Path to a JSON array of {id, subject, body_text?, body_html?} (or {templates: [...]}); relative to the manifest. */ file: z.string().min(1), archiveMissing: z.boolean().optional(), }) .strict() .optional(), keys: z.array(keySchema).optional(), /** * The mailbox's forum topic in the bound Telegram chat. `topicName` creates * one under that name (independent of mailbox.displayName, which is the From * name of its replies); `adoptThreadId` maps an existing topic instead. An * empty object creates one named after the mailbox. */ telegram: z .object({ topicName: z.string().trim().min(1).max(128).optional(), adoptThreadId: z.number().int().positive().optional(), }) .strict() .optional(), }) .strict(); export type OnboardManifestInput = z.input; type ParsedManifest = z.output; export type ManifestKey = ParsedManifest["keys"] extends Array | undefined ? K : never; export type SendingPlanEntry = { target: SendingTarget; dmarc: DmarcPlan; }; export type ResolvedManifest = { zone: string; zoneId?: string; sending: SendingPlanEntry[]; dmarc: { policy: "none" | "quarantine" | "reject"; rua?: string; alignment?: "relaxed" | "strict"; }; mailbox: { address: string; displayName?: string; aliases: string[] }; templates?: { file: string; archiveMissing: boolean }; keys: ManifestKey[]; telegram?: TelegramTopicSpec; warnings: string[]; }; export type TelegramTopicSpec = { topicName?: string; adoptThreadId?: number }; export type Validation = { ok: true; value: T } | { ok: false; errors: string[] }; function zodErrors(error: z.ZodError, prefix = ""): string[] { return error.issues.map((issue) => { const path = issue.path.length > 0 ? issue.path.join(".") : "(root)"; return `${prefix}${path}: ${issue.message}`; }); } function domainOf(address: string): string { return address.slice(address.lastIndexOf("@") + 1); } /** * Shape (zod) plus the cross-field rules the server would otherwise only reject * halfway through a run: addresses on the zone, a key sender on a name this * manifest provisions, a recipient policy the send path can parse. */ export function validateManifest(raw: unknown): Validation { const parsed = onboardManifestSchema.safeParse(raw); if (!parsed.success) return { ok: false, errors: zodErrors(parsed.error) }; const m = parsed.data; const errors: string[] = []; const warnings: string[] = []; const zoneResult = normalizeZone(m.zone); if (!zoneResult.ok) return { ok: false, errors: [`zone: ${zoneResult.error}`] }; const zone = zoneResult.value; const targets: SendingTarget[] = []; if (m.sending.subdomain !== false) { const sub = resolveSendingTarget({ zone, subdomain: m.sending.subdomain }); if (sub.ok) targets.push(sub.value); else errors.push(`sending.subdomain: ${sub.error}`); } if (m.sending.apex) { const apex = resolveSendingTarget({ zone, apex: true }); if (apex.ok && !targets.some((t) => t.sendingDomain === apex.value.sendingDomain)) { targets.push(apex.value); } } if (targets.length === 0 && errors.length === 0) { errors.push("sending: nothing to provision (subdomain is false and apex is not true)."); } const sending: SendingPlanEntry[] = []; for (const target of targets) { const plan = resolveDmarcPlan({ target, policy: m.sending.dmarc.policy, alignment: m.sending.dmarc.alignment, rua: m.sending.dmarc.rua, }); if (!plan.ok) { errors.push(`sending.dmarc (${target.sendingDomain}): ${plan.error}`); continue; } sending.push({ target, dmarc: plan.value }); // setup:sending's "this run will replace the apex DMARC" warning is about a // write; onboard only writes it when the step says so (and its action names // the apex), so only the unconditional warnings are surfaced up front. for (const warning of plan.value.warnings) { if (!warning.startsWith("APEX DMARC:")) warnings.push(`${target.sendingDomain}: ${warning}`); } } const sendingDomains = new Set(targets.map((t) => t.sendingDomain)); const address = m.mailbox.address; if (domainOf(address) !== zone) { errors.push( `mailbox.address: ${address} is not on ${zone} (its routing rule lives in this zone).`, ); } const aliases: string[] = []; for (const [index, alias] of (m.mailbox.aliases ?? []).entries()) { if (domainOf(alias) !== zone) { errors.push(`mailbox.aliases.${index}: ${alias} is not on ${zone}.`); } else if (alias === address) { errors.push(`mailbox.aliases.${index}: ${alias} is the mailbox address itself.`); } else if (aliases.includes(alias)) { errors.push(`mailbox.aliases.${index}: ${alias} is listed twice.`); } else { aliases.push(alias); } } const keys = m.keys ?? []; const names = new Set(); const stores = new Set(); for (const [index, key] of keys.entries()) { const at = `keys.${index} (${key.name})`; if (names.has(key.name)) errors.push(`${at}: duplicate key name.`); names.add(key.name); const senderDomain = domainOf(key.sender); if (!sendingDomains.has(senderDomain)) { errors.push( `${at}.sender: ${key.sender} is not on a sending domain this manifest provisions (${[...sendingDomains].join(", ") || "none"}).`, ); } if (key.recipientPolicy !== undefined) { for (const issue of validateRecipientPolicy(key.recipientPolicy)) { errors.push(`${at}.recipientPolicy: rule ${JSON.stringify(issue.rule)}: ${issue.reason}`); } } if (key.templates !== undefined && !m.templates) { errors.push(`${at}.templates: needs a top-level "templates.file" to resolve against.`); } if (key.store) { const id = `${key.store.onePassword.vault}\u0000${key.store.onePassword.title}`; if (stores.has(id)) { errors.push(`${at}.store: another key already stores to this 1Password vault + title.`); } stores.add(id); } if (key.environment === "test") { warnings.push(`${at}: a test key cannot send (the production send path rejects test keys).`); } } if (errors.length > 0) return { ok: false, errors }; return { ok: true, value: { zone, ...(m.zoneId ? { zoneId: m.zoneId } : {}), sending, dmarc: m.sending.dmarc, mailbox: { address, ...(m.mailbox.displayName ? { displayName: m.mailbox.displayName } : {}), aliases, }, ...(m.templates ? { templates: { file: m.templates.file, archiveMissing: m.templates.archiveMissing ?? false, }, } : {}), keys, ...(m.telegram ? { telegram: m.telegram } : {}), warnings, }, }; } export type TemplateInput = { id: string; subject: string; body_text?: string | null; body_html?: string | null; }; /** A templates file: a bare array, or `{ templates: [...] }`. Validated with the sync route's schema. */ export function parseTemplatesFile(raw: unknown): Validation { const templates = Array.isArray(raw) || raw === null || typeof raw !== "object" ? raw : (raw as { templates?: unknown }).templates; const parsed = syncTransactionalTemplatesSchema.safeParse({ templates }); if (!parsed.success) return { ok: false, errors: zodErrors(parsed.error, "templates file: ") }; return { ok: true, value: parsed.data.templates }; } export type KeyCreateBody = z.output; /** * The exact `POST .../transactional/api-keys` body for each manifest key, run * through the route's own schema — so a body onboard would send is a body the * server accepts (send ⇒ templates:use, send ⇒ a non-empty allowlist, a * header-safe senderName, a parseable policy). */ export function resolveKeyBodies( keys: ManifestKey[], templateIds: string[] | null, ): Validation> { const errors: string[] = []; const bodies = new Map(); for (const key of keys) { let allowlist: string[] | undefined; if (key.templates === "all") { allowlist = [...(templateIds ?? [])]; if (allowlist.length === 0) { errors.push(`keys (${key.name}).templates: "all" but the templates file is empty.`); continue; } } else if (Array.isArray(key.templates)) { const unknown = key.templates.filter((id) => !templateIds?.includes(id)); if (unknown.length > 0) { errors.push( `keys (${key.name}).templates: not in the templates file: ${unknown.join(", ")}.`, ); continue; } allowlist = [...key.templates]; } const body = { environment: key.environment, sender: key.sender, ...(key.senderName !== undefined ? { senderName: key.senderName } : {}), scopes: key.scopes, ...(allowlist ? { templateAllowlist: allowlist } : {}), ...(key.recipientPolicy !== undefined ? { recipientPolicy: key.recipientPolicy } : {}), ...(key.quotaMax !== undefined ? { quotaMax: key.quotaMax } : {}), }; const parsed = createTransactionalApiKeySchema.safeParse(body); if (!parsed.success) { errors.push(...zodErrors(parsed.error, `keys (${key.name}): `)); continue; } bodies.set(key.name, parsed.data); } return errors.length > 0 ? { ok: false, errors } : { ok: true, value: bodies }; } // --------------------------------------------------------------------------- // Steps, outcomes and dependency skipping // --------------------------------------------------------------------------- export type Mode = "dry-run" | "apply"; export type OnboardOutcome = StepOutcome | { state: "would_do"; action: string }; export type Decision

= | { state: "already"; detail: string } | { state: "todo"; action: string; plan: P } | { state: "blocked"; reason: string; remedy: string }; export type StepSpec = { id: string; deps: string[]; run: () => Promise; }; export type StepResult = { id: string; outcome: OnboardOutcome }; /** A dependency is met when it is in place, or (dry run) would be put in place by this run. */ export function dependencyMet(outcome: OnboardOutcome, mode: Mode): boolean { if (outcome.state === "already" || outcome.state === "done") return true; return mode === "dry-run" && outcome.state === "would_do"; } /** * Runs steps in order. A step whose dependency did not succeed is `skipped` * (naming the dependency and its state) without running; nothing else is. A * throw inside a step becomes that step's `failed`, never an abort of the run. */ export async function runSteps( steps: StepSpec[], mode: Mode, onResult?: (result: StepResult) => void, describeError: (error: unknown) => string = (error) => error instanceof Error ? error.message : String(error), ): Promise { const byId = new Map(); const results: StepResult[] = []; for (const step of steps) { const unmet: string[] = []; for (const dep of step.deps) { const result = byId.get(dep); if (!result) throw new Error(`onboard: step ${step.id} depends on unknown/later step ${dep}`); if (!dependencyMet(result.outcome, mode)) unmet.push(`${dep} (${result.outcome.state})`); } let outcome: OnboardOutcome; if (unmet.length > 0) { outcome = { state: "skipped", reason: `needs ${unmet.join(", ")}` }; } else { try { outcome = await step.run(); } catch (error) { outcome = { state: "failed", error: describeError(error) }; } } const result = { id: step.id, outcome }; byId.set(step.id, result); results.push(result); onResult?.(result); } return results; } /** * Turns a decision into an outcome: `already`/`blocked` pass through, `todo` * becomes `would_do` in a dry run and runs `apply` otherwise. */ export async function settle

( decision: Decision

, mode: Mode, apply: (plan: P) => Promise, ): Promise { if (decision.state === "already") return { state: "already", detail: decision.detail }; if (decision.state === "blocked") { return { state: "blocked", reason: decision.reason, remedy: decision.remedy }; } if (mode === "dry-run") return { state: "would_do", action: decision.action }; return apply(decision.plan); } export function isFailure(outcome: OnboardOutcome): boolean { return outcome.state === "failed" || outcome.state === "blocked" || outcome.state === "skipped"; } // --------------------------------------------------------------------------- // Commands the report prints // --------------------------------------------------------------------------- export function deployCommandFor(env: string | undefined): string { return env === "dev" ? "pnpm run deploy:dev" : env ? `pnpm exec tsx scripts/deploy.ts --env ${env}` : "pnpm run deploy"; } /** `pnpm setup:sending ...` argv (without the leading `pnpm`) for one target. */ export function setupSendingArgs(opts: { env: string | undefined; target: SendingTarget; dmarc: ResolvedManifest["dmarc"]; apply: boolean; }): string[] { const { target, dmarc } = opts; return [ "setup:sending", ...(opts.env ? ["--env", opts.env] : []), "--domain", target.zone, ...(target.isApex ? ["--apex"] : ["--subdomain", target.label ?? "send"]), "--dmarc-policy", dmarc.policy, ...(dmarc.alignment ? ["--dmarc-alignment", dmarc.alignment] : []), ...(dmarc.rua ? ["--dmarc-rua", dmarc.rua] : []), ...(opts.apply ? ["--apply"] : []), ]; } export function endpointFor(origin: string, mailboxId: string): string { return `${origin}/v1/mailboxes/${mailboxId}/transactional/messages`; } // --------------------------------------------------------------------------- // Decisions: Cloudflare Email Sending // --------------------------------------------------------------------------- export type SendingObservation = { /** From `wrangler email sending list`; null when it could not be read. */ enabled: boolean | null; /** From `wrangler email sending dns get`; null when unavailable (e.g. not enabled). */ providerRecords: ParsedDnsRecord[] | null; /** Public DNS (DoH) answers; null when the lookup failed. */ spf: string[] | null; /** TXT at the provider's DKIM name; null when failed or not looked up. */ dkim: string[] | null; mx: MxAnswer[] | null; dmarc: string[] | null; feedback: | { verdict: FeedbackSubscriptionVerdict; queue: string } | { error: string; fix: string }; /** Whether CLOUDFLARE_API_TOKEN is set (setup:sending needs it for every DNS upsert). */ tokenPresent: boolean; }; export type SendingTodo = { args: string[]; missing: string[] }; /** * Whether one sending name is whole: enabled, SPF/DKIM/MX/DMARC published as * setup:sending would leave them, and all six lifecycle events reaching our * queue. "Already" is defined so that `setup:sending --apply` on it would change * nothing (same exact-match DMARC comparison it uses). */ export function decideSendingTarget(opts: { env: string | undefined; entry: SendingPlanEntry; dmarc: ResolvedManifest["dmarc"]; observed: SendingObservation; }): Decision { const { target } = opts.entry; const o = opts.observed; const sd = target.sendingDomain; const missing: string[] = []; const dns: string[] = []; const unknown: string[] = []; const verified: string[] = []; if (o.enabled === null) unknown.push(`whether Email Sending is enabled for ${sd}`); else if (!o.enabled) missing.push(`Email Sending is not enabled for ${sd}`); else verified.push("enabled"); // SPF on the bounce name. if (o.spf === null) unknown.push(`TXT ${target.bounceDomain}`); else { const spf = o.spf.filter((txt) => normalizeTxtContent(txt).toLowerCase().startsWith("v=spf1")); if (spf.length > 1) { return { state: "blocked", reason: `${target.bounceDomain} publishes ${spf.length} SPF records; setup:sending refuses to pick one.`, remedy: `Delete all but "${SPF_VALUE}" at ${target.bounceDomain}, then re-run.`, }; } if (spf.length === 1 && normalizeTxtContent(spf[0] ?? "") === SPF_VALUE) verified.push("SPF"); else dns.push(`SPF ${target.bounceDomain} -> ${SPF_VALUE}`); } // Provider DKIM + MX: only knowable once sending is enabled. const provider = o.providerRecords ? selectProviderRecords(o.providerRecords, sd) : null; if (!provider) { if (o.enabled) unknown.push(`the provider's DKIM/MX records (wrangler email sending dns get ${sd})`); else dns.push("DKIM + MX (published by the provider once enabled)"); } else { if (!provider.dkim) { unknown.push( `the provider's DKIM record for ${sd} (not in its output yet — still provisioning?)`, ); } else if (o.dkim === null) { unknown.push(`TXT ${provider.dkim.name}`); } else if ( o.dkim.some( (txt) => normalizeTxtContent(txt) === normalizeTxtContent(provider.dkim?.content ?? ""), ) ) { verified.push("DKIM"); } else { dns.push(`DKIM ${provider.dkim.name}`); } if (provider.mx.length === 0) { unknown.push(`the provider's MX records for ${target.bounceDomain}`); } else if (o.mx === null) { unknown.push(`MX ${target.bounceDomain}`); } else { const absent = provider.mx.filter( (want) => !o.mx?.some( (have) => have.exchange === canonicalHost(want.content) && have.priority === (want.priority ?? 10), ), ); if (absent.length === 0) verified.push(`${provider.mx.length} MX`); else { dns.push( `MX ${target.bounceDomain} ${absent.map((r) => `${canonicalHost(r.content)} (${r.priority})`).join(", ")}`, ); } } } // DMARC, compared exactly as setup:sending compares before deciding to patch. const wantDmarc = opts.entry.dmarc.value; if (o.dmarc === null) unknown.push(`TXT ${target.dmarcDomain}`); else { const records = o.dmarc.filter((txt) => parseDmarcTags(txt) !== null); if ( records.length === 1 && normalizeTxtContent(records[0] ?? "") === normalizeTxtContent(wantDmarc) ) { verified.push( `DMARC p=${opts.entry.dmarc.policy}${opts.dmarc.rua ? ` rua=${opts.dmarc.rua}` : ""}`, ); } else { const now = records.length === 0 ? "(none)" : records.map((r) => `"${normalizeTxtContent(r)}"`).join(" + "); // The apex record is the policy for every sender on the zone, not just Reccado. dns.push( `DMARC ${target.dmarcDomain}: ${now} -> "${wantDmarc}"${target.isApex ? ` (APEX: governs every sender using @${target.zone})` : ""}`, ); } } // Feedback subscription. if ("error" in o.feedback) { return { state: "blocked", reason: `could not verify the event subscription for ${sd}: ${o.feedback.error}`, remedy: o.feedback.fix, }; } const verdict = o.feedback.verdict; if (verdict.state === "wrong_queue") { return { state: "blocked", reason: describeFeedbackVerdict(sd, o.feedback.queue, verdict), remedy: `Delete that subscription (pnpm wrangler queues subscription delete --id ${verdict.subscriptionIds[0]}), then re-run — setup:sending never repoints one.`, }; } if (verdict.state === "live") verified.push("6 events"); else missing.push(describeFeedbackVerdict(sd, o.feedback.queue, verdict)); if (unknown.length > 0) { return { state: "blocked", reason: `could not read ${unknown.join("; ")}.`, remedy: `Check pnpm wrangler login and network access, then re-run. To look yourself: pnpm wrangler email sending dns get ${sd}`, }; } const allMissing = [...missing, ...dns]; if (allMissing.length === 0) { return { state: "already", detail: `${sd}: ${verified.join(", ")}.`, }; } // Enabling alone makes Cloudflare write a p=reject DMARC nobody chose, so a run that // cannot also write DNS would leave the name worse than it found it. if (!o.tokenPresent && (dns.length > 0 || o.enabled === false)) { return { state: "blocked", reason: `${sd} needs DNS writes (${dns.join("; ") || "records published after enabling"}), and CLOUDFLARE_API_TOKEN is not set.`, remedy: `export CLOUDFLARE_API_TOKEN= and re-run (setup:sending upserts SPF/DKIM/MX/DMARC with it).`, }; } const args = setupSendingArgs({ env: opts.env, target, dmarc: opts.dmarc, apply: true }); return { state: "todo", action: `pnpm ${args.join(" ")} # fixes: ${allMissing.join("; ")}`, plan: { args, missing: allMissing }, }; } // --------------------------------------------------------------------------- // Decisions: generated config and the deployed Worker // --------------------------------------------------------------------------- /** Whether writing the generated config would change it, described. */ export function decideSendingConfig( update: SendingConfigUpdate, generatedPath: string, ): Decision { const added = update.sendingDomains.filter((d) => !update.previousSendingDomains.includes(d)); if (!update.changed) { return { state: "already", detail: `${generatedPath} ships MAIL_SENDING_DOMAINS=${update.sendingDomains.join(",")}.`, }; } const parts = [ added.length > 0 ? `MAIL_SENDING_DOMAINS += ${added.join(", ")} (-> ${update.sendingDomains.join(",")})` : `MAIL_SENDING_DOMAINS=${update.sendingDomains.join(",")}`, ]; if (update.previousFrom === undefined) parts.push(`MAIL_FROM_ADDRESS=${update.nextFrom}`); if (update.emailBinding === "bounded") { parts.push(`EMAIL.allowed_sender_addresses=${update.allowedSenders?.join(",")}`); } return { state: "todo", action: `write ${generatedPath}: ${parts.join("; ")}`, plan: undefined }; } /** * Whether the running Worker already carries the sending domains. Onboard never * deploys; a missing domain here is `blocked` on a human running the deploy. */ export function decideDeployedDomains(opts: { env: string | undefined; worker: string; targets: string[]; /** MAIL_SENDING_DOMAINS per live version id; null when unreadable. */ live: Array<{ versionId: string; domains: string[] }> | null; liveError?: string; }): Decision { const deploy = deployCommandFor(opts.env); if (!opts.live || opts.live.length === 0) { return { state: "blocked", reason: `could not read the deployed ${opts.worker} version${opts.liveError ? ` (${opts.liveError})` : ""}.`, remedy: `pnpm wrangler deployments status --name ${opts.worker} --json`, }; } const gaps = opts.live .map((v) => ({ ...v, lacking: opts.targets.filter((d) => !v.domains.includes(d)) })) .filter((v) => v.lacking.length > 0); if (gaps.length === 0) { return { state: "already", detail: `${opts.worker} (version ${opts.live.map((v) => v.versionId.slice(0, 8)).join(", ")}) ships ${opts.targets.join(", ")}.`, }; } return { state: "blocked", reason: `deploy required: ${opts.worker} ${gaps.map((g) => `version ${g.versionId.slice(0, 8)} lacks ${g.lacking.join(", ")}`).join("; ")} in MAIL_SENDING_DOMAINS, so mail from them does not send as itself yet.`, remedy: `${deploy} --dry-run # review the overlay; then: ${deploy}`, }; } // --------------------------------------------------------------------------- // Decisions: Email Routing // --------------------------------------------------------------------------- export function decideRoutingEnabled( zone: string, settings: RoutingSettings | null, ): Decision { if (!settings) { return { state: "blocked", reason: `could not read Email Routing settings for ${zone}.`, remedy: `pnpm wrangler email routing settings ${zone}`, }; } if (!settings.enabled) { return { state: "todo", action: `pnpm wrangler email routing enable ${zone}`, plan: undefined, }; } if (settings.status && settings.status !== "ready") { return { state: "blocked", reason: `Email Routing is enabled on ${zone} but its status is "${settings.status}" (DNS not verified).`, remedy: `Add the records from: pnpm wrangler email routing dns get ${zone}`, }; } return { state: "already", detail: `Email Routing is enabled on ${zone} (status ${settings.status ?? "unknown"}).`, }; } export type RoutingRuleTodo = { args: string[] }; /** * Only creates a literal `to:

` → worker rule when no rule mentions the * address at all. One that does but is disabled, or delivers elsewhere, is * somebody's decision — `blocked`, never overwritten. The catch-all is not * considered here at all. */ export function decideRoutingRule(opts: { zone: string; address: string; worker: string; rules: RoutingRule[] | null; }): Decision { if (!opts.rules) { return { state: "blocked", reason: `could not list Email Routing rules for ${opts.zone}.`, remedy: `pnpm wrangler email routing rules list ${opts.zone}`, }; } const matching = opts.rules.filter((rule) => ruleMatchesAddress(rule, opts.address)); const exact = matching.find( (rule) => rule.enabled && rule.matchers.length === 1 && ruleDeliversToWorker(rule, opts.worker), ); if (exact) { return { state: "already", detail: `rule ${exact.id.slice(0, 8)}${exact.name ? ` "${exact.name}"` : ""}: to:${opts.address} -> worker:${opts.worker}.`, }; } if (matching.length > 0) { const describe = matching .map( (rule) => `${rule.id} (${rule.enabled ? "enabled" : "disabled"}, ${rule.actions.map((a) => `${a.type}${a.values.length ? `:${a.values.join(",")}` : ""}`).join(", ") || "no action"})`, ) .join("; "); return { state: "blocked", reason: `a rule already matches to:${opts.address} but does not deliver it to ${opts.worker}: ${describe}. Not overwriting it.`, remedy: `Decide what ${opts.address} should do: edit that rule to worker:${opts.worker}, or delete it (pnpm wrangler email routing rules delete ${opts.zone} ), then re-run.`, }; } const local = opts.address.slice(0, opts.address.indexOf("@")); const args = literalRoutingRuleArgs({ zone: opts.zone, address: opts.address, worker: opts.worker, name: `${local} -> ${opts.worker}`, }); return { state: "todo", action: `pnpm wrangler ${args.join(" ")}`, plan: { args } }; } // --------------------------------------------------------------------------- // Decisions: control plane // --------------------------------------------------------------------------- export type DomainRowLike = { id: string; domain: string; zone_id: string; status: string }; export type MailboxRowLike = { mailbox_id: string; primary_address: string; display_name: string | null; status: string; owner_email: string | null; }; export type AliasRowLike = { alias_address: string; mailbox_id: string; status: string }; export function decideDomain(opts: { zone: string; domains: DomainRowLike[]; zoneId: string | undefined; zoneIdSource?: string; }): Decision<{ zoneId: string }> { const row = opts.domains.find((d) => d.domain.toLowerCase() === opts.zone); if (row) { if (row.status !== "active") { return { state: "blocked", reason: `${opts.zone} is registered but "${row.status}", so nothing on it routes.`, remedy: `Re-enable it: PATCH /api/domains/${opts.zone} {"status":"active"} (as the operator), then re-run.`, }; } if (opts.zoneId && row.zone_id !== opts.zoneId) { return { state: "blocked", reason: `${opts.zone} is registered with zone_id "${row.zone_id}", but its Cloudflare zone is ${opts.zoneId}${opts.zoneIdSource ? ` (${opts.zoneIdSource})` : ""}.`, remedy: `Correct domains.zone_id for ${opts.zone} in D1 (pnpm wrangler d1 execute --remote --command "UPDATE domains SET zone_id='${opts.zoneId}' WHERE domain='${opts.zone}'"), then re-run.`, }; } return { state: "already", detail: `${opts.zone} is registered (zone ${row.zone_id}${opts.zoneId ? `, matches the zone id ${opts.zoneIdSource ?? "known"}` : "; not cross-checked"}).`, }; } if (!opts.zoneId) { return { state: "blocked", reason: `${opts.zone} is not registered and its Cloudflare zone id could not be discovered.`, remedy: `Add "zoneId" to the manifest (Cloudflare dashboard → ${opts.zone} → Overview → Zone ID), or export CLOUDFLARE_API_TOKEN (Zone · Read) and re-run.`, }; } return { state: "todo", action: `POST /api/domains {"domain":"${opts.zone}","zoneId":"${opts.zoneId}"}`, plan: { zoneId: opts.zoneId }, }; } export type MailboxTodo = | { kind: "create" } | { kind: "converge"; mailboxId: string; displayName?: string; repostForPrimaryAlias: boolean }; export function decideMailbox(opts: { address: string; displayName?: string; mailboxes: MailboxRowLike[]; aliases: AliasRowLike[]; sessionEmail: string | undefined; }): Decision { const row = opts.mailboxes.find((m) => m.primary_address.toLowerCase() === opts.address); const body = JSON.stringify({ primaryAddress: opts.address, ...(opts.displayName ? { displayName: opts.displayName } : {}), }); if (!row) { return { state: "todo", action: `POST /api/mailboxes ${body} (also creates the primary alias; owner = ${opts.sessionEmail ?? "the signed-in operator"})`, plan: { kind: "create" }, }; } if (row.status !== "active") { return { state: "blocked", reason: `mailbox ${row.mailbox_id} for ${opts.address} is "${row.status}".`, remedy: `Re-enable it: PATCH /api/mailboxes/${row.mailbox_id} {"status":"active"}, then re-run.`, }; } if (opts.sessionEmail && row.owner_email?.toLowerCase() !== opts.sessionEmail.toLowerCase()) { return { state: "blocked", reason: `mailbox ${row.mailbox_id} is owned by ${row.owner_email ?? "nobody"}, not ${opts.sessionEmail}; its templates and keys are owner-gated.`, remedy: `Sign in as its owner (pnpm operator login --email ${row.owner_email ?? ""}), or fix mailboxes.owner_email in D1.`, }; } const primary = opts.aliases.find((a) => a.alias_address.toLowerCase() === opts.address); if (primary && (primary.mailbox_id !== row.mailbox_id || primary.status !== "active")) { return { state: "blocked", reason: `the primary alias ${opts.address} is ${primary.status} and points at ${primary.mailbox_id}, not ${row.mailbox_id}.`, remedy: `Fix it: PATCH /api/aliases/${opts.address} {"status":"active"} or DELETE it and re-run (POST /api/mailboxes re-creates it).`, }; } const fixes: string[] = []; const plan: MailboxTodo = { kind: "converge", mailboxId: row.mailbox_id, repostForPrimaryAlias: !primary, }; if (!primary) fixes.push(`POST /api/mailboxes ${body} (re-creates the missing primary alias)`); if (opts.displayName && row.display_name !== opts.displayName) { plan.displayName = opts.displayName; fixes.push( `PATCH /api/mailboxes/${row.mailbox_id} {"displayName":${JSON.stringify(opts.displayName)}} (was ${JSON.stringify(row.display_name)})`, ); } if (fixes.length === 0) { return { state: "already", detail: `${opts.address} is mailbox ${row.mailbox_id}${row.display_name ? ` ("${row.display_name}")` : ""}, owner ${row.owner_email}.`, }; } return { state: "todo", action: fixes.join("; "), plan }; } export function decideAlias(opts: { alias: string; /** The mailbox's id, or undefined when this run would create the mailbox. */ mailboxId: string | undefined; aliases: AliasRowLike[]; }): Decision { const row = opts.aliases.find((a) => a.alias_address.toLowerCase() === opts.alias); const target = opts.mailboxId ?? ""; if (!row) { return { state: "todo", action: `POST /api/aliases {"aliasAddress":"${opts.alias}","mailboxId":"${target}"}`, plan: undefined, }; } if (row.mailbox_id !== opts.mailboxId) { return { state: "blocked", reason: `${opts.alias} already routes to mailbox ${row.mailbox_id}, not ${target}.`, remedy: `If it should move: DELETE /api/aliases/${opts.alias}, then re-run. Otherwise drop it from the manifest.`, }; } if (row.status !== "active") { return { state: "blocked", reason: `${opts.alias} points at ${row.mailbox_id} but is "${row.status}".`, remedy: `PATCH /api/aliases/${opts.alias} {"status":"active"}, then re-run.`, }; } return { state: "already", detail: `${opts.alias} -> mailbox ${row.mailbox_id}.` }; } // --------------------------------------------------------------------------- // Decisions: templates // --------------------------------------------------------------------------- export type ExistingTemplate = { id: string; subject: string; body_text: string | null; body_html: string | null; }; export type TemplateDiff = { create: string[]; update: string[]; unchanged: string[]; archive: string[]; }; /** * What `PUT .../transactional/templates` would do, from the ACTIVE templates the * GET returns. Archived ids are invisible to that GET; the sync reports them as * `archived: already_archived` rather than reviving them, which * `interpretTemplateSync` turns into `blocked`. */ export function diffTemplates( desired: TemplateInput[], existing: ExistingTemplate[], archiveMissing: boolean, ): TemplateDiff { const byId = new Map(existing.map((t) => [t.id, t])); const diff: TemplateDiff = { create: [], update: [], unchanged: [], archive: [] }; for (const t of desired) { const row = byId.get(t.id); if (!row) diff.create.push(t.id); else if ( row.subject === t.subject && (row.body_text ?? null) === (t.body_text ?? null) && (row.body_html ?? null) === (t.body_html ?? null) ) { diff.unchanged.push(t.id); } else diff.update.push(t.id); } if (archiveMissing) { const wanted = new Set(desired.map((t) => t.id)); for (const row of existing) if (!wanted.has(row.id)) diff.archive.push(row.id); } return diff; } export function decideTemplates(opts: { mailboxId: string | undefined; file: string; diff: TemplateDiff; archiveMissing: boolean; }): Decision { const { diff } = opts; if (diff.create.length + diff.update.length + diff.archive.length === 0) { return { state: "already", detail: `${diff.unchanged.length} template(s) match ${opts.file}${opts.archiveMissing ? "; none to archive" : ""}.`, }; } const parts = [ diff.create.length ? `create ${diff.create.join(", ")}` : "", diff.update.length ? `update ${diff.update.join(", ")}` : "", diff.archive.length ? `archive ${diff.archive.join(", ")}` : "", diff.unchanged.length ? `${diff.unchanged.length} unchanged` : "", ].filter(Boolean); return { state: "todo", action: `PUT /api/mailboxes/${opts.mailboxId ?? ""}/transactional/templates {templates from ${opts.file}${opts.archiveMissing ? ", archiveMissing: true" : ""}} (${parts.join("; ")})`, plan: undefined, }; } export type TemplateSyncResult = { id: string; outcome: string; reason?: string }; /** Outcome of a PUT sync. An id that is archived server-side is `blocked`: the sync never revives it. */ export function interpretTemplateSync(results: TemplateSyncResult[]): OnboardOutcome { const archivedSkipped = results.filter( (r) => r.outcome === "archived" && r.reason === "already_archived", ); const counts = { created: 0, updated: 0, unchanged: 0, archived: 0 }; for (const r of results) { if (r.outcome in counts && r.reason !== "already_archived") counts[r.outcome as keyof typeof counts] += 1; } const summary = `created ${counts.created}, updated ${counts.updated}, unchanged ${counts.unchanged}, archived ${counts.archived}`; if (archivedSkipped.length > 0) { return { state: "blocked", reason: `synced (${summary}), but ${archivedSkipped.map((r) => r.id).join(", ")} ${archivedSkipped.length === 1 ? "is" : "are"} archived on the server and the sync never revives an archived id.`, remedy: "Give those templates new ids in the templates file (and any key allowlist), then re-run.", }; } return { state: "done", detail: `templates synced: ${summary}.` }; } // --------------------------------------------------------------------------- // Decisions: API keys + 1Password // --------------------------------------------------------------------------- export type KeyStoreObservation = /** The manifest names no store. */ | { kind: "none" } /** `op` could not be used (not installed, locked, no such vault). */ | { kind: "unavailable"; reason: string } | { kind: "absent" } | { kind: "ambiguous"; itemIds: string[] } | { kind: "present"; itemId: string; keyId?: string; endpoint?: string }; export type ApiKeyEntry = { keyId: string; status: string; environment: string; sender: string; senderName: string | null; scopes: string[]; templateAllowlist: string[] | null; recipientPolicy: string | null; quotaMax: number | null; }; export type KeyTodo = | { kind: "mint" } | { kind: "fix"; itemId: string; keyId: string; senderName?: string | null; endpoint?: string }; function sameSet( a: readonly string[] | null | undefined, b: readonly string[] | null | undefined, ): boolean { const x = [...new Set(a ?? [])].sort(); const y = [...new Set(b ?? [])].sort(); return x.length === y.length && x.every((v, i) => v === y[i]); } /** * Differences between a live key and the body the manifest would mint. Only * `senderName` can be changed in place (PATCH); everything else is fixed at * creation, and even rotation copies it verbatim. */ export function diffKey( body: KeyCreateBody, entry: ApiKeyEntry, ): { senderName: boolean; fixed: string[] } { const fixed: string[] = []; if (entry.environment !== body.environment) fixed.push(`environment ${entry.environment} ≠ ${body.environment}`); if (entry.sender.toLowerCase() !== body.sender.toLowerCase()) fixed.push(`sender ${entry.sender} ≠ ${body.sender}`); if (!sameSet(entry.scopes, body.scopes)) fixed.push(`scopes [${entry.scopes.join(",")}] ≠ [${body.scopes.join(",")}]`); if (!sameSet(entry.templateAllowlist, body.templateAllowlist ?? null)) { const have = new Set(entry.templateAllowlist ?? []); const want = new Set(body.templateAllowlist ?? []); const add = [...want].filter((id) => !have.has(id)); const drop = [...have].filter((id) => !want.has(id)); fixed.push( `templateAllowlist${add.length ? ` lacks ${add.join(",")}` : ""}${drop.length ? `${add.length ? " and" : ""} has extra ${drop.join(",")}` : ""}`, ); } if ((entry.recipientPolicy ?? null) !== (body.recipientPolicy ?? null)) { fixed.push( `recipientPolicy ${JSON.stringify(entry.recipientPolicy)} ≠ ${JSON.stringify(body.recipientPolicy ?? null)}`, ); } if ((entry.quotaMax ?? null) !== (body.quotaMax ?? null)) { fixed.push(`quotaMax ${entry.quotaMax ?? "none"} ≠ ${body.quotaMax ?? "none"}`); } const senderName = (entry.senderName ?? null) !== (body.senderName?.trim() || null); return { senderName, fixed }; } /** * Keys have no name server-side, so the store IS the idempotency key: an item * that holds a `key id` which is active on this mailbox means the key exists. * Every other combination either mints exactly one key (nothing stored yet) or * stops — a second key is never minted silently next to a stored one. */ export function decideKey(opts: { name: string; body: KeyCreateBody; store: KeyStoreObservation; storeLabel?: string; /** null when the mailbox does not exist yet (dry run before it is created). */ keys: ApiKeyEntry[] | null; mailboxId: string | undefined; endpoint: string | undefined; }): Decision { const where = opts.storeLabel ?? "the store"; switch (opts.store.kind) { case "none": return { state: "blocked", reason: `key "${opts.name}" has no "store": its plaintext is shown exactly once, and a key that cannot be stored is a lost key.`, remedy: `Add "store": {"onePassword": {"vault": "...", "title": "..."}} to the key in the manifest.`, }; case "unavailable": return { state: "blocked", reason: `cannot use 1Password for ${where}: ${opts.store.reason}`, remedy: "Unlock 1Password / sign in (op signin) and check the vault name, then re-run.", }; case "ambiguous": return { state: "blocked", reason: `${where} matches ${opts.store.itemIds.length} items (${opts.store.itemIds.join(", ")}); which one holds the key is ambiguous.`, remedy: "Rename or delete the extra items so exactly one has this title, then re-run.", }; case "absent": return { state: "todo", action: `POST /api/mailboxes/${opts.mailboxId ?? ""}/transactional/api-keys ${JSON.stringify(opts.body)} -> store plaintext in 1Password ${where}`, plan: { kind: "mint" }, }; case "present": break; } const { itemId, keyId, endpoint } = opts.store; if (!keyId) { return { state: "blocked", reason: `${where} (item ${itemId}) exists but has no "key id" field, so which key it holds is unknown.`, remedy: `Add the key id to the item, or delete it (op item delete ${itemId}) and re-run to mint a new key.`, }; } const entry = opts.keys?.find((k) => k.keyId === keyId); if (entry?.status !== "active") { return { state: "blocked", reason: `${where} holds key ${keyId}, which is ${entry ? entry.status : opts.keys ? "not a key of this mailbox" : "on a mailbox that does not exist yet"}.`, remedy: entry ? `The stored secret is dead. Delete the item (op item delete ${itemId}) and re-run --apply to mint and store a fresh key.` : `Check the item belongs to this deployment/mailbox. If it is stale, delete it (op item delete ${itemId}) and re-run --apply; if the key is live elsewhere, rotate it there instead.`, }; } const diff = diffKey(opts.body, entry); if (diff.fixed.length > 0) { return { state: "blocked", reason: `key ${keyId} differs from the manifest in fields fixed at creation: ${diff.fixed.join("; ")}.`, remedy: `Either change the manifest to match, or mint a replacement: revoke ${keyId} (POST /api/mailboxes/${opts.mailboxId}/transactional/api-keys/${keyId}/revoke), delete the item (op item delete ${itemId}), and re-run --apply. Rotation copies these fields, so it cannot fix them.`, }; } const plan: KeyTodo = { kind: "fix", itemId, keyId }; const fixes: string[] = []; if (diff.senderName) { plan.senderName = opts.body.senderName?.trim() || null; fixes.push( `PATCH /api/mailboxes/${opts.mailboxId}/transactional/api-keys/${keyId} {"senderName":${JSON.stringify(plan.senderName)}}`, ); } if (opts.endpoint && endpoint !== opts.endpoint) { plan.endpoint = opts.endpoint; fixes.push( `op item edit ${itemId} RECCADO_ENDPOINT=${opts.endpoint} (was ${endpoint ?? "unset"})`, ); } if (fixes.length === 0) { return { state: "already", detail: `key ${keyId} is active; stored in 1Password ${where}.` }; } return { state: "todo", action: fixes.join("; "), plan }; } /** The item JSON for `op item create --template`. Only this function ever holds plaintext in a structure. */ export function buildOnePasswordItem(opts: { title: string; plaintextKey: string; endpoint: string; keyId: string; notes: string; }): Record { return { title: opts.title, category: "API_CREDENTIAL", fields: [ { id: "credential", type: "CONCEALED", label: "credential", value: opts.plaintextKey }, { id: "endpoint", type: "STRING", label: "RECCADO_ENDPOINT", value: opts.endpoint }, { id: "keyid", type: "STRING", label: "key id", value: opts.keyId }, { id: "notesPlain", type: "STRING", purpose: "NOTES", label: "notesPlain", value: opts.notes, }, ], }; } /** Items from `op item list --format json` with exactly this title. */ export function findItemsByTitle(listJson: string, title: string): string[] { const items = JSON.parse(listJson) as unknown; if (!Array.isArray(items)) throw new Error("op item list did not return a JSON array"); return items .filter((item) => (item as { title?: unknown }).title === title) .map((item) => String((item as { id?: unknown }).id ?? "")); } /** * `op item get --fields label=a,label=b --format json` returns one object for a * single field and an array for several. Label → value; nothing else is kept. */ export function parseOpFields(json: string): Record { const parsed = JSON.parse(json) as unknown; const fields = Array.isArray(parsed) ? parsed : [parsed]; const out: Record = {}; for (const field of fields) { const f = field as { label?: unknown; value?: unknown }; if (typeof f.label === "string" && typeof f.value === "string") out[f.label] = f.value; } return out; } // --------------------------------------------------------------------------- // Parsers for the deployed Worker // --------------------------------------------------------------------------- /** Version ids serving traffic, from `wrangler deployments status --json`. */ export function parseDeploymentStatus(raw: string): string[] | null { const start = raw.indexOf("{"); if (start === -1) return null; try { const parsed = JSON.parse(raw.slice(start)) as { versions?: Array<{ version_id?: string; percentage?: number }>; }; const ids = (parsed.versions ?? []) .filter((v) => typeof v.version_id === "string" && (v.percentage ?? 0) > 0) .map((v) => v.version_id as string); return ids.length > 0 ? ids : null; } catch { return null; } } /** Plain-text vars of a version, from `wrangler versions view --json`. */ export function parseVersionVars(raw: string): Record | null { const start = raw.indexOf("{"); if (start === -1) return null; try { const parsed = JSON.parse(raw.slice(start)) as { resources?: { bindings?: Array<{ type?: string; name?: string; text?: string }> }; }; const bindings = parsed.resources?.bindings; if (!Array.isArray(bindings)) return null; const vars: Record = {}; for (const b of bindings) { if (b.type === "plain_text" && typeof b.name === "string" && typeof b.text === "string") { vars[b.name] = b.text; } } return vars; } catch { return null; } } // --------------------------------------------------------------------------- // Decisions: Telegram topic // --------------------------------------------------------------------------- /** * What GET /api/telegram/status says about the bridge, reduced to what the * `telegram:topic` step decides from. `isForum` is Telegram's live answer when * getChat succeeded, else the stored flag. */ export type TelegramTopicObservation = { bridgeOn: boolean; chatId: string | null; isForum: boolean | null; /** Null when the bot's membership could not be read. */ canManageTopics: boolean | null; /** The mailbox's mapping in the bound chat; null when none (or the mailbox does not exist yet). */ mapping: { topicId: number; topicName: string | null; effectiveName: string } | null; }; export type TelegramTopicPlan = { name?: string; adoptThreadId?: number }; function telegramTopicCommand(address: string, spec: TelegramTopicPlan, replace: boolean): string { const how = spec.adoptThreadId !== undefined ? `--adopt ${spec.adoptThreadId}${spec.name ? ` --name ${JSON.stringify(spec.name)}` : ""}` : `--name ${JSON.stringify(spec.name ?? "")}`; return `pnpm operator telegram topic ${address} ${how}${replace ? " --replace" : ""} --apply`; } /** * The mailbox's topic in the bound forum: `already` when the mapping the * manifest asks for is in place, `todo` (POST /api/telegram/topics) when there * is none, `blocked` otherwise. * * Never replaces a mapping, the way routing rules are never overwritten: a * mapping decides where the mailbox's mail lands, and the remedy prints the * exact `--replace` command for the operator to choose. With neither a name nor * a thread id, any existing mapping is accepted and a missing one is created * under the mailbox's display name (else its address) -- stored with the * mapping, so later display-name changes do not rename the topic. */ export function decideTelegramTopic(opts: { spec: TelegramTopicSpec; address: string; displayName?: string; observed: TelegramTopicObservation; }): Decision { const { spec, address, observed } = opts; if (!observed.bridgeOn) { return { state: "blocked", reason: "the Telegram bridge is off (TELEGRAM_BOT_TOKEN is not set on the worker).", remedy: "Set it: pnpm wrangler secret put TELEGRAM_BOT_TOKEN --env , bind a forum, then re-run.", }; } if (!observed.chatId) { return { state: "blocked", reason: "no Telegram chat is bound.", remedy: "Send /start to the bot from the forum, or: pnpm operator telegram rebind --chat --apply; then re-run.", }; } if (observed.isForum !== true) { return { state: "blocked", reason: `the bound chat ${observed.chatId} is not a forum${observed.isForum === null ? " (never observed)" : ""}.`, remedy: `Turn on Topics in the group (or move the bridge to a forum), then: pnpm operator telegram rebind --chat --apply; then re-run.`, }; } const plan: TelegramTopicPlan = spec.adoptThreadId !== undefined ? { adoptThreadId: spec.adoptThreadId, ...(spec.topicName ? { name: spec.topicName } : {}), } : { name: spec.topicName ?? opts.displayName ?? address }; const existing = observed.mapping; if (existing) { const same = spec.adoptThreadId !== undefined ? existing.topicId === spec.adoptThreadId && (spec.topicName === undefined || spec.topicName === existing.topicName) : spec.topicName === undefined || spec.topicName === existing.topicName; if (same) { return { state: "already", detail: `${address} -> topic ${existing.topicId} "${existing.effectiveName}" in ${observed.chatId}.`, }; } return { state: "blocked", reason: `${address} is already mapped to topic ${existing.topicId} "${existing.effectiveName}"${existing.topicName ? "" : " (unnamed: follows the mailbox)"} in ${observed.chatId}, not to what the manifest asks.`, remedy: `If it should move: ${telegramTopicCommand(address, plan, true)}. Otherwise align the manifest's telegram block.`, }; } if (plan.adoptThreadId === undefined && observed.canManageTopics === false) { return { state: "blocked", reason: `the bot cannot create topics in ${observed.chatId} (no can_manage_topics).`, remedy: "Make the bot an administrator with Manage Topics, or create the topic yourself and set telegram.adoptThreadId; then re-run.", }; } return { state: "todo", action: `POST /api/telegram/topics ${JSON.stringify(plan)} for ${address} in ${observed.chatId}${plan.adoptThreadId === undefined ? " (creates the forum topic)" : " (maps the existing thread; unverifiable until pnpm smoke:telegram)"}`, plan, }; } // --------------------------------------------------------------------------- // Report // --------------------------------------------------------------------------- const LABEL: Record = { already: "already", done: "done", would_do: "would do", skipped: "skipped", blocked: "blocked", failed: "failed", }; export function outcomeText(outcome: OnboardOutcome): string { switch (outcome.state) { case "already": case "done": return outcome.detail; case "would_do": return outcome.action; case "skipped": return outcome.reason; case "blocked": return outcome.reason; case "failed": return outcome.error; } } /** One line per step (plus a remedy line for `blocked`), column-aligned. */ export function formatResults(results: StepResult[], width?: number): string[] { const idWidth = width ?? Math.min(48, Math.max(4, ...results.map((r) => r.id.length))); const lines: string[] = []; for (const r of results) { lines.push( ` ${LABEL[r.outcome.state].padEnd(8)} ${r.id.padEnd(idWidth)} ${outcomeText(r.outcome)}`, ); if (r.outcome.state === "blocked") { lines.push(` ${"".padEnd(8)} ${"".padEnd(idWidth)} -> ${r.outcome.remedy}`); } } return lines; } export function tally(results: StepResult[]): Record { const counts: Record = { already: 0, done: 0, would_do: 0, skipped: 0, blocked: 0, failed: 0, }; for (const r of results) counts[r.outcome.state] += 1; return counts; } /** Exit code: non-zero when anything failed, is blocked, or was skipped because of either. */ export function exitCodeFor(results: StepResult[]): number { return results.some((r) => isFailure(r.outcome)) ? 1 : 0; }