scripts/lib/onboard-core.ts
Rama main · réplica de lectura del repositorio original.
/**
* 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<typeof onboardManifestSchema>;
type ParsedManifest = z.output<typeof onboardManifestSchema>;
export type ManifestKey = ParsedManifest["keys"] extends Array<infer K> | 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<T> = { 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<ResolvedManifest> {
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<string>();
const stores = new Set<string>();
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<TemplateInput[]> {
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<typeof createTransactionalApiKeySchema>;
/**
* 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<Map<string, KeyCreateBody>> {
const errors: string[] = [];
const bodies = new Map<string, KeyCreateBody>();
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<P = undefined> =
| { 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<OnboardOutcome>;
};
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<StepResult[]> {
const byId = new Map<string, StepResult>();
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<P>(
decision: Decision<P>,
mode: Mode,
apply: (plan: P) => Promise<OnboardOutcome>,
): Promise<OnboardOutcome> {
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<SendingTodo> {
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 <its-queue> --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=<token with Zone · DNS · Edit on ${target.zone}> 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<undefined> {
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<undefined> {
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<undefined> {
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:<address>` → 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<RoutingRuleTodo> {
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} <rule-id>), 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 <INDEX_DB> --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<MailboxTodo> {
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 ?? "<owner>"}), 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<undefined> {
const row = opts.aliases.find((a) => a.alias_address.toLowerCase() === opts.alias);
const target = opts.mailboxId ?? "<new mailbox id>";
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<undefined> {
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 ?? "<new mailbox id>"}/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<KeyTodo> {
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 ?? "<new mailbox id>"}/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<string, unknown> {
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<string, string> {
const parsed = JSON.parse(json) as unknown;
const fields = Array.isArray(parsed) ? parsed : [parsed];
const out: Record<string, string> = {};
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 <id> --json`. */
export function parseVersionVars(raw: string): Record<string, string> | 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<string, string> = {};
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<TelegramTopicPlan> {
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 <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 <forum chat id> --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 <forum chat id> --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<OnboardOutcome["state"], string> = {
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<OnboardOutcome["state"], number> {
const counts: Record<OnboardOutcome["state"], number> = {
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;
}