@pithy-sh/cloudflare 0.1.6 → 0.1.7

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.
@@ -30,7 +30,16 @@ export declare class CloudflareNotConfiguredError extends PithyError {
30
30
  cause?: unknown;
31
31
  });
32
32
  }
33
- /** A call to the Cloudflare REST API failed (network error, 4xx/5xx from the API). */
33
+ /**
34
+ * A call to the Cloudflare REST API failed (network error, 4xx/5xx from the API).
35
+ *
36
+ * **The `apiAnswer` default is the guarantee, not a convenience.** `core`'s `GUARANTEED_ERROR_PARAMS`
37
+ * declares that every throw of `cloudflare/request_failed` passes `apiAnswer`, and a locale wrote a
38
+ * `{apiAnswer}` against that declaration — so a path that omits it renders the placeholder literally on
39
+ * a Spanish reader's screen. Guaranteed means every throw, so the promise is kept where the code is
40
+ * fixed rather than at each of the sites that construct one: an empty answer is what "the call never
41
+ * reached Cloudflare" renders as, and it is the honest value for any path that has nothing to report.
42
+ */
34
43
  export declare class CloudflareRequestError extends PithyError {
35
44
  constructor(args?: CloudflareErrorArgs, options?: {
36
45
  cause?: unknown;
@@ -42,13 +51,141 @@ export declare class CloudflareInvalidResponseError extends PithyError {
42
51
  cause?: unknown;
43
52
  });
44
53
  }
54
+ /**
55
+ * One entry of Cloudflare's `errors[]` array — the API's own answer to a failed call.
56
+ *
57
+ * Every v4 endpoint answers a failure with `{ success: false, errors: [{ code, message,
58
+ * documentation_url }] }`, and the SDK already parses it onto the thrown `APIError`. Until #534 we
59
+ * projected none of it: a refusal named the operation and stopped, so a token missing one product's
60
+ * grant, a revoked token, a wrong account and an outage all rendered as the same sentence.
61
+ */
62
+ export interface CloudflareApiError {
63
+ /** Cloudflare's numeric error code — the stable discriminator (`10000` is its auth-class code). */
64
+ code?: number;
65
+ /** Cloudflare's own sentence for the failure ("Authentication error"). */
66
+ message?: string;
67
+ /** The docs page for the endpoint that refused, when Cloudflare names one. */
68
+ documentationUrl?: string;
69
+ }
45
70
  /**
46
71
  * Run a Cloudflare SDK call and turn any failure into a `CloudflareRequestError`, preserving the
47
72
  * original error as `cause` and its message as internal `detail`. `operation` names the call for
48
73
  * the audit trail (`"KV get for key 'x'"`). A `PithyError` already in flight (e.g. a
49
74
  * not-configured guard) passes through untouched — only foreign throws get wrapped.
75
+ *
76
+ * The refusal carries Cloudflare's own `errors[]` — see {@link cloudflareRefusal}.
77
+ *
78
+ * **A call that ran out of time is `core/upstream_timeout` (504), not a 502.** CLAUDE.md §Errors
79
+ * splits the upstream class in two for a reason a retry policy acts on: a 502 says Cloudflare
80
+ * answered and refused, so the same call refuses again; a 504 says nobody answered, so the same call
81
+ * may well succeed — **and may already have been applied**, which is the fact a caller needs before it
82
+ * retries a create. Folding a connect timeout into `cloudflare/request_failed` told every reader the
83
+ * first story about the second event.
84
+ *
85
+ * `permission` names the account permission group behind this endpoint for the refusal's action line,
86
+ * for the endpoints whose docs link does not say. It is the same fact `cloudflareRefusal` takes, handed
87
+ * in here so the *first* call of a command can carry it: `pithy token mint` refuses at
88
+ * `findTokenByName` and `listPermissionGroups`, both of which run before the mint, so a hint attached
89
+ * only to the mint is a hint the operator never reaches.
90
+ */
91
+ export declare function cloudflareRequest<T>(operation: string, fn: () => Promise<T>, options?: {
92
+ permission?: string;
93
+ }): Promise<T>;
94
+ /**
95
+ * Compose the refusal every failed Cloudflare call renders: the problem line, then the API's own
96
+ * answer, then — only for an auth-class code — what to do about it.
97
+ *
98
+ * One place, so a fix reaches every provisioner at once — and **one place is a claim a test has to
99
+ * hold, because it was false the first time it was written.** Round one of #534 said
100
+ * `CloudflareBuildsManager` was the only path composing a refusal by hand; `mintToken`, the Stream
101
+ * direct upload and two integration helpers were composing their own, so `pithy token mint` — the
102
+ * bootstrap command an under-scoped token meets first — still printed the operation and nothing else
103
+ * on the identical 401 body. `refusalSites.test.ts` now fails any `new CloudflareRequestError(`
104
+ * outside this file, so "the only place" is enforced rather than remembered.
105
+ *
106
+ * CLAUDE.md §Errors decides where each string lands. Cloudflare's sentence and its docs link are the
107
+ * **caller's** information — what happened, in the words of whoever it happened to — so they go in
108
+ * `message`, field by field off `errors[]` and never as `JSON.stringify(body)`: these managers are
109
+ * imported into Worker paths too, so `message` can reach a browser through `clientError`. The
110
+ * structured `code` and link ride along in `params`, which is client-facing by definition, so a
111
+ * translating client can render them under the error code. "Add this grant, then re-run" names a
112
+ * console the operator is sitting in front of, so it is an `action`. The raw body stays `detail`.
113
+ *
114
+ * The code stays `cloudflare/request_failed` at 502: a dedicated sibling of `core/upstream_failed`,
115
+ * already the upstream class CLAUDE.md asks for, and pinned by `core`'s payload test.
116
+ *
117
+ * **The message is one line, and that is a kit-wide rule this had to learn the hard way.** Round one
118
+ * of #534 made it a small document — problem line, `errors[]` indented beneath, action last — which
119
+ * reads beautifully in a terminal and is unencodable everywhere else. A `PithyError` raised inside a
120
+ * `classifiedSteps` step crosses a durable boundary as one string whose newline separates the sentence
121
+ * from the remedy, so a multi-line `message` was declined whole at the reader and the operator got
122
+ * `core/workflow_failed` 500 about durable execution instead of the API answer this issue exists to
123
+ * show them. The same newline is what the CLI's action line is, so an upstream sentence containing one
124
+ * forges a remedy Pithy never wrote. `Cloudflare said:` marks the boundary between our words and
125
+ * theirs with a word instead of an indent, and a word survives a step record, a `--json` line and a
126
+ * browser.
127
+ */
128
+ export declare function cloudflareRefusal(args: {
129
+ /** The problem line — what Pithy was doing when the API refused. */
130
+ problem: string;
131
+ /** Cloudflare's own error entries, from {@link cloudflareApiErrors}. */
132
+ apiErrors: CloudflareApiError[];
133
+ /** The HTTP status, when one is known. A fallback signal only; the code is the discriminator. */
134
+ status?: number;
135
+ /**
136
+ * The account permission group behind this endpoint, when the call site knows it and Cloudflare's
137
+ * docs link will not say. A **fact**, not wording: the sentence is still composed here, and a hint
138
+ * that disagrees with the link loses to the link. `undefined` yields the generic action, which is
139
+ * right — a wrong grant named confidently is worse than none.
140
+ */
141
+ permission?: string;
142
+ /** The raw failure text, for logs and audit alone. */
143
+ detail: string;
144
+ /** The original throw. */
145
+ cause?: unknown;
146
+ }): CloudflareRequestError;
147
+ /**
148
+ * Pithy's problem line with Cloudflare's own answer on the end of it, and the same answer as `params`.
149
+ *
150
+ * Exported because {@link cloudflareRefusal} is not the only refusal that must carry it. The 403 on
151
+ * `accounts/tokens create` says something a generic refusal cannot — the token got in and may not
152
+ * create tokens — so it keeps its own diagnosis and its own code, and *that* is what made it drop
153
+ * Cloudflare's answer for two rounds: a call site with a better sentence had no way to keep the API's
154
+ * words. Now it composes the sentence here like everything else, and the only thing it supplies is the
155
+ * problem line.
156
+ *
157
+ * One line, always. Whitespace runs collapse and entries join with a semicolon. The problem line is
158
+ * flattened along with the rest because a call site interpolates values into it (`KV get for key
159
+ * '<key>'`), and a key holding a newline is an operator's refusal wearing a forged action line.
160
+ *
161
+ * **What gets cut when it does not fit is the part Pithy can afford to lose.** The problem line is
162
+ * bounded to {@link MAX_PROBLEM_LINE} first, so a 512-byte KV key or a 1024-byte R2 one cannot crowd out
163
+ * the answer; then whole entries are dropped from the end until the sentence fits
164
+ * {@link MAX_REFUSAL_MESSAGE}, the bound of the durable step channel. Slicing the composed sentence
165
+ * instead — which is what this did — discards the tail, and the tail is always Cloudflare's answer.
166
+ */
167
+ export declare function cloudflareSaid(problem: string, apiErrors: CloudflareApiError[]): {
168
+ message: string;
169
+ params: MessageParams;
170
+ };
171
+ /**
172
+ * Cloudflare's `errors[]`, read off a thrown SDK error (`APIError.errors`) or handed the array
173
+ * straight from an unwrapped envelope. Duck-typed rather than `instanceof APIError`, matching
174
+ * {@link isNotFoundError}: the SDK is one producer of this shape and the raw-`fetch` path is another.
175
+ *
176
+ * Entries are read field by field and dropped when they carry neither a code nor a sentence, so a
177
+ * malformed body degrades to today's refusal instead of rendering an empty line. A
178
+ * `documentation_url` is kept only when it is `https:` — the link is printed, and a printed link is
179
+ * one somebody clicks.
180
+ */
181
+ export declare function cloudflareApiErrors(source: unknown): CloudflareApiError[];
182
+ /**
183
+ * The HTTP status of a thrown SDK error, when it carries one. Exported because a call site that
184
+ * composes its own refusal through {@link cloudflareRefusal} has to hand it the same status this
185
+ * module would have read — a hand-rolled `(error as { status?: number }).status` beside it is the
186
+ * duplicate that drifts.
50
187
  */
51
- export declare function cloudflareRequest<T>(operation: string, fn: () => Promise<T>): Promise<T>;
188
+ export declare function statusOf(error: unknown): number | undefined;
52
189
  /** The raw message of an unknown throw, for use as a PithyError `detail`. One source of truth. */
53
190
  export declare function messageOf(error: unknown): string;
54
191
  /**
@@ -63,6 +200,11 @@ export declare function isNotFoundError(error: unknown): boolean;
63
200
  * Whether a thrown SDK error is an HTTP 403 — the token reached the API but lacks the permission for
64
201
  * the operation. The signal a caller uses to turn a raw "Unauthorized" into an actionable
65
202
  * "grant this permission group" message instead of a generic request failure.
203
+ *
204
+ * Strictly 403, and it stays that way: its callers branch on it to *swallow* a denial
205
+ * (`getTokenName` answers `null`), so widening it to 401 would report a dead token as a permission
206
+ * this project does not need. The broader "did the credentials get in" question is
207
+ * {@link AUTH_CLASS_CODES}, which reads the API's own code rather than the status.
66
208
  */
67
209
  export declare function isAuthorizationError(error: unknown): boolean;
68
210
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/client/errors.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,UAAU,EAAE,MAAM,qCAAqC,CAAC;AACjE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AACrE,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAE7B;;;;;;GAMG;AAEH,uFAAuF;AACvF,UAAU,mBAAmB;IAC3B,mDAAmD;IACnD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,4CAA4C;IAC5C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB;AAED,qFAAqF;AACrF,qBAAa,4BAA6B,SAAQ,UAAU;IAC1D,YAAY,IAAI,GAAE,mBAAwB,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,EAYxE;CACF;AAED,sFAAsF;AACtF,qBAAa,sBAAuB,SAAQ,UAAU;IACpD,YAAY,IAAI,GAAE,mBAAwB,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,EAYxE;CACF;AAED,+FAA+F;AAC/F,qBAAa,8BAA+B,SAAQ,UAAU;IAC5D,YAAY,IAAI,GAAE,mBAAwB,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,EAYxE;CACF;AAED;;;;;GAKG;AACH,wBAAsB,iBAAiB,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAU9F;AAED,kGAAkG;AAClG,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEhD;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAG/C;AAED,kGAAkG;AAClG,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEvD;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE5D;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,CAAC,SAAS,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CASzG"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/client/errors.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
- import { PithyError } from "@pithy-sh/core/src/error/pithyError";
3
+ import { PithyError, UpstreamTimeoutError } from "@pithy-sh/core/src/error/pithyError";
4
+ import { MAX_WORKFLOW_STEP_TEXT } from "@pithy-sh/core/src/workflow/stepMessage";
4
5
  //#region src/client/errors.ts
5
6
  /** A required piece of configuration (token, account id, resource id) is missing. */
6
7
  var CloudflareNotConfiguredError = class extends PithyError {
@@ -15,7 +16,16 @@ var CloudflareNotConfiguredError = class extends PithyError {
15
16
  }, options);
16
17
  }
