@pithy-sh/cloudflare 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -0
  3. package/package.json +48 -0
  4. package/src/ai/aiManager.ts +227 -0
  5. package/src/ai/vectorizeManager.ts +161 -0
  6. package/src/ai/vectorizeProvisioner.ts +266 -0
  7. package/src/client/accounts.ts +80 -0
  8. package/src/client/clients.ts +244 -0
  9. package/src/client/errors.ts +143 -0
  10. package/src/client/manager.ts +85 -0
  11. package/src/d1/d1Manager.ts +171 -0
  12. package/src/d1/d1PreparedStatement.ts +114 -0
  13. package/src/d1/d1Provisioner.ts +75 -0
  14. package/src/email/emailRoutingManager.ts +143 -0
  15. package/src/email/emailSendManager.ts +81 -0
  16. package/src/env/devVars.ts +90 -0
  17. package/src/hostnames/customHostnamesManager.ts +134 -0
  18. package/src/kv/kvManager.ts +202 -0
  19. package/src/kv/kvProvisioner.ts +80 -0
  20. package/src/media/assetSeeder.ts +87 -0
  21. package/src/media/imageManager.ts +125 -0
  22. package/src/media/ownership.ts +59 -0
  23. package/src/media/streamManager.ts +198 -0
  24. package/src/queue/queueManager.ts +185 -0
  25. package/src/r2/r2Credentials.ts +17 -0
  26. package/src/r2/r2Manager.ts +548 -0
  27. package/src/r2/r2Provisioner.ts +99 -0
  28. package/src/secrets/secretsStoreManager.ts +177 -0
  29. package/src/secrets/secretsStores.ts +75 -0
  30. package/src/test-utils/emailRoutingRules.ts +122 -0
  31. package/src/test-utils/fixtureReportSetup.ts +31 -0
  32. package/src/test-utils/fixtures.ts +372 -0
  33. package/src/test-utils/harness.ts +413 -0
  34. package/src/test-utils/inboundRecorder.ts +189 -0
  35. package/src/test-utils/integrationSetup.ts +46 -0
  36. package/src/test-utils/reap.ts +297 -0
  37. package/src/tokens/accountTokensManager.ts +334 -0
  38. package/src/tokens/permissions.ts +67 -0
  39. package/src/tokens/profiles.ts +238 -0
  40. package/src/turnstile/turnstileManager.ts +177 -0
  41. package/src/user/userManager.ts +73 -0
  42. package/src/workers/buildsManager.ts +348 -0
  43. package/src/workers/buildsTypes.ts +122 -0
  44. package/src/workers/workersBuildEvent.ts +48 -0
  45. package/src/workers/workersManager.ts +423 -0
  46. package/src/workers/workersProvisioner.ts +167 -0
  47. package/src/workflows/stepFailure.ts +280 -0
  48. package/src/workflows/workflowsClient.ts +213 -0
  49. package/src/zones/zonesManager.ts +92 -0
