@pithy-sh/cloudflare 0.8.1 → 0.9.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.
@@ -4,14 +4,14 @@ import { z } from "zod";
4
4
  import { CloudflareManager } from "../client/manager";
5
5
  import { type AnalyticsDataset } from "./datasets";
6
6
  /**
7
- * **Cloudflare's GraphQL Analytics API, for the account-level datasets the kit reads (#728).**
7
+ * **Cloudflare's GraphQL Analytics API, for the account-level datasets the kit reads.**
8
8
  *
9
9
  * One endpoint, `POST /graphql`, reached through the SDK's raw `post` like every other call the typed SDK
10
10
  * does not cover — never a hand-rolled `fetch` (CLAUDE.md §Cloudflare access). It answers HTTP 200 with
11
11
  * `{ data, errors }` even when it refuses, so `errors` is read on every answer and a non-empty one is a
12
12
  * refusal in Cloudflare's own words.
13
13
  *
14
- * The token it runs on is the application's one `runtime` token, carrying "Account Analytics Read", whose grain is
14
+ * The token it runs on is the application's one `runtime` token, carrying {@link ANALYTICS_PERMISSION}, whose grain is
15
15
  * the whole account. This manager narrows nothing on its own: every query takes the keys it is about, and
16
16
  * the caller decides which keys those are.
17
17
  */
@@ -68,7 +68,7 @@ export declare class CloudflareAnalyticsManager extends CloudflareManager {
68
68
  imageIds: readonly string[];
69
69
  }): Promise<Map<string, number>>;
70
70
  /**
71
- * Turnstile events per sitekey and event type inside the window, for the sitekeys given (#731). The sitekey
71
+ * Turnstile events per sitekey and event type inside the window, for the sitekeys given. The sitekey
72
72
  * filter is what scopes the account-wide dataset to the widgets the caller is about. Absent means none.
73
73
  */
74
74
  turnstileEvents(window: WindowQuery & {
@@ -1 +1 @@
1
- {"version":3,"file":"analyticsManager.d.ts","sourceRoot":"","sources":["../../src/analytics/analyticsManager.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,EAAE,KAAK,gBAAgB,EAAoD,MAAM,YAAY,CAAC;AAErG;;;;;;;;;;;GAWG;AAEH;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,OAAO,CAAC;AAEhD;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,MAAM,CAAC;AAEvC,sGAAsG;AACtG,eAAO,MAAM,aAAa;;;;;iBAOiF,CAAC;AAC5G,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAgE1D,4CAA4C;AAC5C,MAAM,WAAW,WAAW;IAC1B,qCAAqC;IACrC,KAAK,EAAE,IAAI,CAAC;IACZ,mCAAmC;IACnC,KAAK,EAAE,IAAI,CAAC;CACb;AASD,6DAA6D;AAC7D,qBAAa,0BAA2B,SAAQ,iBAAiB;IAC/D,cAAc,IAAI,MAAM,CAEvB;IAED,iFAAiF;IAC3E,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED;;;OAGG;YACW,KAAK;IAsBnB,mFAAmF;IACnF,OAAO,CAAC,WAAW;IAWnB;;;OAGG;IACG,aAAa,CACjB,QAAQ,EAAE,SAAS,gBAAgB,EAAE,GACpC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,gBAAgB,EAAE,aAAa,CAAC,CAAC,CAAC,CA2B3D;IAED,0GAA0G;IACpG,mBAAmB,CAAC,MAAM,EAAE,WAAW,GAAG;QAAE,IAAI,EAAE,SAAS,MAAM,EAAE,CAAA;KAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAuBzG;IAED,qGAAqG;IAC/F,aAAa,CAAC,MAAM,EAAE,WAAW,GAAG;QAAE,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;KAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CA2BvG;IAED;;;OAGG;IACG,eAAe,CACnB,MAAM,EAAE,WAAW,GAAG;QAAE,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;KAAE,GACpD,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAmC3C;CACF"}
1
+ {"version":3,"file":"analyticsManager.d.ts","sourceRoot":"","sources":["../../src/analytics/analyticsManager.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,EAAE,KAAK,gBAAgB,EAAoD,MAAM,YAAY,CAAC;AAGrG;;;;;;;;;;;GAWG;AAEH;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,OAAO,CAAC;AAEhD;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,MAAM,CAAC;AAEvC,sGAAsG;AACtG,eAAO,MAAM,aAAa;;;;;iBAOiF,CAAC;AAC5G,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAgE1D,4CAA4C;AAC5C,MAAM,WAAW,WAAW;IAC1B,qCAAqC;IACrC,KAAK,EAAE,IAAI,CAAC;IACZ,mCAAmC;IACnC,KAAK,EAAE,IAAI,CAAC;CACb;AASD,6DAA6D;AAC7D,qBAAa,0BAA2B,SAAQ,iBAAiB;IAC/D,cAAc,IAAI,MAAM,CAEvB;IAED,iFAAiF;IAC3E,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED;;;OAGG;YACW,KAAK;IA6BnB,mFAAmF;IACnF,OAAO,CAAC,WAAW;IAWnB;;;OAGG;IACG,aAAa,CACjB,QAAQ,EAAE,SAAS,gBAAgB,EAAE,GACpC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,gBAAgB,EAAE,aAAa,CAAC,CAAC,CAAC,CA2B3D;IAED,0GAA0G;IACpG,mBAAmB,CAAC,MAAM,EAAE,WAAW,GAAG;QAAE,IAAI,EAAE,SAAS,MAAM,EAAE,CAAA;KAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAuBzG;IAED,qGAAqG;IAC/F,aAAa,CAAC,MAAM,EAAE,WAAW,GAAG;QAAE,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;KAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CA2BvG;IAED;;;OAGG;IACG,eAAe,CACnB,MAAM,EAAE,WAAW,GAAG;QAAE,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;KAAE,GACpD,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAmC3C;CACF"}
@@ -3,17 +3,18 @@
3
3
  import { CloudflareInvalidResponseError, cloudflareRefusal, cloudflareRequest, decodeResponse } from "../client/errors.js";
4
4
  import { CloudflareManager } from "../client/manager.js";
5
5
  import { IMAGE_REQUESTS, STREAM_MINUTES, TURNSTILE_EVENTS } from "./datasets.js";
6
+ import { ANALYTICS_PERMISSION, GRAPHQL_AUTH_CODES } from "./forbidden.js";
6
7
  import { z } from "zod";
7
8
  //#region src/analytics/analyticsManager.ts
8
9
  /**
9
- * **Cloudflare's GraphQL Analytics API, for the account-level datasets the kit reads (#728).**
10
+ * **Cloudflare's GraphQL Analytics API, for the account-level datasets the kit reads.**
10
11
  *
11
12
  * One endpoint, `POST /graphql`, reached through the SDK's raw `post` like every other call the typed SDK
12
13
  * does not cover — never a hand-rolled `fetch` (CLAUDE.md §Cloudflare access). It answers HTTP 200 with
13
14
  * `{ data, errors }` even when it refuses, so `errors` is read on every answer and a non-empty one is a
14
15
  * refusal in Cloudflare's own words.
15
16
  *
16
- * The token it runs on is the application's one `runtime` token, carrying "Account Analytics Read", whose grain is
17
+ * The token it runs on is the application's one `runtime` token, carrying {@link ANALYTICS_PERMISSION}, whose grain is
17
18
  * the whole account. This manager narrows nothing on its own: every query takes the keys it is about, and
18
19
  * the caller decides which keys those are.
19
20
  */
@@ -95,16 +96,21 @@ var CloudflareAnalyticsManager = class extends CloudflareManager {
95
96
  const raw = await cloudflareRequest(operation, () => this.getClient().post("/graphql", { body: {
96
97
  query,
97
98
  variables
98
- } }), { permission: "Account Analytics Read" });
99
+ } }), { permission: ANALYTICS_PERMISSION });
99
100
  const answer = decodeResponse(GraphQLAnswer, raw, operation);
100
101
  const errors = answer.errors ?? [];
101
- if (errors.length > 0) throw cloudflareRefusal({
102
- problem: `Cloudflare request failed: ${operation}.`,
103
- apiErrors: errors.map((error) => ({ ...error.message === void 0 ? {} : { message: error.message } })),
104
- permission: "Account Analytics Read",
105
- ...errors.some((error) => error.extensions?.code === RATE_BUDGET_CODE) ? { status: 429 } : {},
106
- detail: JSON.stringify(errors)
107
- });
102
+ if (errors.length > 0) {
103
+ const codes = errors.map((error) => error.extensions?.code);
104
+ const status = codes.some((code) => code === RATE_BUDGET_CODE) ? 429 : codes.some((code) => code !== void 0 && GRAPHQL_AUTH_CODES.has(code)) ? 403 : void 0;
105
+ throw cloudflareRefusal({
106
+ problem: `Cloudflare request failed: ${operation}.`,
107
+ apiErrors: errors.map((error) => ({ ...error.message === void 0 ? {} : { message: error.message } })),
108
+ permission: ANALYTICS_PERMISSION,
109
+ ...status === void 0 ? {} : { status },
110
+ detail: JSON.stringify(errors),
111
+ cause: errors
112
+ });
113
+ }
108
114
  return answer.data;
109
115
  }