17
18
  };
18
- /** A call to the Cloudflare REST API failed (network error, 4xx/5xx from the API). */
19
+ /**
20
+ * A call to the Cloudflare REST API failed (network error, 4xx/5xx from the API).
21
+ *
22
+ * **The `apiAnswer` default is the guarantee, not a convenience.** `core`'s `GUARANTEED_ERROR_PARAMS`
23
+ * declares that every throw of `cloudflare/request_failed` passes `apiAnswer`, and a locale wrote a
24
+ * `{apiAnswer}` against that declaration — so a path that omits it renders the placeholder literally on
25
+ * a Spanish reader's screen. Guaranteed means every throw, so the promise is kept where the code is
26
+ * fixed rather than at each of the sites that construct one: an empty answer is what "the call never
27
+ * reached Cloudflare" renders as, and it is the honest value for any path that has nothing to report.
28
+ */
19
29
  var CloudflareRequestError = class extends PithyError {
20
30
  constructor(args = {}, options) {
21
31
  super({
@@ -24,7 +34,10 @@ var CloudflareRequestError = class extends PithyError {
24
34
  message: args.message ?? "A Cloudflare REST API call failed.",
25
35
  action: args.action,
26
36
  detail: args.detail,
27
- params: args.params
37
+ params: {
38
+ apiAnswer: "",
39
+ ...args.params
40
+ }
28
41
  }, options);
29
42
  }
30
43
  };
@@ -42,21 +55,412 @@ var CloudflareInvalidResponseError = class extends PithyError {
42
55
  }
43
56
  };