@@ -0,0 +1,280 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { ErrorPayload, kitErrorStatus } from "@pithy-sh/core/src/error/payload";
5
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
6
+ import {
7
+ decodeWorkflowStepMessage,
8
+ MAX_WORKFLOW_STEP_TEXT,
9
+ splitWorkflowStepCode,
10
+ } from "@pithy-sh/core/src/workflow/stepMessage";
11
+ import { z } from "zod";
12
+
13
+ /**
14
+ * **The step's sentence, recovered from an instance the engine has already written its own over.**
15
+ *
16
+ * A Workflow instance carries two failure texts. The instance-level `error` is the *platform's*: when a
17
+ * step raises `NonRetryableError`, the engine replaces whatever was thrown with
18
+ * `"The execution of the Workflow instance was terminated, as a step threw an NonRetryableError…"`.
19
+ * The step's own entry in `steps[]` still holds the text the kit raised. Reporting the first is how
20
+ * `pithy secrets create` on a name that already exists started answering with a sentence about durable
21
+ * execution instead of one about the secret (pithy-sh/pithy#349), the day #338 made that refusal terminal.
22
+ *
23
+ * So this reads the step, and it is read *here* rather than at a call site: every dispatch that polls a
24
+ * Workflow — secrets write and probe, payments reconcile, vector reprocess — goes through one
25
+ * `dispatchAndPoll`, and a rule that lives in the primitive cannot be missed by the next caller.
26
+ *
27
+ * ## What may cross into an operator's `message`, and what may not
28
+ *
29
+ * A `PithyError`'s `message` is public and its `detail` is stripped at the HTTP boundary — that is the
30
+ * security boundary, and this function is on the wrong side of it: the text arrives from a Worker whose
31
+ * code we did not write, over an API, and it could say anything. So a step's text is promoted into the
32
+ * operator's `message` **only when it is demonstrably a sentence the kit itself authored as public**:
33
+ *
34
+ * - `PithyError: <text>` — the throw's own name is the proof. `PithyError.message` *is*
35
+ * `payload.message`, the field already written to be safe for a client.
36
+ * - The encoding core's `classifiedSteps` writes, inside the platform's terminal envelope. **This file
37
+ * does not restate that encoding**; it calls `decodeWorkflowStepMessage`, which is the same module
38
+ * the writer calls, for the reason in its own doc comment: two packages agreeing about a string by
39
+ * each writing it down is a coincidence, not a contract (pithy-sh/pithy#353).
40
+ *
41
+ * Anything else — a foreign throw, a bare string, the engine's own prose — stays in `detail`, where it
42
+ * always was. Nothing that was not already public becomes public here.
43
+ *
44
+ * `action` rides that encoding and is promoted with the sentence, because it is already the operator's
45
+ * field: the CLI prints it under the problem line and `operatorError` includes it. `detail` does not
46
+ * cross, has no encoding, and is not read here.
47
+ *
48
+ * ## The literals are captured, not guessed
49
+ *
50
+ * Every platform string below was read off a real Workflows engine — the shipped Workflow class driven
51
+ * under `wrangler dev` (wrangler 4.123.0, 2026-08-14), instance detail fetched from the local dev
52
+ * session's own instance endpoint. They are frozen so the gate in `stepFailure.test.ts` polices a fixed
53
+ * set rather than whatever this file happens to believe today.
54
+ */
55
+
56
+ /** A failure as the Workflows API records one: a class name and its text. Instance-level and per-step alike. */
57
+ export const WorkflowFailureDetail = z
58
+ .object({
59
+ name: z.string().optional().describe("The thrown value's class name, as the engine recorded it."),
60
+ message: z
61
+ .string()
62
+ .optional()
63
+ .describe("The failure text — the engine's own for a terminal step, else the throw's."),
64
+ })
65
+ .describe("One failure the Workflows API reports, on an instance or on a step attempt.");
66
+ export type WorkflowFailureDetail = z.output<typeof WorkflowFailureDetail>;
67
+
68
+ /** One attempt at a step. The engine appends one per retry, so the last failed one is the answer. */
69
+ export const WorkflowStepAttempt = z
70
+ .object({
71
+ success: z.boolean().nullish().describe("Whether this attempt returned; `false` once it threw."),
72
+ error: WorkflowFailureDetail.nullish().describe("What this attempt threw, absent when it returned."),
73
+ })
74
+ .describe("One attempt at a Workflow step, as the instance-detail endpoint reports it.");
75
+ export type WorkflowStepAttempt = z.output<typeof WorkflowStepAttempt>;
76
+
77
+ /**
78
+ * One entry in an instance's `steps[]`.
79
+ *
80
+ * The API's four step shapes (`step`, `sleep`, `waitForEvent`, `termination`) differ in where the
81
+ * failure lives — a `step` keeps one per attempt, the others keep one on the entry — so both are
82
+ * optional here and the reader takes whichever is present. Unknown fields (`config`, `output`,
83
+ * timestamps) are stripped rather than refused: this is a diagnostic read, and a step shape nobody
84
+ * anticipated must not turn a failed Workflow into a parse error about the failure report.
85
+ */
86
+ export const WorkflowStepRecord = z
87
+ .object({
88
+ name: z.string().optional().describe("The step's name, as `step.do` was called with it."),
89
+ type: z.string().optional().describe("Which of the API's step shapes this is — `step`, `sleep`, `waitForEvent`."),
90
+ success: z.boolean().nullish().describe("Whether the step completed; `false` once every attempt failed."),
91
+ error: WorkflowFailureDetail.nullish().describe("The failure, for the shapes that record one on the entry."),
92
+ attempts: z.array(WorkflowStepAttempt).optional().describe("Each attempt at the step, oldest first."),
93
+ })
94
+ .describe("One step of a Workflow instance, narrowed to the fields a failure report reads.");
95
+ export type WorkflowStepRecord = z.output<typeof WorkflowStepRecord>;
96
+
97
+ /** What a failed instance's steps say about why, once the platform's own prose is set aside. */
98
+ export interface WorkflowStepFailure {
99
+ /** The failed step's name, when the engine reported one. */
100
+ step?: string;
101
+ /** The step's recorded text, verbatim, platform envelope and all. For `detail` — never for `message`. */
102
+ raw: string;
103
+ /** The `PithyError` code the step raised, when its text carries one. */
104
+ code?: string;
105
+ /** The step's own public sentence — present only when the text is one the kit authored. */
106
+ sentence?: string;
107
+ /** The remedy the raising error stated, when it stated one. The CLI's action line. */
108
+ action?: string;
109
+ }
110
+
111
+ /**
112
+ * The envelope the engine wraps a terminal step throw in, captured verbatim. The inner text is *not*
113
+ * JSON-quoted — a message containing a `"` is embedded raw — so this is a prefix/suffix strip and never
114
+ * a `JSON.parse`.
115
+ */
116
+ const TERMINAL_STEP_ENVELOPE = Object.freeze({
117
+ prefix: 'Step threw a NonRetryableError with message "',
118
+ suffix: '"',
119
+ });
120
+
121
+ /**
122
+ * The throw names a kit step error can arrive under. `PithyError` is core's one throw vehicle;
123
+ * `NonRetryableError` is what `classifiedSteps` re-throws a terminal fault as. A name outside this set
124
+ * is somebody else's error, and its text stays in `detail`.
125
+ */
126
+ const KIT_THROW_NAMES: readonly string[] = Object.freeze(["PithyError", "NonRetryableError"]);
127
+
128
+ /**
129
+ * The kit-authored public sentence inside a step's recorded text, and the remedy riding with it —
130
+ * `null` when there is none.
131
+ *
132
+ * Exported for the gate, which drives captured engine output through it: a platform sentence reaching an
133
+ * operator is a test failure, and the test has to be able to ask this question directly.
134
+ */
135
+ export function kitSentence(raw: string): { code?: string; message: string; action?: string } | null {
136
+ let text = raw.trim();
137
+ if (text.startsWith(TERMINAL_STEP_ENVELOPE.prefix) && text.endsWith(TERMINAL_STEP_ENVELOPE.suffix)) {
138
+ text = text.slice(TERMINAL_STEP_ENVELOPE.prefix.length, -TERMINAL_STEP_ENVELOPE.suffix.length);
139
+ }
140
+ const thrown = KIT_THROW_NAMES.map((name) => `${name}: `).find((prefix) => text.startsWith(prefix));
141
+ if (thrown === undefined) return null;
142
+ text = text.slice(thrown.length);
143
+
144
+ // A `NonRetryableError` is `classifiedSteps`' vehicle, so its text is core's encoding or it is
145
+ // somebody else's — anyone may throw one with anything inside, and the code prefix is the only proof.
146
+ if (thrown === "NonRetryableError: ") return decodeWorkflowStepMessage(text);
147
+
148
+ // A `PithyError` name is proof on its own. Its recorded text is `payload.message` and carries no
149
+ // encoding at all — no action ever rode here — so the sentence is taken as it stands, and a newline
150
+ // in it is still declined rather than reshaped: it would forge the CLI's action line.
151
+ const { code, rest } = splitWorkflowStepCode(text);
152
+ const message = rest.trim();
153
+ if (message === "" || message.length > MAX_WORKFLOW_STEP_TEXT) return null;
154
+ if (/[\n\r]/.test(message)) return null;
155
+ return code === undefined ? { message } : { code, message };
156
+ }
157
+
158
+ /** The recorded failure text of one step entry, from its last failed attempt or its own error. */
159
+ function failureTextOf(step: WorkflowStepRecord): string | undefined {
160
+ for (let index = (step.attempts?.length ?? 0) - 1; index >= 0; index -= 1) {
161
+ const attempt = step.attempts?.[index];
162
+ if (attempt?.success === true) continue;
163
+ const message = attempt?.error?.message;
164
+ if (message !== undefined && message !== "") return message;
165
+ }
166
+ const own = step.error?.message;
167
+ return own === undefined || own === "" ? undefined : own;
168
+ }
169
+
170
+ /**
171
+ * The last step that failed, and what it said. `null` when no step reports a failure — an instance the
172
+ * platform failed on its own (a timeout, a terminate) has steps that all succeeded, or none at all.
173
+ *
174
+ * Read from the end: the failing step is the last one the instance reached, and an earlier step that
175
+ * failed and was retried into success has nothing to say about why the instance ended.
176
+ *
177
+ * Each entry is validated on its way in and an entry that will not parse is skipped rather than thrown
178
+ * on. The caller is already reporting a failure; refusing to describe it because one step of the report
179
+ * had an unfamiliar shape trades a useful sentence for a useless one.
180
+ */
181
+ export function stepFailure(steps: readonly unknown[] | undefined): WorkflowStepFailure | null {
182
+ if (steps === undefined) return null;
183
+ for (let index = steps.length - 1; index >= 0; index -= 1) {
184
+ const parsed = WorkflowStepRecord.safeParse(steps[index]);
185
+ if (!parsed.success) continue;
186
+ if (parsed.data.success === true) continue;
187
+ const raw = failureTextOf(parsed.data);
188
+ if (raw === undefined) continue;
189
+ const kit = kitSentence(raw);
190
+ return {
191
+ ...(parsed.data.name === undefined ? {} : { step: parsed.data.name }),
192
+ raw,
193
+ ...(kit?.code === undefined ? {} : { code: kit.code }),
194
+ ...(kit === null ? {} : { sentence: kit.message }),
195
+ // Absent, not `undefined`: a step that stated no remedy must leave nothing for the CLI to print
196
+ // under the problem line — no empty line, no trailing separator, no `undefined`.
197
+ ...(kit?.action === undefined ? {} : { action: kit.action }),
198
+ };
199
+ }
200
+ return null;
201
+ }
202
+
203
+ /**
204
+ * The code a terminal Workflow failure falls back to when the fault that ended the run cannot be
205
+ * attributed. Stated here because two things must agree on it: the thrower below, and the gate that
206
+ * proves this code is not one a transport failure can arrive under.
207
+ */
208
+ export const WORKFLOW_FAILED_CODE = "core/workflow_failed" as const;
209
+
210
+ /** The status pinned to {@link WORKFLOW_FAILED_CODE}, restated so the throw cannot drift off it. */
211
+ export const WORKFLOW_FAILED_STATUS = 500 as const;
212
+
213
+ /**
214
+ * **The error an operator is handed for a Workflow that ran and did not complete** (pithy-sh/pithy#365).
215
+ *
216
+ * `#353` fixed the sentence and the remedy. The `code` and the `status` were still the *transport's*:
217
+ * `dispatchAndPoll` threw `CloudflareRequestError`, which fixes `cloudflare/request_failed` and 502 by
218
+ * construction, so a step that raised `secrets/already_exists` with 409 reached the CLI as a 502 —
219
+ * "the far side is broken, try later" for a request that was delivered, ran, and was permanently
220
+ * refused. Anything branching on the pair rather than reading the prose was told the opposite of what
221
+ * happened.
222
+ *
223
+ * ## The distinction that has to survive, and where it lives
224
+ *
225
+ * A Workflow whose *step raised* is not the same event as a dispatch that could not be delivered, and
226
+ * the difference is now in the two machine-readable fields rather than in the message:
227
+ *
228
+ * - **The step raised, and the kit pins a status for its code** → that code, that status. Terminal by
229
+ * the code's own definition; `secrets/already_exists` is a 409 wherever it is raised.
230
+ * - **The run ended and nothing is attributable** — a foreign throw, the platform's own prose, an
231
+ * `unclassified` fault, an adopter code the kit pins no status for → {@link WORKFLOW_FAILED_CODE},
232
+ * 500. Terminal, and deliberately not 502.
233
+ * - **The dispatch itself failed** — the REST call was refused, timed out, or answered with a shape
234
+ * nobody expected → `cloudflare/request_failed` / `cloudflare/invalid_response`, 502, thrown by
235
+ * `cloudflareRequest` exactly as before. This function is never reached for one.
236
+ *
237
+ * So the reader tells them apart on `code` alone, and the three sets are disjoint.
238
+ *
239
+ * ## Why the status is recovered from the code rather than carried on the wire
240
+ *
241
+ * Nothing but `code`, `message` and `action` crosses a durable step boundary — the engine records the
242
+ * throw's text and discards the throw. A fourth field could have been encoded, at the cost of
243
+ * reopening a format `#353` froze against a measurement. It is not needed: **every kit member pins
244
+ * `status` to one literal**, so the code *is* the status (`kitErrorStatus`). A code the kit does not
245
+ * define has no pinned status, and rather than invent one this says so with its own code.
246
+ *
247
+ * ## `detail` still does not cross
248
+ *
249
+ * `detail` is composed here from the platform's raw text and the instance id — the operator's side of
250
+ * the boundary — and nothing derived from the step's own `detail` is in it, because the step's
251
+ * `detail` never left the step. The only fields promoted from the far side are `message` and `action`,
252
+ * both already public, both already proved kit-authored by `kitSentence`.
253
+ */
254
+ export function terminalWorkflowError(args: {
255
+ /** What the instance's steps said, or `null` when no step reported a failure. */
256
+ failure: WorkflowStepFailure | null;
257
+ /** The sentence to use when the step authored none — the caller's own, about the instance. */
258
+ fallbackMessage: string;
259
+ /** The operator's context line. Raw platform text and the instance id; never the dispatched params. */
260
+ detail: string;
261
+ }): PithyError {
262
+ const { failure, fallbackMessage, detail } = args;
263
+ // A remedy travels only alongside the sentence it is a remedy for. An action line under a general
264
+ // fallback about durable execution is a fix for a problem nobody was told about.
265
+ const message = failure?.sentence ?? fallbackMessage;
266
+ const action = failure?.sentence === undefined ? undefined : failure.action;
267
+
268
+ const status = failure?.code === undefined ? undefined : kitErrorStatus(failure.code);
269
+ if (failure?.sentence !== undefined && failure.code !== undefined && status !== undefined) {
270
+ const candidate = { code: failure.code, status, message, action, detail };
271
+ // Parsed rather than trusted. A recovered code is a string from a Worker we did not write, and one
272
+ // kit member (`validation/invalid_input`) requires a field this boundary has no way to supply — so
273
+ // a payload that would not validate must not be thrown from the error path. It falls through to
274
+ // the general terminal code below, keeping the step's own sentence.
275
+ const parsed = ErrorPayload.safeParse(candidate);
276
+ if (parsed.success) return new PithyError(parsed.data);
277
+ }
278
+
279
+ return new PithyError({ code: WORKFLOW_FAILED_CODE, status: WORKFLOW_FAILED_STATUS, message, action, detail });
280
+ }
@@ -0,0 +1,213 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { UpstreamTimeoutError } from "@pithy-sh/core/src/error/pithyError";
5
+ import { z } from "zod";
6
+ import { CloudflareInvalidResponseError, cloudflareRequest, isNotFoundError } from "../client/errors";
7
+ import { CloudflareManager, type CloudflareManagerConfig } from "../client/manager";
8
+ import { stepFailure, terminalWorkflowError, type WorkflowStepFailure } from "./stepFailure";
9
+
10
+ /** Pause `ms` milliseconds between status polls. Injectable so tests run with no real delay. */
11
+ export type Sleeper = (ms: number) => Promise<void>;
12
+
13
+ export interface CloudflareWorkflowsClientConfig extends CloudflareManagerConfig {
14
+ /** Override the poll delay (tests). Defaults to a real `setTimeout`. */
15
+ sleeper?: Sleeper;
16
+ }
17
+
18
+ export const WorkflowInstanceStatus = z
19
+ .enum([
20
+ "queued",
21
+ "running",
22
+ "paused",
23
+ "errored",
24
+ "terminated",
25
+ "complete",
26
+ "waitingForPause",
27
+ "waiting",
28
+ "rollingBack",
29
+ "unknown",
30
+ ])
31
+ .describe("Lifecycle state of a Cloudflare Workflow instance, from the status endpoint.");
32
+ export type WorkflowInstanceStatus = z.output<typeof WorkflowInstanceStatus>;
33
+
34
+ export const WorkflowInstance = z
35
+ .object({
36
+ status: WorkflowInstanceStatus.describe("The instance's current lifecycle state."),
37
+ output: z.unknown().optional().describe("The Workflow's return value, present once `status` is `complete`."),
38
+ error: z
39
+ .union([
40
+ z.string(),
41
+ z
42
+ .object({ message: z.string().optional().describe("Failure message.") })
43
+ .describe("The structured form of the failure, when the API sends an object rather than a string."),
44
+ z.null(),
45
+ ])
46
+ .optional()
47
+ .describe("The instance-level failure, present when `status` is `errored` or `terminated`."),
48
+ steps: z
49
+ .array(z.unknown())
50
+ .optional()
51
+ .describe(
52
+ "The instance's steps, each validated where it is read (`stepFailure`). Held as `unknown` here on purpose: the API has four step shapes, this client reads one field of one of them, and a shape nobody anticipated must not turn a failed Workflow into a parse error about the failure report.",
53
+ ),
54
+ })
55
+ .describe("A Cloudflare Workflow instance's status, as returned by the instance-detail endpoint.");
56
+ export type WorkflowInstance = z.output<typeof WorkflowInstance>;
57
+
58
+ /** Terminal states a poll stops on. */
59
+ const TERMINAL: ReadonlySet<WorkflowInstanceStatus> = new Set(["complete", "errored", "terminated"]);
60
+
61
+ const defaultSleeper: Sleeper = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
62
+
63
+ /**
64
+ * A client over the Cloudflare Workflows API — the only sanctioned way to reach CF Workflows from
65
+ * outside a Worker (CLAUDE.md: no hand-rolled CF `fetch` outside `@pithy-sh/cloudflare`). The
66
+ * `pithy secrets` CLI uses it to trigger a per-env manager's Workflow and poll to completion: the
67
+ * CLI never runs secret logic locally, since the master key is worker-only.
68
+ *
69
+ * The SDK owns transport and envelope handling. Instance status is still re-validated through
70
+ * {@link WorkflowInstance} rather than taken from the SDK's types: the SDK narrows `output` to
71
+ * `string | number`, but a Workflow's return value is arbitrary JSON, and the status enum is a wire
72
+ * value we decode at the boundary like every other external input.
73
+ */
74
+ export class CloudflareWorkflowsClient extends CloudflareManager {
75
+ readonly #sleeper: Sleeper;
76
+
77
+ constructor(config: CloudflareWorkflowsClientConfig) {
78
+ super(config);
79
+ this.#sleeper = config.sleeper ?? defaultSleeper;
80
+ }
81
+
82
+ getServiceType(): string {
83
+ return "Workflows";
84
+ }
85
+
86
+ /** Prove reach by listing the account's Workflows; never throws. */
87
+ async validateServiceAccess(): Promise<boolean> {
88
+ try {
89
+ for await (const _workflow of this.getClient().workflows.list({ account_id: this.accountId })) break;
90
+ return true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /** Create (trigger) a Workflow instance and return its id. */
97
+ async createInstance(workflowName: string, params: unknown, instanceId?: string): Promise<string> {
98
+ return cloudflareRequest(`create instance of ${workflowName}`, async () => {
99
+ const created = await this.getClient().workflows.instances.create(workflowName, {
100
+ account_id: this.accountId,
101
+ params,
102
+ ...(instanceId ? { instance_id: instanceId } : {}),
103
+ });
104
+ if (!created.id) {
105
+ throw new CloudflareInvalidResponseError({ detail: `create instance of ${workflowName}: missing instance id` });
106
+ }
107
+ return created.id;
108
+ });
109
+ }
110
+
111
+ /** Fetch an instance's status. Returns `null` on a 404 (a just-created instance can briefly lag). */
112
+ async getInstanceStatus(workflowName: string, instanceId: string): Promise<WorkflowInstance | null> {
113
+ const operation = `get status of ${workflowName}/${instanceId}`;
114
+ return cloudflareRequest(operation, async () => {
115
+ let response: unknown;
116
+ try {
117
+ response = await this.getClient().workflows.instances.get(instanceId, {
118
+ account_id: this.accountId,
119
+ workflow_name: workflowName,
120
+ });
121
+ } catch (error) {
122
+ // The instance is not queryable yet — the caller keeps polling. Any other failure is real.
123
+ if (isNotFoundError(error)) return null;
124
+ throw error;
125
+ }
126
+ const parsed = WorkflowInstance.safeParse(response);
127
+ if (!parsed.success) {
128
+ throw new CloudflareInvalidResponseError({ detail: `${operation}: unexpected shape` });
129
+ }
130
+ return parsed.data;
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Trigger a Workflow and poll until it reaches a terminal state. Resolves with the instance
136
+ * `output` on `complete`. The error never carries the dispatched params (which may be secret).
137
+ *
138
+ * **Three failures, three codes, and a caller can tell them apart without reading a word of prose**
139
+ * (pithy-sh/pithy#365):
140
+ *
141
+ * | What happened | Code | Status |
142
+ * |---|---|---|
143
+ * | A step raised, and the kit pins a status for its code | that code | that status |
144
+ * | The run ended terminally, nothing attributable | `core/workflow_failed` | 500 |
145
+ * | The dispatch or a poll could not be delivered | `cloudflare/request_failed` | 502 |
146
+ * | We stopped waiting; the instance may still finish | `core/upstream_timeout` | 504 |
147
+ *
148
+ * The first two are terminal — the run is over and re-driving it reaches the same end. The last two
149
+ * are not. Reporting all of them as 502 told an operator to wait for a recovery that would never
150
+ * come; see `terminalWorkflowError` for the argument in full.
151
+ */
152
+ async dispatchAndPoll(
153
+ workflowName: string,
154
+ params: unknown,
155
+ options: { pollIntervalMs?: number; maxPolls?: number; instanceId?: string } = {},
156
+ ): Promise<unknown> {
157
+ const pollIntervalMs = options.pollIntervalMs ?? 1_000;
158
+ const maxPolls = options.maxPolls ?? 120;
159
+ const id = await this.createInstance(workflowName, params, options.instanceId);
160
+
161
+ for (let poll = 0; poll < maxPolls; poll++) {
162
+ await this.#sleeper(pollIntervalMs);
163
+ const instance = await this.getInstanceStatus(workflowName, id);
164
+ if (!instance) continue; // not queryable yet — keep polling
165
+ if (!TERMINAL.has(instance.status)) continue;
166
+ if (instance.status === "complete") return instance.output;
167
+ // The step's sentence, not the instance's. The engine writes its own prose over a terminal step's
168
+ // error, so the instance says "a step threw an NonRetryableError" where the step says what was
169
+ // actually wrong (pithy-sh/pithy#349). Only a sentence the kit demonstrably authored is promoted
170
+ // into `message`; everything else stays in `detail`, which the HTTP codec strips.
171
+ // …and under the step's own code and status, not the transport's (pithy-sh/pithy#365). The
172
+ // sentence, the remedy, the code and the status are all the raising error's; the fallbacks and
173
+ // `detail` are this client's. `terminalWorkflowError` owns which of the two answers, so there is
174
+ // one statement of it rather than a rule this call site remembers.
175
+ const failure = stepFailure(instance.steps);
176
+ throw terminalWorkflowError({
177
+ failure,
178
+ fallbackMessage: `Workflow ${workflowName} did not complete (${instance.status}).`,
179
+ detail: `instance ${id} ended ${instance.status}: ${describeFailure(failure, instance.error)}`,
180
+ });
181
+ }
182
+ // Not terminal, and it must not read as one: the budget is *ours*, and the instance is still
183
+ // running on the far side. `core/upstream_timeout` (504) is the kit's stated code for a dependency
184
+ // that did not answer inside a deadline and may yet apply the work — which is exactly this — and it
185
+ // is neither the transport's 502 nor a terminal Workflow's code.
186
+ throw new UpstreamTimeoutError({
187
+ message: `Workflow ${workflowName} did not finish in time.`,
188
+ action: `Check the instance in the Cloudflare dashboard; it may still complete.`,
189
+ detail: `instance ${id} still running after ${maxPolls} polls`,
190
+ });
191
+ }
192
+ }
193
+
194
+ /** The most specific instance-error text, for a failure `detail`. Never the dispatched params. */
195
+ function describeError(error: WorkflowInstance["error"]): string {
196
+ if (!error) return "no error detail";
197
+ if (typeof error === "string") return error;
198
+ return error.message ?? "no error detail";
199
+ }
200
+
201
+ /**
202
+ * Everything known about why, for the `detail` line: the failed step's name and its recorded text
203
+ * verbatim, then the instance's own. Both, because they differ — that difference is the bug this
204
+ * function exists because of — and `detail` is the operator's side of the boundary, where the raw
205
+ * platform text belongs. Never the dispatched params.
206
+ */
207
+ function describeFailure(failure: WorkflowStepFailure | null, error: WorkflowInstance["error"]): string {
208
+ const instance = describeError(error);
209
+ if (failure === null) return instance;
210
+ const step = failure.step === undefined ? "step" : `step '${failure.step}'`;
211
+ const code = failure.code === undefined ? "" : ` [${failure.code}]`;
212
+ return `${step} raised${code} ${failure.raw} — instance error: ${instance}`;
213
+ }
@@ -0,0 +1,92 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { cloudflareRequest } from "../client/errors";
6
+ import { CloudflareManager } from "../client/manager";
7
+
8
+ /**
9
+ * The account's zones — the registrable domains a custom domain can attach to.
10
+ *
11
+ * **Nothing here listed zones before.** Every zone-scoped operation in this package takes a `zoneId` the
12
+ * caller already knows: `CloudflareCustomHostnamesManager` takes it as constructor config,
13
+ * `CloudflareEmailRoutingManager` takes it per method, `CloudflareWorkersManager.addRoute` takes it
14
+ * positionally — and `pithy email provision` makes a human paste one, telling them to "find it on the
15
+ * zone's Overview page". So the CLI never *discovered* a zone; it demanded one.
16
+ *
17
+ * That is what this exists to change. When `pithy init` and `pithy worker add` ask where a Worker will
18
+ * answer, offering the account's real zones means a typo fails at `init` with a list of what exists,
19
+ * rather than at `deploy` with a Cloudflare error to decode.
20
+ *
21
+ * Read-only, deliberately. Pithy attaches routes to zones and never creates, transfers, or deletes one —
22
+ * a zone is the adopter's relationship with their registrar, not a resource this toolset provisions. The
23
+ * scoped token needs only `Zone:Read`.
24
+ */
25
+
26
+ /** One zone on the account. */
27
+ export const ZoneInfo = z
28
+ .object({
29
+ id: z.string().describe("The CF-assigned zone id, which every zone-scoped API call is addressed by."),
30
+ name: z
31
+ .string()
32
+ .describe(
33
+ "The registrable domain, e.g. `example.com`. This is the value a Worker's `domains` declaration names as its `zone`, and what wrangler writes as `zone_name`.",
34
+ ),
35
+ status: z
36
+ .string()
37
+ .describe(
38
+ "The zone's lifecycle status — `active` once Cloudflare is serving it, otherwise `pending`, `initializing`, or `moved`. A non-active zone cannot carry a custom domain yet, so a picker shows it and says so rather than hiding it.",
39
+ ),
40
+ })
41
+ .describe("One Cloudflare zone: the registrable domain a custom domain can attach to, and whether it is live.");
42
+ export type ZoneInfo = z.output<typeof ZoneInfo>;
43
+
44
+ /** Read the account's zones. Never creates or deletes — a zone is the adopter's, not ours to provision. */
45
+ export class CloudflareZonesManager extends CloudflareManager {
46
+ getServiceType(): string {
47
+ return "Cloudflare Zones";
48
+ }
49
+
50
+ /** Prove access by listing. A read, and never throws — the caller decides what an inaccessible account means. */
51
+ async validateServiceAccess(): Promise<boolean> {
52
+ try {
53
+ await this.listZones();
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Every zone on this account, name-sorted so a picker is stable between runs.
62
+ *
63
+ * Filtered to the account, because a user-bound token can see zones on accounts this project has
64
+ * nothing to do with — offering those would invite someone to attach a Worker to a zone that another
65
+ * account owns, which fails at deploy with an error that names neither problem.
66
+ */
67
+ async listZones(): Promise<ZoneInfo[]> {
68
+ return cloudflareRequest("list zones", async () => {
69
+ const zones: ZoneInfo[] = [];
70
+ for await (const zone of this.getClient().zones.list({ account: { id: this.accountId } })) {
71
+ const parsed = ZoneInfo.safeParse(zone);
72
+ if (parsed.success) zones.push(parsed.data);
73
+ }
74
+ return zones.sort((a, b) => a.name.localeCompare(b.name));
75
+ });
76
+ }
77
+
78
+ /**
79
+ * The zone a hostname belongs to, or `null`.
80
+ *
81
+ * Matches the **longest** zone name that the hostname sits under, which is the only correct rule when
82
+ * an account holds both `example.com` and `eu.example.com`: `api.eu.example.com` belongs to the latter,
83
+ * and picking the first match would attach it to the wrong zone. A public-suffix guess is not used at
84
+ * all — a zone can itself be a subdomain, and the account's own list is the authority.
85
+ */
86
+ async findZoneForHostname(hostname: string): Promise<ZoneInfo | null> {
87
+ const zones = await this.listZones();
88
+ const candidates = zones.filter((zone) => hostname === zone.name || hostname.endsWith(`.${zone.name}`));
89
+ if (candidates.length === 0) return null;
90
+ return candidates.reduce((longest, zone) => (zone.name.length > longest.name.length ? zone : longest));
91
+ }
92
+ }