110
116
  /** The one account in an answer, refused when the token's viewer cannot see it. */
@@ -172,7 +178,7 @@ var CloudflareAnalyticsManager = class extends CloudflareManager {
172
178
  return totals;
173
179
  }
174
180
  /**
175
- * Turnstile events per sitekey and event type inside the window, for the sitekeys given (#731). The sitekey
181
+ * Turnstile events per sitekey and event type inside the window, for the sitekeys given. The sitekey
176
182
  * filter is what scopes the account-wide dataset to the widgets the caller is about. Absent means none.
177
183
  */
178
184
  async turnstileEvents(window) {
@@ -0,0 +1,50 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
4
+ import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
5
+ /**
6
+ * **An analytics read the Worker's own token may not make, as each caller's own code.**
7
+ *
8
+ * Every capability that reads Cloudflare's GraphQL Analytics does it on the one `runtime` token, which needs
9
+ * {@link ANALYTICS_PERMISSION}. A refusal for want of it is `cloudflare/request_failed` like any other, and
10
+ * that code names no permission a client can read. So a caller wraps its read in {@link guardForbidden},
11
+ * which hands a missing grant to the caller's own error class with the permission in `params` and the
12
+ * remedy in `message`, and lets every other failure through unchanged. No SDK is imported here, so a
13
+ * caller that loads the manager lazily keeps doing so.
14
+ */
15
+ /** The account permission group every analytics read needs, as Cloudflare's token editor names it. */
16
+ export declare const ANALYTICS_PERMISSION = "Account Analytics Read";
17
+ /** The GraphQL `extensions.code` that means the token may not read the account it asked about. */
18
+ export declare const GRAPHQL_AUTH_CODES: ReadonlySet<string>;
19
+ /** A missing grant, in the fields a caller's error class takes. */
20
+ export interface ForbiddenRefusal {
21
+ /** The sentence the caller reads: the permission, and the rotate that adds it. */
22
+ message: string;
23
+ /** `permission`, for a client that translates. */
24
+ params: MessageParams;
25
+ /** The operator's line from the Cloudflare refusal, kept for the terminal and the log. */
26
+ action?: string;
27
+ /** The Cloudflare refusal's detail, kept for the log. */
28
+ detail?: string;
29
+ }
30
+ /** Builds the caller's own error for a missing grant. */
31
+ export type RefuseForbidden = (refusal: ForbiddenRefusal, options: {
32
+ cause: unknown;
33
+ }) => Error;
34
+ /** Where the read ran and what to call it, for the sentence. */
35
+ export interface ForbiddenContext {
36
+ /** The environment this Worker serves, or null when it names none. */
37
+ environment: string | null;
38
+ /** The read as the sentence names it: "the delivery analytics read". */
39
+ read: string;
40
+ }
41
+ /**
42
+ * Whether a failure is an analytics query refused because the credentials did not get in: a 401 or 403, or
43
+ * Cloudflare's auth-class code, or GraphQL's `authz`. A spent rate budget, a 5xx and a rejected query are not.
44
+ */
45
+ export declare function isAnalyticsForbidden(error: unknown): boolean;
46
+ /** A missing grant on `error`, as the fields of the caller's own error. */
47
+ export declare function forbiddenRefusal(error: PithyError, context: ForbiddenContext): ForbiddenRefusal;
48
+ /** Run an analytics read, refusing a missing grant with the caller's own error and passing every other failure on. */
49
+ export declare function guardForbidden<T>(read: () => Promise<T>, context: ForbiddenContext, refuse: RefuseForbidden): Promise<T>;
50
+ //# sourceMappingURL=forbidden.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"forbidden.d.ts","sourceRoot":"","sources":["../../src/analytics/forbidden.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,UAAU,EAAE,MAAM,qCAAqC,CAAC;AACjE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AAIrE;;;;;;;;;GASG;AAEH,sGAAsG;AACtG,eAAO,MAAM,oBAAoB,2BAA2B,CAAC;AAE7D,kGAAkG;AAClG,eAAO,MAAM,kBAAkB,EAAE,WAAW,CAAC,MAAM,CAAsB,CAAC;AAE1E,mEAAmE;AACnE,MAAM,WAAW,gBAAgB;IAC/B,kFAAkF;IAClF,OAAO,EAAE,MAAM,CAAC;IAChB,kDAAkD;IAClD,MAAM,EAAE,aAAa,CAAC;IACtB,0FAA0F;IAC1F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yDAAyD;IACzD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,yDAAyD;AACzD,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,EAAE,gBAAgB,EAAE,OAAO,EAAE;IAAE,KAAK,EAAE,OAAO,CAAA;CAAE,KAAK,KAAK,CAAC;AAEhG,gEAAgE;AAChE,MAAM,WAAW,gBAAgB;IAC/B,sEAAsE;IACtE,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;CACd;AAWD;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAG5D;AAWD,2EAA2E;AAC3E,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,gBAAgB,GAAG,gBAAgB,CAO/F;AAED,sHAAsH;AACtH,wBAAsB,cAAc,CAAC,CAAC,EACpC,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACtB,OAAO,EAAE,gBAAgB,EACzB,MAAM,EAAE,eAAe,GACtB,OAAO,CAAC,CAAC,CAAC,CAOZ"}
@@ -0,0 +1,66 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { isAuthClassFailure } from "../client/errors.js";
4
+ import { RUNTIME_PROFILE } from "../tokens/runtime.js";
5
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
6
+ import { commandName, remedy } from "@pithy-sh/core/src/cli/command";
7
+ //#region src/analytics/forbidden.ts
8
+ /**
9
+ * **An analytics read the Worker's own token may not make, as each caller's own code.**
10
+ *
11
+ * Every capability that reads Cloudflare's GraphQL Analytics does it on the one `runtime` token, which needs
12
+ * {@link ANALYTICS_PERMISSION}. A refusal for want of it is `cloudflare/request_failed` like any other, and
13
+ * that code names no permission a client can read. So a caller wraps its read in {@link guardForbidden},
14
+ * which hands a missing grant to the caller's own error class with the permission in `params` and the
15
+ * remedy in `message`, and lets every other failure through unchanged. No SDK is imported here, so a
16
+ * caller that loads the manager lazily keeps doing so.
17
+ */
18
+ /** The account permission group every analytics read needs, as Cloudflare's token editor names it. */
19
+ const ANALYTICS_PERMISSION = "Account Analytics Read";
20
+ /** The GraphQL `extensions.code` that means the token may not read the account it asked about. */
21
+ const GRAPHQL_AUTH_CODES = /* @__PURE__ */ new Set(["authz"]);
22
+ /** Whether a GraphQL `errors` array, kept as a refusal's cause, says the token may not read the account. */
23
+ function isGraphQLAuthRefusal(cause) {
24
+ if (!Array.isArray(cause)) return false;
25
+ return cause.some((entry) => {
26
+ const code = entry?.extensions?.code;
27
+ return typeof code === "string" && GRAPHQL_AUTH_CODES.has(code);
28
+ });
29
+ }
30
+ /**
31
+ * Whether a failure is an analytics query refused because the credentials did not get in: a 401 or 403, or
32
+ * Cloudflare's auth-class code, or GraphQL's `authz`. A spent rate budget, a 5xx and a rejected query are not.
33
+ */
34
+ function isAnalyticsForbidden(error) {
35
+ if (!(error instanceof PithyError) || error.payload.code !== "cloudflare/request_failed") return false;
36
+ return isAuthClassFailure(error.cause) || isGraphQLAuthRefusal(error.cause);
37
+ }
38
+ /** The remedy a caller reads: rotate re-scopes the runtime token, where a mint only rolls its value. */
39
+ function rotateSentence(environment) {
40
+ const tail = "to re-scope it; a mint only rolls the value.";
41
+ if (environment === null) return `Rotate it with \`${commandName("token rotate")}\` ${tail}`;
42
+ return remedy("token rotate", {
43
+ args: [RUNTIME_PROFILE],
44
+ flags: { "--env": environment }
45
+ }, { runtime: true }).say((command) => `Run \`${command}\` ${tail}`);
46
+ }
47
+ /** A missing grant on `error`, as the fields of the caller's own error. */
48
+ function forbiddenRefusal(error, context) {
49
+ return {
50
+ message: `Cloudflare refused ${context.read}: this Worker's ${RUNTIME_PROFILE} token lacks ${ANALYTICS_PERMISSION}. ${rotateSentence(context.environment)}`,
51
+ params: { permission: ANALYTICS_PERMISSION },
52
+ ...error.payload.action === void 0 ? {} : { action: error.payload.action },
53
+ ...error.payload.detail === void 0 ? {} : { detail: error.payload.detail }
54
+ };
55
+ }
56
+ /** Run an analytics read, refusing a missing grant with the caller's own error and passing every other failure on. */
57
+ async function guardForbidden(read, context, refuse) {
58
+ try {
59
+ return await read();
60
+ } catch (error) {
61
+ if (!(error instanceof PithyError) || !isAnalyticsForbidden(error)) throw error;
62
+ throw refuse(forbiddenRefusal(error, context), { cause: error });
63
+ }
64
+ }
65
+ //#endregion
66
+ export { ANALYTICS_PERMISSION, GRAPHQL_AUTH_CODES, forbiddenRefusal, guardForbidden, isAnalyticsForbidden };
@@ -56,9 +56,8 @@ export declare class CloudflareInvalidResponseError extends PithyError {
56
56
  * One entry of Cloudflare's `errors[]` array — the API's own answer to a failed call.
57
57
  *
58
58
  * Every v4 endpoint answers a failure with `{ success: false, errors: [{ code, message,
59
- * documentation_url }] }`, and the SDK already parses it onto the thrown `APIError`. Until #534 we
60
- * projected none of it: a refusal named the operation and stopped, so a token missing one product's
61
- * grant, a revoked token, a wrong account and an outage all rendered as the same sentence.
59
+ * documentation_url }] }`, and the SDK parses it onto the thrown `APIError`. It is what tells a token
60
+ * missing one product's grant, a revoked token, a wrong account and an outage apart.
62
61
  */
63
62
  export interface CloudflareApiError {
64
63
  /** Cloudflare's numeric error code — the stable discriminator (`10000` is its auth-class code). */
@@ -96,13 +95,9 @@ export declare function cloudflareRequest<T>(operation: string, fn: () => Promis
96
95
  * Compose the refusal every failed Cloudflare call renders: the problem line, then the API's own
97
96
  * answer, then — only for an auth-class code — what to do about it.
98
97
  *
99
- * One place, so a fix reaches every provisioner at once — and **one place is a claim a test has to
100
- * hold, because it was false the first time it was written.** Round one of #534 said
101
- * `CloudflareBuildsManager` was the only path composing a refusal by hand; `mintToken`, the Stream
102
- * direct upload and two integration helpers were composing their own, so `pithy token mint` — the
103
- * bootstrap command an under-scoped token meets first — still printed the operation and nothing else
104
- * on the identical 401 body. `refusalSites.test.ts` now fails any `new CloudflareRequestError(`
105
- * outside this file, so "the only place" is enforced rather than remembered.
98
+ * One place, so a fix reaches every provisioner at once. `refusalSites.test.ts` fails any
99
+ * `new CloudflareRequestError(` outside this file, so "the only place" is enforced rather than
100
+ * remembered.
106
101
  *
107
102
  * CLAUDE.md §Errors decides where each string lands. Cloudflare's sentence and its docs link are the
108
103
  * **caller's** information — what happened, in the words of whoever it happened to — so they go in
@@ -118,14 +113,10 @@ export declare function cloudflareRequest<T>(operation: string, fn: () => Promis
118
113
  * for, and pinned by `core`'s payload test. The words are composed the same way either side of that
119
114
  * branch, so only the two machine-readable fields differ.
120
115
  *
121
- * **The message is one line, and that is a kit-wide rule this had to learn the hard way.** Round one
122
- * of #534 made it a small document — problem line, `errors[]` indented beneath, action last — which
123
- * reads beautifully in a terminal and is unencodable everywhere else. A `PithyError` raised inside a
124
- * `classifiedSteps` step crosses a durable boundary as one string whose newline separates the sentence
125
- * from the remedy, so a multi-line `message` was declined whole at the reader and the operator got
126
- * `core/workflow_failed` 500 about durable execution instead of the API answer this issue exists to
127
- * show them. The same newline is what the CLI's action line is, so an upstream sentence containing one
128
- * forges a remedy Pithy never wrote. `Cloudflare said:` marks the boundary between our words and
116
+ * **The message is one line, a kit-wide rule.** A `PithyError` raised inside a `classifiedSteps` step
117
+ * crosses a durable boundary as one string whose newline separates the sentence from the remedy, so a
118
+ * multi-line `message` is declined whole at the reader. The same newline is what the CLI's action line
119
+ * is, so an upstream sentence containing one would forge a remedy Pithy never wrote. `Cloudflare said:` marks the boundary between our words and
129
120
  * theirs with a word instead of an indent, and a word survives a step record, a `--json` line and a
130
121
  * browser.
131
122
  */
@@ -183,6 +174,11 @@ export declare function cloudflareSaid(problem: string, apiErrors: CloudflareApi
183
174
  * one somebody clicks.
184
175
  */
185
176
  export declare function cloudflareApiErrors(source: unknown): CloudflareApiError[];
177
+ /**
178
+ * Whether a thrown SDK error is the credentials failing rather than the request — the same judgment the
179
+ * refusal's action line makes, read off the throw a {@link cloudflareRefusal} keeps as its `cause`.
180
+ */
181
+ export declare function isAuthClassFailure(error: unknown): boolean;
186
182
  /**
187
183
  * The HTTP status of a thrown SDK error, when it carries one. Exported because a call site that
188
184
  * composes its own refusal through {@link cloudflareRefusal} has to hand it the same status this
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/client/errors.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/client/errors.ts"],"names":[],"mappings":"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"}
@@ -104,10 +104,9 @@ function oneLine(text) {
104
104
  * Cloudflare's auth-class codes — the ones that mean "your credentials did not get you in", as
105
105
  * opposed to a missing resource (`12000`), a conflict (`12042`) or a rejected argument (`10202`).
106
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.
107
+ * Status alone is not the discriminator: Cloudflare returns `10000` under 400, 401 and 403 depending
108
+ * on the endpoint, so the code is what identifies the class and the status is only a fallback for a
109
+ * body-less throw.
111
110
  */
112
111
  const AUTH_CLASS_CODES = /* @__PURE__ */ new Set([
113
112
  9103,
@@ -188,13 +187,9 @@ async function cloudflareRequest(operation, fn, options) {
188
187
  * Compose the refusal every failed Cloudflare call renders: the problem line, then the API's own
189
188
  * answer, then — only for an auth-class code — what to do about it.
190
189
  *
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.
190
+ * One place, so a fix reaches every provisioner at once. `refusalSites.test.ts` fails any
191
+ * `new CloudflareRequestError(` outside this file, so "the only place" is enforced rather than
192
+ * remembered.
198
193
  *
199
194
  * CLAUDE.md §Errors decides where each string lands. Cloudflare's sentence and its docs link are the
200
195
  * **caller's** information — what happened, in the words of whoever it happened to — so they go in
@@ -210,14 +205,10 @@ async function cloudflareRequest(operation, fn, options) {
210
205
  * for, and pinned by `core`'s payload test. The words are composed the same way either side of that
211
206
  * branch, so only the two machine-readable fields differ.
212
207
  *
213
- * **The message is one line, and that is a kit-wide rule this had to learn the hard way.** Round one
214
- * of #534 made it a small document — problem line, `errors[]` indented beneath, action last — which
215
- * reads beautifully in a terminal and is unencodable everywhere else. A `PithyError` raised inside a
216
- * `classifiedSteps` step crosses a durable boundary as one string whose newline separates the sentence
217
- * from the remedy, so a multi-line `message` was declined whole at the reader and the operator got
218
- * `core/workflow_failed` 500 about durable execution instead of the API answer this issue exists to
219
- * show them. The same newline is what the CLI's action line is, so an upstream sentence containing one
220
- * forges a remedy Pithy never wrote. `Cloudflare said:` marks the boundary between our words and
208
+ * **The message is one line, a kit-wide rule.** A `PithyError` raised inside a `classifiedSteps` step
209
+ * crosses a durable boundary as one string whose newline separates the sentence from the remedy, so a
210
+ * multi-line `message` is declined whole at the reader. The same newline is what the CLI's action line
211
+ * is, so an upstream sentence containing one would forge a remedy Pithy never wrote. `Cloudflare said:` marks the boundary between our words and
221
212
  * theirs with a word instead of an indent, and a word survives a step record, a `--json` line and a
222
213
  * browser.
223
214
  */
@@ -254,7 +245,7 @@ function cloudflareRefusal(args) {
254
245
  * which is the transient failure by definition. The timeout statuses never reach here — `isTimeout`
255
246
  * claims 408, 504 and 524 first, and they are the 504 half of the same pair.
256
247
  *
257
- * **A statusless failure is not covered here, and deliberately so (#647 review).** A connection that reset
248
+ * **A statusless failure is not covered here, and deliberately so.** A connection that reset
258
249
  * mid-write is genuinely transient and ending a rotation on one is expensive — but "carries no status" is
259
250
  * far wider than "the network faltered". A `TypeError` raised in our own request builder carries no status
260
251
  * either, and a predicate that called it transient would retry a code defect on the Workflow's own cadence,
@@ -404,7 +395,7 @@ function apiErrorParams(entry) {
404
395
  *
405
396
  * **The API's code decides; the status is only consulted when there is no code.** Cloudflare returns
406
397
  * `10000` under 400, 401 and 403 depending on the endpoint, which is why the status cannot be the
407
- * discriminator — and the converse is the half that shipped wrong: a 403 is *also* how Cloudflare
398
+ * discriminator — and the converse holds too: a 403 is *also* how Cloudflare
408
399
  * answers `10021 Script startup exceeded CPU limit`, and "add Account → Workers Scripts to it" changes
409
400
  * nothing about a CPU limit. So when Cloudflare named any code at all, the codes are the whole of the
410
401
  * evidence and the status is not consulted; only a body-less throw (`401 status code (no body)`) falls
@@ -414,13 +405,11 @@ function apiErrorParams(entry) {
414
405
  * Cloudflare answered `10000` for this one call; that is all it said. A token missing this product's
415
406
  * grant, a revoked token and a token pointed at the wrong account all produce it, and this process can
416
407
  * separate none of the three. So the line names all three, in the order an operator can check them,
417
- * and asserts none. It named two for one round — "reaches other products" or "reaches none" — which
418
- * excluded the third by construction: a token in the *wrong* account reaches other products in that
419
- * account, so the operator was told to add a grant they already held.
408
+ * and asserts none.
420
409
  *
421
- * The **level** is deliberately absent too. The call that raised #534 was a *list* — Read would have
422
- * satisfied it — so "Turnstile → Edit" would have been a guess dressed as an instruction. Name the
423
- * group; the operator picks the level their calls need.
410
+ * The **level** is deliberately absent too. A list call is satisfied by Read, so "Turnstile → Edit"
411
+ * would be a guess dressed as an instruction. Name the group; the operator picks the level their calls
412
+ * need.
424
413
  */
425
414
  function permissionAction(apiErrors, status, hint) {
426
415
  if (!isAuthClassRefusal(apiErrors, status)) return void 0;
@@ -436,6 +425,13 @@ function isAuthClassRefusal(apiErrors, status) {
436
425
  if (coded.length > 0) return coded.some((entry) => AUTH_CLASS_CODES.has(entry.code));
437
426
  return status === 401 || status === 403;
438
427
  }
428
+ /**
429
+ * Whether a thrown SDK error is the credentials failing rather than the request — the same judgment the
430
+ * refusal's action line makes, read off the throw a {@link cloudflareRefusal} keeps as its `cause`.
431
+ */
432
+ function isAuthClassFailure(error) {
433
+ return isAuthClassRefusal(cloudflareApiErrors(error), statusOf(error));
434
+ }
439
435
  /** The account permission group behind the refused endpoint, per Cloudflare's own docs link. */
440
436
  function accountPermissionOf(apiErrors) {
441
437
  for (const entry of apiErrors) {
@@ -575,4 +571,4 @@ function decodeResponse(schema, raw, context) {
575
571
  return parsed.data;
576
572
  }
577
573
  //#endregion
578
- export { CloudflareInvalidResponseError, CloudflareNotConfiguredError, CloudflareRequestError, cloudflareApiErrors, cloudflareRefusal, cloudflareRequest, cloudflareSaid, decodeResponse, isAuthorizationError, isNotFoundError, messageOf, reasonOf, statusOf };
574
+ export { CloudflareInvalidResponseError, CloudflareNotConfiguredError, CloudflareRequestError, cloudflareApiErrors, cloudflareRefusal, cloudflareRequest, cloudflareSaid, decodeResponse, isAuthClassFailure, isAuthorizationError, isNotFoundError, messageOf, reasonOf, statusOf };
@@ -1,9 +1,9 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { CloudflareNotConfiguredError } from "../client/errors.js";
4
+ import { RUNTIME_PROFILE, RUNTIME_TOKEN_SECRET } from "./runtime.js";
4
5
  import { accountResource, zoneResources } from "./accountTokensManager.js";
5
6
  import { PERMISSION_GROUPS, isPermissionKey, isZoneScopedPermission, resolvePermissionKeys } from "./permissions.js";
6
- import { RUNTIME_PROFILE, RUNTIME_TOKEN_SECRET } from "./runtime.js";
7
7
  //#region src/tokens/profiles.ts
8
8
  /** The valid stores, for validating a `--store` flag or a profile's `defaultStore`. */
9
9
  const TOKEN_STORES = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pithy-sh/cloudflare",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,7 @@
40
40
  "js-base64": "^3.9.2"
41
41
  },
42
42
  "peerDependencies": {
43
- "@pithy-sh/core": "^0.15.0",
43
+ "@pithy-sh/core": "^0.16.0",
44
44
  "zod": "^4.4.0"
45
45
  },
46
46
  "devDependencies": {
@@ -5,16 +5,17 @@ import { z } from "zod";
5
5
  import { CloudflareInvalidResponseError, cloudflareRefusal, cloudflareRequest, decodeResponse } from "../client/errors";
6
6
  import { CloudflareManager } from "../client/manager";
7
7
  import { type AnalyticsDataset, IMAGE_REQUESTS, STREAM_MINUTES, TURNSTILE_EVENTS } from "./datasets";
8
+ import { ANALYTICS_PERMISSION, GRAPHQL_AUTH_CODES } from "./forbidden";
8
9
 
9
10
  /**
10
- * **Cloudflare's GraphQL Analytics API, for the account-level datasets the kit reads (#728).**
11
+ * **Cloudflare's GraphQL Analytics API, for the account-level datasets the kit reads.**
11
12
  *
12
13
  * One endpoint, `POST /graphql`, reached through the SDK's raw `post` like every other call the typed SDK
13
14
  * does not cover — never a hand-rolled `fetch` (CLAUDE.md §Cloudflare access). It answers HTTP 200 with
14
15
  * `{ data, errors }` even when it refuses, so `errors` is read on every answer and a non-empty one is a
15
16
  * refusal in Cloudflare's own words.
16
17
  *
17
- * The token it runs on is the application's one `runtime` token, carrying "Account Analytics Read", whose grain is
18
+ * The token it runs on is the application's one `runtime` token, carrying {@link ANALYTICS_PERMISSION}, whose grain is
18
19
  * the whole account. This manager narrows nothing on its own: every query takes the keys it is about, and
19
20
  * the caller decides which keys those are.
20
21
  */
@@ -145,19 +146,26 @@ export class CloudflareAnalyticsManager extends CloudflareManager {
145
146
  const raw = await cloudflareRequest(
146
147
  operation,
147
148
  () => this.getClient().post<unknown>("/graphql", { body: { query, variables } }),
148
- { permission: "Account Analytics Read" },
149
+ { permission: ANALYTICS_PERMISSION },
149
150
  );
150
151
  const answer = decodeResponse(GraphQLAnswer, raw, operation);
151
152
  const errors = answer.errors ?? [];
152
153
  if (errors.length > 0) {
154
+ const codes = errors.map((error) => error.extensions?.code);
155
+ // Every refusal answers 200, so the status handed on is the one the GraphQL code means: a spent rate
156
+ // budget is the 429 worth asking again, and `authz` is the 403 whose action line names the grant.
157
+ const status = codes.some((code) => code === RATE_BUDGET_CODE)
158
+ ? 429
159
+ : codes.some((code) => code !== undefined && GRAPHQL_AUTH_CODES.has(code))
160
+ ? 403
161
+ : undefined;
153
162
  throw cloudflareRefusal({
154
163
  problem: `Cloudflare request failed: ${operation}.`,
155
164
  apiErrors: errors.map((error) => ({ ...(error.message === undefined ? {} : { message: error.message }) })),
156
- permission: "Account Analytics Read",
157
- // A spent rate budget answers 200 too, with `extensions.code: "budget"` (measured 2026-10-17). It
158
- // is the one GraphQL refusal that is worth asking again, so it is handed on as the 429 it means.
159
- ...(errors.some((error) => error.extensions?.code === RATE_BUDGET_CODE) ? { status: 429 } : {}),
165
+ permission: ANALYTICS_PERMISSION,
166
+ ...(status === undefined ? {} : { status }),
160
167
  detail: JSON.stringify(errors),
168
+ cause: errors,
161
169
  });
162
170
  }
163
171
  return answer.data;
@@ -267,7 +275,7 @@ export class CloudflareAnalyticsManager extends CloudflareManager {
267
275
  }
268
276
 
269
277
  /**
270
- * Turnstile events per sitekey and event type inside the window, for the sitekeys given (#731). The sitekey
278
+ * Turnstile events per sitekey and event type inside the window, for the sitekeys given. The sitekey
271
279
  * filter is what scopes the account-wide dataset to the widgets the caller is about. Absent means none.
272
280
  */
273
281
  async turnstileEvents(
@@ -0,0 +1,99 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { commandName, remedy } from "@pithy-sh/core/src/cli/command";
5
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
7
+ import { isAuthClassFailure } from "../client/errors";
8
+ import { RUNTIME_PROFILE } from "../tokens/runtime";
9
+
10
+ /**
11
+ * **An analytics read the Worker's own token may not make, as each caller's own code.**
12
+ *
13
+ * Every capability that reads Cloudflare's GraphQL Analytics does it on the one `runtime` token, which needs
14
+ * {@link ANALYTICS_PERMISSION}. A refusal for want of it is `cloudflare/request_failed` like any other, and
15
+ * that code names no permission a client can read. So a caller wraps its read in {@link guardForbidden},
16
+ * which hands a missing grant to the caller's own error class with the permission in `params` and the
17
+ * remedy in `message`, and lets every other failure through unchanged. No SDK is imported here, so a
18
+ * caller that loads the manager lazily keeps doing so.
19
+ */
20
+
21
+ /** The account permission group every analytics read needs, as Cloudflare's token editor names it. */
22
+ export const ANALYTICS_PERMISSION = "Account Analytics Read";
23
+
24
+ /** The GraphQL `extensions.code` that means the token may not read the account it asked about. */
25
+ export const GRAPHQL_AUTH_CODES: ReadonlySet<string> = new Set(["authz"]);
26
+
27
+ /** A missing grant, in the fields a caller's error class takes. */
28
+ export interface ForbiddenRefusal {
29
+ /** The sentence the caller reads: the permission, and the rotate that adds it. */
30
+ message: string;
31
+ /** `permission`, for a client that translates. */
32
+ params: MessageParams;
33
+ /** The operator's line from the Cloudflare refusal, kept for the terminal and the log. */
34
+ action?: string;
35
+ /** The Cloudflare refusal's detail, kept for the log. */
36
+ detail?: string;
37
+ }
38
+
39
+ /** Builds the caller's own error for a missing grant. */
40
+ export type RefuseForbidden = (refusal: ForbiddenRefusal, options: { cause: unknown }) => Error;
41
+
42
+ /** Where the read ran and what to call it, for the sentence. */
43
+ export interface ForbiddenContext {
44
+ /** The environment this Worker serves, or null when it names none. */
45
+ environment: string | null;
46
+ /** The read as the sentence names it: "the delivery analytics read". */
47
+ read: string;
48
+ }
49
+
50
+ /** Whether a GraphQL `errors` array, kept as a refusal's cause, says the token may not read the account. */
51
+ function isGraphQLAuthRefusal(cause: unknown): boolean {
52
+ if (!Array.isArray(cause)) return false;
53
+ return cause.some((entry: unknown) => {
54
+ const code = (entry as { extensions?: { code?: unknown } } | null)?.extensions?.code;
55
+ return typeof code === "string" && GRAPHQL_AUTH_CODES.has(code);
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Whether a failure is an analytics query refused because the credentials did not get in: a 401 or 403, or
61
+ * Cloudflare's auth-class code, or GraphQL's `authz`. A spent rate budget, a 5xx and a rejected query are not.
62
+ */
63
+ export function isAnalyticsForbidden(error: unknown): boolean {
64
+ if (!(error instanceof PithyError) || error.payload.code !== "cloudflare/request_failed") return false;
65
+ return isAuthClassFailure(error.cause) || isGraphQLAuthRefusal(error.cause);
66
+ }
67
+
68
+ /** The remedy a caller reads: rotate re-scopes the runtime token, where a mint only rolls its value. */
69
+ function rotateSentence(environment: string | null): string {
70
+ const tail = "to re-scope it; a mint only rolls the value.";
71
+ if (environment === null) return `Rotate it with \`${commandName("token rotate")}\` ${tail}`;
72
+ return remedy("token rotate", { args: [RUNTIME_PROFILE], flags: { "--env": environment } }, { runtime: true }).say(
73
+ (command) => `Run \`${command}\` ${tail}`,
74
+ );
75
+ }
76
+
77
+ /** A missing grant on `error`, as the fields of the caller's own error. */
78
+ export function forbiddenRefusal(error: PithyError, context: ForbiddenContext): ForbiddenRefusal {
79
+ return {
80
+ message: `Cloudflare refused ${context.read}: this Worker's ${RUNTIME_PROFILE} token lacks ${ANALYTICS_PERMISSION}. ${rotateSentence(context.environment)}`,
81
+ params: { permission: ANALYTICS_PERMISSION },
82
+ ...(error.payload.action === undefined ? {} : { action: error.payload.action }),
83
+ ...(error.payload.detail === undefined ? {} : { detail: error.payload.detail }),
84
+ };
85
+ }
86
+
87
+ /** Run an analytics read, refusing a missing grant with the caller's own error and passing every other failure on. */
88
+ export async function guardForbidden<T>(
89
+ read: () => Promise<T>,
90
+ context: ForbiddenContext,
91
+ refuse: RefuseForbidden,
92
+ ): Promise<T> {
93
+ try {
94
+ return await read();
95
+ } catch (error) {
96
+ if (!(error instanceof PithyError) || !isAnalyticsForbidden(error)) throw error;
97
+ throw refuse(forbiddenRefusal(error, context), { cause: error });
98
+ }
99
+ }
@@ -94,9 +94,8 @@ export class CloudflareInvalidResponseError extends PithyError {
94
94
  * One entry of Cloudflare's `errors[]` array — the API's own answer to a failed call.
95
95
  *
96
96
  * Every v4 endpoint answers a failure with `{ success: false, errors: [{ code, message,
97
- * documentation_url }] }`, and the SDK already parses it onto the thrown `APIError`. Until #534 we
98
- * projected none of it: a refusal named the operation and stopped, so a token missing one product's
99
- * grant, a revoked token, a wrong account and an outage all rendered as the same sentence.
97
+ * documentation_url }] }`, and the SDK parses it onto the thrown `APIError`. It is what tells a token
98
+ * missing one product's grant, a revoked token, a wrong account and an outage apart.
100
99
  */
101
100
  export interface CloudflareApiError {
102
101
  /** Cloudflare's numeric error code — the stable discriminator (`10000` is its auth-class code). */
@@ -162,10 +161,9 @@ function oneLine(text: string): string {
162
161
  * Cloudflare's auth-class codes — the ones that mean "your credentials did not get you in", as
163
162
  * opposed to a missing resource (`12000`), a conflict (`12042`) or a rejected argument (`10202`).
164
163
  *
165
- * Status alone is not the discriminator, which is the trap #534 walked into: the reported Turnstile
166
- * refusal was `10000` under a **401**, while `isAuthorizationError` below keys on 403 and would not
167
- * have fired. Cloudflare returns `10000` under 400, 401 and 403 depending on the endpoint, so the
168
- * code is what identifies the class and the status is only a fallback for a body-less throw.
164
+ * Status alone is not the discriminator: Cloudflare returns `10000` under 400, 401 and 403 depending
165
+ * on the endpoint, so the code is what identifies the class and the status is only a fallback for a
166
+ * body-less throw.
169
167
  */
170
168
  const AUTH_CLASS_CODES = new Set([9103, 9106, 9107, 9109, 10000, 10001]);
171
169
 
@@ -252,13 +250,9 @@ export async function cloudflareRequest<T>(
252
250
  * Compose the refusal every failed Cloudflare call renders: the problem line, then the API's own
253
251
  * answer, then — only for an auth-class code — what to do about it.
254
252
  *
255
- * One place, so a fix reaches every provisioner at once — and **one place is a claim a test has to
256
- * hold, because it was false the first time it was written.** Round one of #534 said
257
- * `CloudflareBuildsManager` was the only path composing a refusal by hand; `mintToken`, the Stream
258
- * direct upload and two integration helpers were composing their own, so `pithy token mint` — the
259
- * bootstrap command an under-scoped token meets first — still printed the operation and nothing else
260
- * on the identical 401 body. `refusalSites.test.ts` now fails any `new CloudflareRequestError(`
261
- * outside this file, so "the only place" is enforced rather than remembered.
253
+ * One place, so a fix reaches every provisioner at once. `refusalSites.test.ts` fails any
254
+ * `new CloudflareRequestError(` outside this file, so "the only place" is enforced rather than
255
+ * remembered.
262
256
  *
263
257
  * CLAUDE.md §Errors decides where each string lands. Cloudflare's sentence and its docs link are the
264
258
  * **caller's** information — what happened, in the words of whoever it happened to — so they go in
@@ -274,14 +268,10 @@ export async function cloudflareRequest<T>(
274
268
  * for, and pinned by `core`'s payload test. The words are composed the same way either side of that
275
269
  * branch, so only the two machine-readable fields differ.
276
270
  *
277
- * **The message is one line, and that is a kit-wide rule this had to learn the hard way.** Round one
278
- * of #534 made it a small document — problem line, `errors[]` indented beneath, action last — which
279
- * reads beautifully in a terminal and is unencodable everywhere else. A `PithyError` raised inside a
280
- * `classifiedSteps` step crosses a durable boundary as one string whose newline separates the sentence
281
- * from the remedy, so a multi-line `message` was declined whole at the reader and the operator got
282
- * `core/workflow_failed` 500 about durable execution instead of the API answer this issue exists to
283
- * show them. The same newline is what the CLI's action line is, so an upstream sentence containing one
284
- * forges a remedy Pithy never wrote. `Cloudflare said:` marks the boundary between our words and
271
+ * **The message is one line, a kit-wide rule.** A `PithyError` raised inside a `classifiedSteps` step
272
+ * crosses a durable boundary as one string whose newline separates the sentence from the remedy, so a
273
+ * multi-line `message` is declined whole at the reader. The same newline is what the CLI's action line
274
+ * is, so an upstream sentence containing one would forge a remedy Pithy never wrote. `Cloudflare said:` marks the boundary between our words and
285
275
  * theirs with a word instead of an indent, and a word survives a step record, a `--json` line and a
286
276
  * browser.
287
277
  */
@@ -341,7 +331,7 @@ export function cloudflareRefusal(args: {
341
331
  * which is the transient failure by definition. The timeout statuses never reach here — `isTimeout`
342
332
  * claims 408, 504 and 524 first, and they are the 504 half of the same pair.
343
333
  *
344
- * **A statusless failure is not covered here, and deliberately so (#647 review).** A connection that reset
334
+ * **A statusless failure is not covered here, and deliberately so.** A connection that reset
345
335
  * mid-write is genuinely transient and ending a rotation on one is expensive — but "carries no status" is
346
336
  * far wider than "the network faltered". A `TypeError` raised in our own request builder carries no status
347
337
  * either, and a predicate that called it transient would retry a code defect on the Workflow's own cadence,
@@ -504,7 +494,7 @@ function apiErrorParams(entry: CloudflareApiError | undefined): MessageParams {
504
494
  *
505
495
  * **The API's code decides; the status is only consulted when there is no code.** Cloudflare returns
506
496
  * `10000` under 400, 401 and 403 depending on the endpoint, which is why the status cannot be the
507
- * discriminator — and the converse is the half that shipped wrong: a 403 is *also* how Cloudflare
497
+ * discriminator — and the converse holds too: a 403 is *also* how Cloudflare
508
498
  * answers `10021 Script startup exceeded CPU limit`, and "add Account → Workers Scripts to it" changes
509
499
  * nothing about a CPU limit. So when Cloudflare named any code at all, the codes are the whole of the
510
500
  * evidence and the status is not consulted; only a body-less throw (`401 status code (no body)`) falls
@@ -514,13 +504,11 @@ function apiErrorParams(entry: CloudflareApiError | undefined): MessageParams {
514
504
  * Cloudflare answered `10000` for this one call; that is all it said. A token missing this product's
515
505
  * grant, a revoked token and a token pointed at the wrong account all produce it, and this process can
516
506
  * separate none of the three. So the line names all three, in the order an operator can check them,
517
- * and asserts none. It named two for one round — "reaches other products" or "reaches none" — which
518
- * excluded the third by construction: a token in the *wrong* account reaches other products in that
519
- * account, so the operator was told to add a grant they already held.
507
+ * and asserts none.
520
508
  *
521
- * The **level** is deliberately absent too. The call that raised #534 was a *list* — Read would have
522
- * satisfied it — so "Turnstile → Edit" would have been a guess dressed as an instruction. Name the
523
- * group; the operator picks the level their calls need.
509
+ * The **level** is deliberately absent too. A list call is satisfied by Read, so "Turnstile → Edit"
510
+ * would be a guess dressed as an instruction. Name the group; the operator picks the level their calls
511
+ * need.
524
512
  */
525
513
  function permissionAction(apiErrors: CloudflareApiError[], status?: number, hint?: string): string | undefined {
526
514
  if (!isAuthClassRefusal(apiErrors, status)) return undefined;
@@ -540,6 +528,14 @@ function isAuthClassRefusal(apiErrors: CloudflareApiError[], status?: number): b
540
528
  return status === 401 || status === 403;
541
529
  }
542
530
 
531
+ /**
532
+ * Whether a thrown SDK error is the credentials failing rather than the request — the same judgment the
533
+ * refusal's action line makes, read off the throw a {@link cloudflareRefusal} keeps as its `cause`.
534
+ */
535
+ export function isAuthClassFailure(error: unknown): boolean {
536
+ return isAuthClassRefusal(cloudflareApiErrors(error), statusOf(error));
537
+ }
538
+
543
539
  /** The account permission group behind the refused endpoint, per Cloudflare's own docs link. */
544
540
  function accountPermissionOf(apiErrors: CloudflareApiError[]): string | undefined {
545
541
  for (const entry of apiErrors) {