44
57
  /**
58
+ * How many of Cloudflare's error entries reach the refusal, and how long each sentence may be.
59
+ *
60
+ * Bounded because this text lands in `message`, and `message` is the one field that crosses to a
61
+ * client (CLAUDE.md §Errors). An operator gets the whole body in `detail`; a refusal stays readable.
62
+ *
63
+ * Bounded is not the same as **shaped**, which is why {@link oneLine} exists beside these two. A `\n`
64
+ * inside Cloudflare's sentence would put the remainder at column zero, indistinguishable from Pithy's
65
+ * own action line, and would cross to a browser exactly as written. So upstream text is flattened to
66
+ * one line before it is measured: sanitize, then slice, never the reverse.
67
+ */
68
+ const MAX_API_ERRORS = 3;
69
+ const MAX_API_MESSAGE = 200;
70
+ /**
71
+ * The most a composed refusal `message` may run to, and it is **derived, not chosen**.
72
+ *
73
+ * A refusal is raised inside Workflow steps as well as at a CLI — `classifiedSteps` wraps the store
74
+ * calls in `@pithy-sh/secrets`' rotation, `@pithy-sh/media`'s Stream reads, `@pithy-sh/payments`'
75
+ * reconcile — and across that durable boundary the step's text is the whole channel, measured against
76
+ * core's `MAX_WORKFLOW_STEP_TEXT`. The encoder truncates past it rather than losing the field, so this
77
+ * is not a guard against disaster; it is the reason the operator's terminal and the step record say the
78
+ * same sentence. A refusal composed to a bound nobody else holds would be cut somewhere else.
79
+ */
80
+ const MAX_REFUSAL_MESSAGE = MAX_WORKFLOW_STEP_TEXT;
81
+ /**
82
+ * The most the **problem line** may run to before the answer is composed after it.
83
+ *
84
+ * **Truncating the composed sentence is not enough, because it cuts the wrong half.** A call site
85
+ * interpolates caller data into the problem — `KV get for key '<key>'`, `R2 copy '<src>' to '<dst>'` —
86
+ * and Cloudflare permits a 512-byte KV key and a 1024-byte R2 one, two per label. A head-preserving
87
+ * slice at {@link MAX_REFUSAL_MESSAGE} therefore discards Pithy's *and* Cloudflare's words in that
88
+ * order, so a long key silently ate the API answer this whole path exists to surface: at a 512-char
89
+ * key the message kept neither the code nor the link, and ended in 59 characters of key.
90
+ *
91
+ * The problem line is bounded first, so the answer always has room. 200 is generous for every operation
92
+ * label in the repository and still leaves the greater part of the budget to Cloudflare.
93
+ */
94
+ const MAX_PROBLEM_LINE = 200;
95
+ /** `text`, or as much of it as `limit` allows with a trailing ellipsis standing for the rest. */
96
+ function bounded(text, limit) {
97
+ return text.length <= limit ? text : `${text.slice(0, limit - 1).trimEnd()}…`;
98
+ }
99
+ /** Upstream text as one line: every whitespace run — newline, tab, CR — collapsed to a single space. */
100
+ function oneLine(text) {
101
+ return text.replace(/\s+/g, " ").trim();
102
+ }
103
+ /**
104
+ * Cloudflare's auth-class codes — the ones that mean "your credentials did not get you in", as
105
+ * opposed to a missing resource (`12000`), a conflict (`12042`) or a rejected argument (`10202`).
106
+ *
107
+ * Status alone is not the discriminator, which is the trap #534 walked into: the reported Turnstile
108
+ * refusal was `10000` under a **401**, while `isAuthorizationError` below keys on 403 and would not
109
+ * have fired. Cloudflare returns `10000` under 400, 401 and 403 depending on the endpoint, so the
110
+ * code is what identifies the class and the status is only a fallback for a body-less throw.
111
+ */
112
+ const AUTH_CLASS_CODES = /* @__PURE__ */ new Set([
113
+ 9103,
114
+ 9106,
115
+ 9107,
116
+ 9109,
117
+ 1e4,
118
+ 10001
119
+ ]);
120
+ /**
121
+ * Cloudflare API resource segment → the account permission group whose absence explains a refusal.
122
+ *
123
+ * The product name comes from Cloudflare's own `documentation_url`
124
+ * (`/api/resources/turnstile/subresources/widgets/methods/list` → `turnstile`), not from the
125
+ * `operation` string a call site wrote: roughly a third of those never name the product, and the
126
+ * manager that does know (`getServiceType()`) is a `this` no free function sees. Deriving it from the
127
+ * failure itself is what lets all 134 call sites improve without one of them changing.
128
+ *
129
+ * A segment absent here yields no product name and the generic action — a wrong grant named
130
+ * confidently is worse than none. Verify a new row against the dashboard's token editor before
131
+ * adding it.
132
+ */
133
+ const ACCOUNT_PERMISSIONS = {
134
+ ai: "Workers AI",
135
+ d1: "D1",
136
+ images: "Cloudflare Images",
137
+ kv: "Workers KV Storage",
138
+ queues: "Queues",
139
+ r2: "Workers R2 Storage",
140
+ secrets_store: "Secrets Store",
141
+ stream: "Stream",
142
+ turnstile: "Turnstile",
143
+ vectorize: "Vectorize",
144
+ workers: "Workers Scripts"
145
+ };
146
+ /**
45
147
  * Run a Cloudflare SDK call and turn any failure into a `CloudflareRequestError`, preserving the
46
148
  * original error as `cause` and its message as internal `detail`. `operation` names the call for
47
149
  * the audit trail (`"KV get for key 'x'"`). A `PithyError` already in flight (e.g. a
48
150
  * not-configured guard) passes through untouched — only foreign throws get wrapped.
151
+ *
152
+ * The refusal carries Cloudflare's own `errors[]` — see {@link cloudflareRefusal}.
153
+ *
154
+ * **A call that ran out of time is `core/upstream_timeout` (504), not a 502.** CLAUDE.md §Errors
155
+ * splits the upstream class in two for a reason a retry policy acts on: a 502 says Cloudflare
156
+ * answered and refused, so the same call refuses again; a 504 says nobody answered, so the same call
157
+ * may well succeed — **and may already have been applied**, which is the fact a caller needs before it
158
+ * retries a create. Folding a connect timeout into `cloudflare/request_failed` told every reader the
159
+ * first story about the second event.
160
+ *
161
+ * `permission` names the account permission group behind this endpoint for the refusal's action line,
162
+ * for the endpoints whose docs link does not say. It is the same fact `cloudflareRefusal` takes, handed
163
+ * in here so the *first* call of a command can carry it: `pithy token mint` refuses at
164
+ * `findTokenByName` and `listPermissionGroups`, both of which run before the mint, so a hint attached
165
+ * only to the mint is a hint the operator never reaches.
49
166
  */
50
- async function cloudflareRequest(operation, fn) {
167
+ async function cloudflareRequest(operation, fn, options) {
51
168
  try {
52
169
  return await fn();
53
170
  } catch (error) {
54
171
  if (error instanceof PithyError) throw error;
55
- throw new CloudflareRequestError({
56
- message: `Cloudflare request failed: ${operation}.`,
172
+ if (isTimeout(error)) throw new UpstreamTimeoutError({
173
+ message: `Cloudflare did not answer in time: ${operation}.`,
174
+ action: "Re-run the command. A create that timed out may already have been applied — check before retrying it.",
57
175
  detail: messageOf(error)
58
176
  }, { cause: error });
177
+ throw cloudflareRefusal({
178
+ problem: `Cloudflare request failed: ${operation}.`,
179
+ apiErrors: cloudflareApiErrors(error),
180
+ status: statusOf(error),
181
+ ...options?.permission === void 0 ? {} : { permission: options.permission },
182
+ detail: messageOf(error),
183
+ cause: error
184
+ });
185
+ }
186
+ }
187
+ /**
188
+ * Compose the refusal every failed Cloudflare call renders: the problem line, then the API's own
189
+ * answer, then — only for an auth-class code — what to do about it.
190
+ *
191
+ * One place, so a fix reaches every provisioner at once — and **one place is a claim a test has to
192
+ * hold, because it was false the first time it was written.** Round one of #534 said
193
+ * `CloudflareBuildsManager` was the only path composing a refusal by hand; `mintToken`, the Stream
194
+ * direct upload and two integration helpers were composing their own, so `pithy token mint` — the
195
+ * bootstrap command an under-scoped token meets first — still printed the operation and nothing else
196
+ * on the identical 401 body. `refusalSites.test.ts` now fails any `new CloudflareRequestError(`
197
+ * outside this file, so "the only place" is enforced rather than remembered.
198
+ *
199
+ * CLAUDE.md §Errors decides where each string lands. Cloudflare's sentence and its docs link are the
200
+ * **caller's** information — what happened, in the words of whoever it happened to — so they go in
201
+ * `message`, field by field off `errors[]` and never as `JSON.stringify(body)`: these managers are
202
+ * imported into Worker paths too, so `message` can reach a browser through `clientError`. The
203
+ * structured `code` and link ride along in `params`, which is client-facing by definition, so a
204
+ * translating client can render them under the error code. "Add this grant, then re-run" names a
205
+ * console the operator is sitting in front of, so it is an `action`. The raw body stays `detail`.
206
+ *
207
+ * The code stays `cloudflare/request_failed` at 502: a dedicated sibling of `core/upstream_failed`,
208
+ * already the upstream class CLAUDE.md asks for, and pinned by `core`'s payload test.
209
+ *
210
+ * **The message is one line, and that is a kit-wide rule this had to learn the hard way.** Round one
211
+ * of #534 made it a small document — problem line, `errors[]` indented beneath, action last — which
212
+ * reads beautifully in a terminal and is unencodable everywhere else. A `PithyError` raised inside a
213
+ * `classifiedSteps` step crosses a durable boundary as one string whose newline separates the sentence
214
+ * from the remedy, so a multi-line `message` was declined whole at the reader and the operator got
215
+ * `core/workflow_failed` 500 about durable execution instead of the API answer this issue exists to
216
+ * show them. The same newline is what the CLI's action line is, so an upstream sentence containing one
217
+ * forges a remedy Pithy never wrote. `Cloudflare said:` marks the boundary between our words and
218
+ * theirs with a word instead of an indent, and a word survives a step record, a `--json` line and a
219
+ * browser.
220
+ */
221
+ function cloudflareRefusal(args) {
222
+ const shown = args.apiErrors.slice(0, MAX_API_ERRORS);
223
+ const said = cloudflareSaid(args.problem, shown);
224
+ return new CloudflareRequestError({
225
+ message: said.message,
226
+ action: permissionAction(shown, args.status, args.permission),
227
+ params: said.params,
228
+ detail: args.detail
229
+ }, { cause: args.cause });
230
+ }
231
+ /**
232
+ * Pithy's problem line with Cloudflare's own answer on the end of it, and the same answer as `params`.
233
+ *
234
+ * Exported because {@link cloudflareRefusal} is not the only refusal that must carry it. The 403 on
235
+ * `accounts/tokens create` says something a generic refusal cannot — the token got in and may not
236
+ * create tokens — so it keeps its own diagnosis and its own code, and *that* is what made it drop
237
+ * Cloudflare's answer for two rounds: a call site with a better sentence had no way to keep the API's
238
+ * words. Now it composes the sentence here like everything else, and the only thing it supplies is the
239
+ * problem line.
240
+ *
241
+ * One line, always. Whitespace runs collapse and entries join with a semicolon. The problem line is
242
+ * flattened along with the rest because a call site interpolates values into it (`KV get for key
243
+ * '<key>'`), and a key holding a newline is an operator's refusal wearing a forged action line.
244
+ *
245
+ * **What gets cut when it does not fit is the part Pithy can afford to lose.** The problem line is
246
+ * bounded to {@link MAX_PROBLEM_LINE} first, so a 512-byte KV key or a 1024-byte R2 one cannot crowd out
247
+ * the answer; then whole entries are dropped from the end until the sentence fits
248
+ * {@link MAX_REFUSAL_MESSAGE}, the bound of the durable step channel. Slicing the composed sentence
249
+ * instead — which is what this did — discards the tail, and the tail is always Cloudflare's answer.
250
+ */
251
+ function cloudflareSaid(problem, apiErrors) {
252
+ const shown = apiErrors.slice(0, MAX_API_ERRORS);
253
+ const primary = shown.find((entry) => entry.code !== void 0) ?? shown[0];
254
+ const phrases = shown.map(apiErrorPhrase).filter((phrase) => phrase !== "");
255
+ const head = bounded(oneLine(problem), MAX_PROBLEM_LINE);
256
+ if (phrases.length === 0) return {
257
+ message: head,
258
+ params: apiErrorParams(primary)
259
+ };
260
+ const kept = [...phrases];
261
+ const compose = () => oneLine(`${head} Cloudflare said: ${kept.join("; ")}${kept.length < phrases.length ? "; …" : ""}`);
262
+ let composed = compose();
263
+ while (kept.length > 1 && composed.length > MAX_REFUSAL_MESSAGE) {
264
+ kept.pop();
265
+ composed = compose();
266
+ }
267
+ return {
268
+ message: bounded(composed, MAX_REFUSAL_MESSAGE),
269
+ params: apiErrorParams(primary)
270
+ };
271
+ }
272
+ /**
273
+ * Cloudflare's `errors[]`, read off a thrown SDK error (`APIError.errors`) or handed the array
274
+ * straight from an unwrapped envelope. Duck-typed rather than `instanceof APIError`, matching
275
+ * {@link isNotFoundError}: the SDK is one producer of this shape and the raw-`fetch` path is another.
276
+ *
277
+ * Entries are read field by field and dropped when they carry neither a code nor a sentence, so a
278
+ * malformed body degrades to today's refusal instead of rendering an empty line. A
279
+ * `documentation_url` is kept only when it is `https:` — the link is printed, and a printed link is
280
+ * one somebody clicks.
281
+ */
282
+ function cloudflareApiErrors(source) {
283
+ const raw = Array.isArray(source) ? source : typeof source === "object" && source !== null ? source.errors : void 0;
284
+ if (!Array.isArray(raw)) return [];
285
+ return raw.flatMap(toApiError);
286
+ }
287
+ /** One raw `errors[]` entry, narrowed — or nothing, when it says nothing. */
288
+ function toApiError(entry) {
289
+ if (typeof entry !== "object" || entry === null) return [];
290
+ const { code, message, documentation_url: docs } = entry;
291
+ const parsed = {};
292
+ if (typeof code === "number") parsed.code = code;
293
+ if (typeof message === "string") {
294
+ const flattened = oneLine(message).slice(0, MAX_API_MESSAGE);
295
+ if (flattened.length > 0) parsed.message = flattened;
59
296
  }
297
+ if (typeof docs === "string" && docs.startsWith("https://") && !/\s/.test(docs)) parsed.documentationUrl = docs;
298
+ return parsed.code === void 0 && parsed.message === void 0 ? [] : [parsed];
299
+ }
300
+ /** One entry as the operator reads it, inline: `<code> <sentence> — <link>`. */
301
+ function apiErrorPhrase(entry) {
302
+ const said = [entry.code, entry.message].filter((part) => part !== void 0).join(" ");
303
+ if (said === "") return "";
304
+ return entry.documentationUrl === void 0 ? said : `${said} — ${entry.documentationUrl}`;
305
+ }
306
+ /**
307
+ * The API's answer as a translating client receives it: one sentence a locale can write into its own
308
+ * wording, plus the structured fields a client may render itself.
309
+ *
310
+ * **`apiAnswer` is supplied on every refusal, and that is the whole point of it.** A locale's sentence
311
+ * is one string with no `if` in it, and `interpolate` leaves a placeholder nobody supplied written out
312
+ * as `{apiAnswer}` — so a `{placeholder}` in a catalog is a claim that *every* throw of this code
313
+ * passes that name, degraded paths included. A call that never reached Cloudflare has no answer, so
314
+ * this one is `""` there, and the value carries its own leading separator (`": 10000 Authentication
315
+ * error"`) so the one sentence closes correctly with the answer and without it. `core`'s
316
+ * `GUARANTEED_ERROR_PARAMS` is where that promise is declared and where `@pithy-sh/i18n` reads it.
317
+ *
318
+ * The three structured fields stay optional, because Cloudflare's own body is: an entry may carry a
319
+ * code with no sentence, a sentence with no code, and usually no link at all. They are for a client
320
+ * reading `payload.params` directly, never for a catalog placeholder.
321
+ */
322
+ function apiErrorParams(entry) {
323
+ if (entry === void 0) return { apiAnswer: "" };
324
+ const said = [entry.code, entry.message].filter((part) => part !== void 0).join(" ");
325
+ return {
326
+ apiAnswer: said.length === 0 ? "" : `: ${said}`,
327
+ ...entry.code === void 0 ? {} : { apiCode: entry.code },
328
+ ...entry.message === void 0 ? {} : { apiMessage: entry.message },
329
+ ...entry.documentationUrl === void 0 ? {} : { apiDocumentationUrl: entry.documentationUrl }
330
+ };
331
+ }
332
+ /**
333
+ * The action line for an auth-class refusal, and nothing at all for any other failure — a 500 that
334
+ * grew an "add a permission" line would send every operator down the wrong path.
335
+ *
336
+ * **The API's code decides; the status is only consulted when there is no code.** Cloudflare returns
337
+ * `10000` under 400, 401 and 403 depending on the endpoint, which is why the status cannot be the
338
+ * discriminator — and the converse is the half that shipped wrong: a 403 is *also* how Cloudflare
339
+ * answers `10021 Script startup exceeded CPU limit`, and "add Account → Workers Scripts to it" changes
340
+ * nothing about a CPU limit. So when Cloudflare named any code at all, the codes are the whole of the
341
+ * evidence and the status is not consulted; only a body-less throw (`401 status code (no body)`) falls
342
+ * back to it.
343
+ *
344
+ * **What is known and what is concluded are kept apart, because the refusal cannot tell them apart.**
345
+ * Cloudflare answered `10000` for this one call; that is all it said. A token missing this product's
346
+ * grant, a revoked token and a token pointed at the wrong account all produce it, and this process can
347
+ * separate none of the three. So the line names all three, in the order an operator can check them,
348
+ * and asserts none. It named two for one round — "reaches other products" or "reaches none" — which
349
+ * excluded the third by construction: a token in the *wrong* account reaches other products in that
350
+ * account, so the operator was told to add a grant they already held.
351
+ *
352
+ * The **level** is deliberately absent too. The call that raised #534 was a *list* — Read would have
353
+ * satisfied it — so "Turnstile → Edit" would have been a guess dressed as an instruction. Name the
354
+ * group; the operator picks the level their calls need.
355
+ */
356
+ function permissionAction(apiErrors, status, hint) {
357
+ if (!isAuthClassRefusal(apiErrors, status)) return void 0;
358
+ const permission = accountPermissionOf(apiErrors) ?? hint;
359
+ return `A missing grant, a dead token and the wrong account all look the same here. Check ${permission === void 0 ? "the token's permission for this product" : `the token for Account → ${permission}`}, then CLOUDFLARE_API_TOKEN, then CLOUDFLARE_ACCOUNT_ID.`;
360
+ }
361
+ /**
362
+ * Whether the refusal is the credentials failing rather than the request — the one question the action
363
+ * line above is allowed to answer. Codes win where there are codes; status is the body-less fallback.
364
+ */
365
+ function isAuthClassRefusal(apiErrors, status) {
366
+ const coded = apiErrors.filter((entry) => entry.code !== void 0);
367
+ if (coded.length > 0) return coded.some((entry) => AUTH_CLASS_CODES.has(entry.code));
368
+ return status === 401 || status === 403;
369
+ }
370
+ /** The account permission group behind the refused endpoint, per Cloudflare's own docs link. */
371
+ function accountPermissionOf(apiErrors) {
372
+ for (const entry of apiErrors) {
373
+ if (entry.documentationUrl === void 0) continue;
374
+ const segments = entry.documentationUrl.split("/");
375
+ const resource = segments[segments.indexOf("resources") + 1];
376
+ if (resource === void 0 || !Object.hasOwn(ACCOUNT_PERMISSIONS, resource)) continue;
377
+ return ACCOUNT_PERMISSIONS[resource];
378
+ }
379
+ }
380
+ /**
381
+ * The names a timed-out call throws under, **measured off the two producers rather than guessed**.
382
+ *
383
+ * Round two read `error.name` and `error.code`, which is what an SDK error and an undici error look
384
+ * like in a fixture somebody types out. Driven against the real thing, neither producer sets what was
385
+ * being read, and the 504 branch could not fire for either:
386
+ *
387
+ * - **The SDK never assigns `name`.** `APIConnectionTimeoutError extends APIConnectionError extends
388
+ * APIError extends CloudflareError extends Error`, and not one of them touches `this.name` — so
389
+ * `error.name` is `"Error"` and `error.code` is `undefined`. Driven with a real `Cloudflare` client
390
+ * (`maxRetries: 0`, `timeout: 150`) against a `node:net` server that accepts and never answers:
391
+ * `ctor=APIConnectionTimeoutError name="Error" code=undefined status=undefined msg="Request timed
392
+ * out."`. The class name is the only marker it carries, so the constructor is read as well as `name`.
393
+ * - **`fetch` puts the timeout on `cause`, not on the throw.** A connect timeout rejects with
394
+ * `TypeError: fetch failed` — `name="TypeError"`, `code=undefined` — and undici's own
395
+ * `ConnectTimeoutError` (`code="UND_ERR_CONNECT_TIMEOUT"`) is one level down. Driven against
396
+ * TEST-NET-1 (`http://192.0.2.1`, 10.5s, undici's default connect timeout). So the chain is walked;
397
+ * reading only the top level made `TIMEOUT_CODES` dead for the raw-`fetch` escape hatch in
398
+ * `CloudflareBuildsManager` that it was written for.
399
+ *
400
+ * Both are pinned by `timeoutProducers.test.ts`, which builds its fixtures from those producers rather
401
+ * than from this list — a fixture written by hand agrees with whoever wrote it.
402
+ *
403
+ * Duck-typed rather than `instanceof APIConnectionTimeoutError`, matching every other predicate here:
404
+ * the SDK is one producer and `fetch` is another, and an `instanceof` across two copies of the SDK
405
+ * answers false. `AbortSignal.timeout` is the third — a `DOMException` whose `name` really is
406
+ * `"TimeoutError"` — and it is what a caller-imposed deadline raises.
407
+ */
408
+ const TIMEOUT_NAMES = /* @__PURE__ */ new Set([
409
+ "APIConnectionTimeoutError",
410
+ "TimeoutError",
411
+ "ConnectTimeoutError",
412
+ "HeadersTimeoutError",
413
+ "BodyTimeoutError"
414
+ ]);
415
+ const TIMEOUT_CODES = /* @__PURE__ */ new Set([
416
+ "ETIMEDOUT",
417
+ "ESOCKETTIMEDOUT",
418
+ "UND_ERR_CONNECT_TIMEOUT",
419
+ "UND_ERR_HEADERS_TIMEOUT",
420
+ "UND_ERR_BODY_TIMEOUT"
421
+ ]);
422
+ /** How far down a `cause` chain the reason is looked for. Bounded, so a cyclic chain cannot spin. */
423
+ const MAX_CAUSE_DEPTH = 5;
424
+ /**
425
+ * Whether a throw is "nobody answered in time" rather than "Cloudflare answered and refused".
426
+ *
427
+ * **A throw carrying a status answered**, whatever is underneath it: an SDK `APIError` is Cloudflare's
428
+ * own reply, so its cause chain is not consulted and the only statuses that mean time ran out are the
429
+ * three below. `408` and `504` are here because a proxy in front of the API answers them for exactly
430
+ * this event; `524` is Cloudflare's own "a timeout occurred", and it can reach a manager that fronts a
431
+ * Worker. Every other throw is a transport failure, and the reason for one lives somewhere in its
432
+ * `cause` chain — `TypeError: fetch failed` → `ConnectTimeoutError`, or the SDK's `APIConnectionError`
433
+ * → `TypeError` → the socket error. A refused connection walks the same chain and states `ECONNREFUSED`
434
+ * at the bottom of it, which is not in either set, so it stays the 502 it should be.
435
+ */
436
+ function isTimeout(error) {
437
+ const answered = statusOf(error);
438
+ if (answered !== void 0) return answered === 408 || answered === 504 || answered === 524;
439
+ let current = error;
440
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && typeof current === "object" && current !== null; depth += 1) {
441
+ if (statesTimeout(current)) return true;
442
+ current = current.cause;
443
+ }
444
+ return false;
445
+ }
446
+ /** Whether one link of a `cause` chain says, in any of the three ways a producer says it, "out of time". */
447
+ function statesTimeout(error) {
448
+ const { name, code, constructor: ctor } = error;
449
+ if (typeof name === "string" && TIMEOUT_NAMES.has(name)) return true;
450
+ if (typeof code === "string" && TIMEOUT_CODES.has(code)) return true;
451
+ const className = ctor?.name;
452
+ return typeof className === "string" && TIMEOUT_NAMES.has(className);
453
+ }
454
+ /**
455
+ * The HTTP status of a thrown SDK error, when it carries one. Exported because a call site that
456
+ * composes its own refusal through {@link cloudflareRefusal} has to hand it the same status this
457
+ * module would have read — a hand-rolled `(error as { status?: number }).status` beside it is the
458
+ * duplicate that drifts.
459
+ */
460
+ function statusOf(error) {
461
+ if (typeof error !== "object" || error === null) return void 0;
462
+ const status = error.status;
463
+ return typeof status === "number" ? status : void 0;
60
464
  }
61
465
  /** The raw message of an unknown throw, for use as a PithyError `detail`. One source of truth. */
62
466
  function messageOf(error) {
@@ -79,6 +483,11 @@ function isNotFoundError(error) {
79
483
  * Whether a thrown SDK error is an HTTP 403 — the token reached the API but lacks the permission for
80
484
  * the operation. The signal a caller uses to turn a raw "Unauthorized" into an actionable
81
485
  * "grant this permission group" message instead of a generic request failure.
486
+ *
487
+ * Strictly 403, and it stays that way: its callers branch on it to *swallow* a denial
488
+ * (`getTokenName` answers `null`), so widening it to 401 would report a dead token as a permission
489
+ * this project does not need. The broader "did the credentials get in" question is
490
+ * {@link AUTH_CLASS_CODES}, which reads the API's own code rather than the status.
82
491
  */
83
492
  function isAuthorizationError(error) {
84
493
  return typeof error === "object" && error !== null && error.status === 403;
@@ -97,4 +506,4 @@ function decodeResponse(schema, raw, context) {
97
506
  return parsed.data;
98
507
  }
99
508
  //#endregion
100
- export { CloudflareInvalidResponseError, CloudflareNotConfiguredError, CloudflareRequestError, cloudflareRequest, decodeResponse, isAuthorizationError, isNotFoundError, messageOf, reasonOf };
509
+ export { CloudflareInvalidResponseError, CloudflareNotConfiguredError, CloudflareRequestError, cloudflareApiErrors, cloudflareRefusal, cloudflareRequest, cloudflareSaid, decodeResponse, isAuthorizationError, isNotFoundError, messageOf, reasonOf, statusOf };