@pithy-sh/cloudflare 0.8.1 → 0.9.1

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 };
@@ -0,0 +1,25 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { BindingType } from "@pithy-sh/core/src/capability/bindings";
4
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
5
+ import type { PermissionKey } from "./permissions";
6
+ /**
7
+ * **What a binding of each kind needs the `ci-system` token to carry**, so that provisioning, deploying and
8
+ * tearing down the resource behind it succeed under that token and under a signed-in session.
9
+ *
10
+ * Read off the binding rather than declared by a capability, because the adopter's own app binds resources
11
+ * too and has no `ciPermissions` anyone would think to write. A kind a provision command creates with no
12
+ * entry here fails `cli/src/provision/bindingPermissions.test.ts`.
13
+ *
14
+ * - `d1`, `secret` and `workflow` name what the `ci-system` base already carries.
15
+ * - `kv`, `r2` and `vectorize` take write alone. Provisioning lists before it creates, and a write scope
16
+ * covers its read, as `OAUTH_SCOPES` records.
17
+ * - `queue` and `artifacts` are reached by the API only, to create or reap; a Worker uses its binding.
18
+ */
19
+ export declare const BINDING_PERMISSIONS: Readonly<Partial<Record<BindingType, readonly PermissionKey[]>>>;
20
+ /**
21
+ * The permission keys every binding the capabilities declare needs, each once, in first-seen order. The
22
+ * adopter's own app is a capability here like any other, so a bucket it declares counts.
23
+ */
24
+ export declare function bindingPermissions(capabilities: readonly Capability[]): PermissionKey[];
25
+ //# sourceMappingURL=bindingPermissions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bindingPermissions.d.ts","sourceRoot":"","sources":["../../src/tokens/bindingPermissions.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wCAAwC,CAAC;AAC1E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0CAA0C,CAAC;AAC3E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAEnD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,mBAAmB,EAAE,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,SAAS,aAAa,EAAE,CAAC,CAAC,CAShG,CAAC;AAEF;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,YAAY,EAAE,SAAS,UAAU,EAAE,GAAG,aAAa,EAAE,CAUvF"}
@@ -0,0 +1,37 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/tokens/bindingPermissions.ts
4
+ /**
5
+ * **What a binding of each kind needs the `ci-system` token to carry**, so that provisioning, deploying and
6
+ * tearing down the resource behind it succeed under that token and under a signed-in session.
7
+ *
8
+ * Read off the binding rather than declared by a capability, because the adopter's own app binds resources
9
+ * too and has no `ciPermissions` anyone would think to write. A kind a provision command creates with no
10
+ * entry here fails `cli/src/provision/bindingPermissions.test.ts`.
11
+ *
12
+ * - `d1`, `secret` and `workflow` name what the `ci-system` base already carries.
13
+ * - `kv`, `r2` and `vectorize` take write alone. Provisioning lists before it creates, and a write scope
14
+ * covers its read, as `OAUTH_SCOPES` records.
15
+ * - `queue` and `artifacts` are reached by the API only, to create or reap; a Worker uses its binding.
16
+ */
17
+ const BINDING_PERMISSIONS = {
18
+ d1: ["d1:read", "d1:write"],
19
+ kv: ["kv:write"],
20
+ r2: ["r2:write"],
21
+ vectorize: ["vectorize:write"],
22
+ queue: ["queues:write"],
23
+ artifacts: ["artifacts:write"],
24
+ secret: ["secrets:read", "secrets:write"],
25
+ workflow: ["workers:write"]
26
+ };
27
+ /**
28
+ * The permission keys every binding the capabilities declare needs, each once, in first-seen order. The
29
+ * adopter's own app is a capability here like any other, so a bucket it declares counts.
30
+ */
31
+ function bindingPermissions(capabilities) {
32
+ const keys = [];
33
+ for (const capability of capabilities) for (const binding of capability.requiredBindings) for (const key of BINDING_PERMISSIONS[binding.type] ?? []) if (!keys.includes(key)) keys.push(key);
34
+ return keys;
35
+ }
36
+ //#endregion
37
+ export { BINDING_PERMISSIONS, bindingPermissions };
@@ -12,8 +12,7 @@ import { type PermissionKey } from "./permissions";
12
12
  * - `dev-vars` — `<config>/<project>/tokens.json`, keyed by environment and outside every checkout.
13
13
  * **Read by an operator, never by a command** — nothing resolves a credential from it. The store for
14
14
  * the `ci-system` token: mint it here, read the value
15
- * out, and set it as CI's `CLOUDFLARE_API_TOKEN`. It wrote `.dev.vars.<env>` *inside* the project until
16
- * #182; the name stays because it is a public `--store` flag value.
15
+ * out, and set it as CI's `CLOUDFLARE_API_TOKEN`. The name is a public `--store` flag value.
17
16
  * - `ephemeral` — nothing is written; the value is used in-process and discarded. The one-step CI
18
17
  * path — CI can't read the CF Secrets Store, so it mints and uses the token in the same job.
19
18
  */
@@ -77,17 +76,16 @@ export declare function permissionsForKeys(keys: PermissionKey[], accountId: str
77
76
  *
78
77
  * This is the second policy on the `ci-system` token, beside the account-scoped one every profile
79
78
  * builds. It exists because `pithy deploy` of an environment with a declared domain calls
80
- * `POST /zones/<zone>/workers/routes`, the one deploy step the CI credential could not make (#651) — and
79
+ * `POST /zones/<zone>/workers/routes`, which no account-scoped group reaches — and
81
80
  * it is a policy of its own rather than a permission on the profile because its resources are zones,
82
81
  * which a profile cannot name: they are resolved from the domains at mint time.
83
82
  *
84
83
  * **This limits the route, not the token.** The account policy beside it carries Workers Scripts Write,
85
- * which attaches a custom domain on any zone the account holds (measured, #709; `docs/TOKENS.md`). Only
84
+ * which attaches a custom domain on any zone the account holds (measured; `docs/TOKENS.md`). Only
86
85
  * the route calls are held to the declared zones.
87
86
  *
88
87
  * **Empty for an empty zone list, deliberately.** A project that declares no domain attaches no route,
89
- * so it mints exactly the token it minted before this existed. No new permission for a project that
90
- * needs none.
88
+ * so it gets no zone permission.
91
89
  */
92
90
  export declare function routePermissions(zoneIds: readonly string[]): TokenPermission[];
93
91
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"profiles.d.ts","sourceRoot":"","sources":["../../src/tokens/profiles.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0CAA0C,CAAC;AAE3E,OAAO,EAAmB,KAAK,eAAe,EAAiB,MAAM,wBAAwB,CAAC;AAC9F,OAAO,EAIL,KAAK,aAAa,EAEnB,MAAM,eAAe,CAAC;AAGvB;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,UAAU,GAAG,eAAe,GAAG,UAAU,GAAG,WAAW,CAAC;AAEpE,uFAAuF;AACvF,eAAO,MAAM,YAAY,EAAE,SAAS,UAAU,EAA+C,CAAC;AAE9F,4GAA4G;AAC5G,eAAO,MAAM,iBAAiB,cAAc,CAAC;AAe7C;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,mFAAmF;IACnF,IAAI,EAAE,MAAM,CAAC;IACb,oGAAoG;IACpG,WAAW,EAAE,aAAa,EAAE,CAAC;IAC7B,oFAAoF;IACpF,SAAS,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/C,0GAA0G;IAC1G,MAAM,EAAE,MAAM,CAAC;IACf,2GAA2G;IAC3G,YAAY,CAAC,EAAE,UAAU,CAAC;IAC1B,sDAAsD;IACtD,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,iHAAiH;AACjH,MAAM,WAAW,eAAe;IAC9B,6CAA6C;IAC7C,WAAW,CAAC,EAAE,aAAa,EAAE,CAAC;IAC9B,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,sDAAsD;IACtD,KAAK,CAAC,EAAE,UAAU,CAAC;CACpB;AAED,yFAAyF;AACzF,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,GAAG,YAAY,CAGtE;AAiFD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,SAAS,UAAU,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAMtG;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,EACtC,IAAI,EAAE,MAAM,EACZ,QAAQ,CAAC,EAAE,eAAe,GACzB,YAAY,CAgBd;AAsBD,uGAAuG;AACvG,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,aAAa,EAAE,EAAE,SAAS,EAAE,MAAM,GAAG,eAAe,EAAE,CAG9F;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,eAAe,EAAE,CAG9E;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,GAAG,eAAe,EAAE,CAK9F"}
1
+ {"version":3,"file":"profiles.d.ts","sourceRoot":"","sources":["../../src/tokens/profiles.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0CAA0C,CAAC;AAE3E,OAAO,EAAmB,KAAK,eAAe,EAAiB,MAAM,wBAAwB,CAAC;AAE9F,OAAO,EAIL,KAAK,aAAa,EAEnB,MAAM,eAAe,CAAC;AAGvB;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,UAAU,GAAG,eAAe,GAAG,UAAU,GAAG,WAAW,CAAC;AAEpE,uFAAuF;AACvF,eAAO,MAAM,YAAY,EAAE,SAAS,UAAU,EAA+C,CAAC;AAE9F,4GAA4G;AAC5G,eAAO,MAAM,iBAAiB,cAAc,CAAC;AAe7C;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,mFAAmF;IACnF,IAAI,EAAE,MAAM,CAAC;IACb,oGAAoG;IACpG,WAAW,EAAE,aAAa,EAAE,CAAC;IAC7B,oFAAoF;IACpF,SAAS,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/C,0GAA0G;IAC1G,MAAM,EAAE,MAAM,CAAC;IACf,2GAA2G;IAC3G,YAAY,CAAC,EAAE,UAAU,CAAC;IAC1B,sDAAsD;IACtD,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,iHAAiH;AACjH,MAAM,WAAW,eAAe;IAC9B,6CAA6C;IAC7C,WAAW,CAAC,EAAE,aAAa,EAAE,CAAC;IAC9B,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,sDAAsD;IACtD,KAAK,CAAC,EAAE,UAAU,CAAC;CACpB;AAED,yFAAyF;AACzF,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,GAAG,YAAY,CAGtE;AAsED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,SAAS,UAAU,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAMtG;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,EACtC,IAAI,EAAE,MAAM,EACZ,QAAQ,CAAC,EAAE,eAAe,GACzB,YAAY,CAgBd;AAsBD,uGAAuG;AACvG,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,aAAa,EAAE,EAAE,SAAS,EAAE,MAAM,GAAG,eAAe,EAAE,CAG9F;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,eAAe,EAAE,CAG9E;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,GAAG,eAAe,EAAE,CAK9F"}
@@ -1,9 +1,10 @@
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
+ import { bindingPermissions } from "./bindingPermissions.js";
7
8
  //#region src/tokens/profiles.ts
8
9
  /** The valid stores, for validating a `--store` flag or a profile's `defaultStore`. */
9
10
  const TOKEN_STORES = [
@@ -47,17 +48,16 @@ function assertPermissionKeys(keys, where) {
47
48
  }
48
49
  /**
49
50
  * Build the aggregating `ci-system` profile: the base permissions ∪ every capability's `ciPermissions` ∪
50
- * `queues:write` where a composed capability binds a queue ∪ `artifacts:write` where one binds Artifacts.
51
- *
52
- * **The queue permission is derived from the binding, not declared (#662).** `pithy provision` creates the queue
53
- * behind every `queue` binding it composes, the adopter's own app's included, and the app has no `ciPermissions`
54
- * anyone would think to write. A permission read off the binding cannot be forgotten by whoever declares it.
51
+ * what every composed binding needs, from `BINDING_PERMISSIONS`. The adopter's own app counts as a
52
+ * capability, so a resource it binds brings its permission with no declaration.
55
53
  */
56
54
  function ciSystemProfile(capabilities) {
57
55
  const permissions = [...CI_SYSTEM_BASE];
58
- for (const capability of capabilities) for (const key of assertPermissionKeys(capability.ciPermissions ?? [], `Capability "${capability.name}"`)) if (!permissions.includes(key)) permissions.push(key);
59
- if (capabilities.some((capability) => capability.requiredBindings.some((binding) => binding.type === "queue")) && !permissions.includes("queues:write")) permissions.push("queues:write");
60
- if (capabilities.some((capability) => capability.requiredBindings.some((binding) => binding.type === "artifacts")) && !permissions.includes("artifacts:write")) permissions.push("artifacts:write");
56
+ const add = (key) => {
57
+ if (!permissions.includes(key)) permissions.push(key);
58
+ };
59
+ for (const capability of capabilities) for (const key of assertPermissionKeys(capability.ciPermissions ?? [], `Capability "${capability.name}"`)) add(key);
60
+ for (const key of bindingPermissions(capabilities)) add(key);
61
61
  return {
62
62
  name: CI_SYSTEM_PROFILE,
63
63
  permissions,
@@ -127,7 +127,7 @@ function resolveProfile(profiles, name, override) {
127
127
  /**
128
128
  * Refuse a zone-scoped key anywhere a policy is being built **account-scoped**.
129
129
  *
130
- * A zone-level permission group handed the account resource is the quiet failure (#651): Cloudflare
130
+ * A zone-level permission group handed the account resource is the quiet failure: Cloudflare
131
131
  * mints the token without complaint and then answers "No access to the specified resource" at the call,
132
132
  * which is a CI failure hours later with nothing in it naming the scope. Which zones a token gets is
133
133
  * never a typed choice — it is the project's declared `domains`, resolved at mint time — so a key naming
@@ -156,17 +156,16 @@ function permissionsForKeys(keys, accountId) {
156
156
  *
157
157
  * This is the second policy on the `ci-system` token, beside the account-scoped one every profile
158
158
  * builds. It exists because `pithy deploy` of an environment with a declared domain calls
159
- * `POST /zones/<zone>/workers/routes`, the one deploy step the CI credential could not make (#651) — and
159
+ * `POST /zones/<zone>/workers/routes`, which no account-scoped group reaches — and
160
160
  * it is a policy of its own rather than a permission on the profile because its resources are zones,
161
161
  * which a profile cannot name: they are resolved from the domains at mint time.
162
162
  *
163
163
  * **This limits the route, not the token.** The account policy beside it carries Workers Scripts Write,
164
- * which attaches a custom domain on any zone the account holds (measured, #709; `docs/TOKENS.md`). Only
164
+ * which attaches a custom domain on any zone the account holds (measured; `docs/TOKENS.md`). Only
165
165
  * the route calls are held to the declared zones.
166
166
  *
167
167
  * **Empty for an empty zone list, deliberately.** A project that declares no domain attaches no route,
168
- * so it mints exactly the token it minted before this existed. No new permission for a project that
169
- * needs none.
168
+ * so it gets no zone permission.
170
169
  */
171
170
  function routePermissions(zoneIds) {
172
171
  if (zoneIds.length === 0) return [];
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.1",
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) {
@@ -0,0 +1,46 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { BindingType } from "@pithy-sh/core/src/capability/bindings";
5
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
6
+ import type { PermissionKey } from "./permissions";
7
+
8
+ /**
9
+ * **What a binding of each kind needs the `ci-system` token to carry**, so that provisioning, deploying and
10
+ * tearing down the resource behind it succeed under that token and under a signed-in session.
11
+ *
12
+ * Read off the binding rather than declared by a capability, because the adopter's own app binds resources
13
+ * too and has no `ciPermissions` anyone would think to write. A kind a provision command creates with no
14
+ * entry here fails `cli/src/provision/bindingPermissions.test.ts`.
15
+ *
16
+ * - `d1`, `secret` and `workflow` name what the `ci-system` base already carries.
17
+ * - `kv`, `r2` and `vectorize` take write alone. Provisioning lists before it creates, and a write scope
18
+ * covers its read, as `OAUTH_SCOPES` records.
19
+ * - `queue` and `artifacts` are reached by the API only, to create or reap; a Worker uses its binding.
20
+ */
21
+ export const BINDING_PERMISSIONS: Readonly<Partial<Record<BindingType, readonly PermissionKey[]>>> = {
22
+ d1: ["d1:read", "d1:write"],
23
+ kv: ["kv:write"],
24
+ r2: ["r2:write"],
25
+ vectorize: ["vectorize:write"],
26
+ queue: ["queues:write"],
27
+ artifacts: ["artifacts:write"],
28
+ secret: ["secrets:read", "secrets:write"],
29
+ workflow: ["workers:write"],
30
+ };
31
+
32
+ /**
33
+ * The permission keys every binding the capabilities declare needs, each once, in first-seen order. The
34
+ * adopter's own app is a capability here like any other, so a bucket it declares counts.
35
+ */
36
+ export function bindingPermissions(capabilities: readonly Capability[]): PermissionKey[] {
37
+ const keys: PermissionKey[] = [];
38
+ for (const capability of capabilities) {
39
+ for (const binding of capability.requiredBindings) {
40
+ for (const key of BINDING_PERMISSIONS[binding.type] ?? []) {
41
+ if (!keys.includes(key)) keys.push(key);
42
+ }
43
+ }
44
+ }
45
+ return keys;
46
+ }
@@ -4,6 +4,7 @@
4
4
  import type { Capability } from "@pithy-sh/core/src/capability/capability";
5
5
  import { CloudflareNotConfiguredError } from "../client/errors";
6
6
  import { accountResource, type TokenPermission, zoneResources } from "./accountTokensManager";
7
+ import { bindingPermissions } from "./bindingPermissions";
7
8
  import {
8
9
  isPermissionKey,
9
10
  isZoneScopedPermission,
@@ -22,8 +23,7 @@ import { RUNTIME_PROFILE, RUNTIME_TOKEN_SECRET } from "./runtime";
22
23
  * - `dev-vars` — `<config>/<project>/tokens.json`, keyed by environment and outside every checkout.
23
24
  * **Read by an operator, never by a command** — nothing resolves a credential from it. The store for
24
25
  * the `ci-system` token: mint it here, read the value
25
- * out, and set it as CI's `CLOUDFLARE_API_TOKEN`. It wrote `.dev.vars.<env>` *inside* the project until
26
- * #182; the name stays because it is a public `--store` flag value.
26
+ * out, and set it as CI's `CLOUDFLARE_API_TOKEN`. The name is a public `--store` flag value.
27
27
  * - `ephemeral` — nothing is written; the value is used in-process and discarded. The one-step CI
28
28
  * path — CI can't read the CF Secrets Store, so it mints and uses the token in the same job.
29
29
  */
@@ -108,29 +108,18 @@ function assertPermissionKeys(keys: readonly string[], where: string): Permissio
108
108
 
109
109
  /**
110
110
  * Build the aggregating `ci-system` profile: the base permissions ∪ every capability's `ciPermissions` ∪
111
- * `queues:write` where a composed capability binds a queue ∪ `artifacts:write` where one binds Artifacts.
112
- *
113
- * **The queue permission is derived from the binding, not declared (#662).** `pithy provision` creates the queue
114
- * behind every `queue` binding it composes, the adopter's own app's included, and the app has no `ciPermissions`
115
- * anyone would think to write. A permission read off the binding cannot be forgotten by whoever declares it.
111
+ * what every composed binding needs, from `BINDING_PERMISSIONS`. The adopter's own app counts as a
112
+ * capability, so a resource it binds brings its permission with no declaration.
116
113
  */
117
114
  function ciSystemProfile(capabilities: readonly Capability[]): TokenProfile {
118
115
  const permissions: PermissionKey[] = [...CI_SYSTEM_BASE];
116
+ const add = (key: PermissionKey) => {
117
+ if (!permissions.includes(key)) permissions.push(key);
118
+ };
119
119
  for (const capability of capabilities) {
120
- for (const key of assertPermissionKeys(capability.ciPermissions ?? [], `Capability "${capability.name}"`)) {
121
- if (!permissions.includes(key)) permissions.push(key);
122
- }
120
+ for (const key of assertPermissionKeys(capability.ciPermissions ?? [], `Capability "${capability.name}"`)) add(key);
123
121
  }
124
- const bindsQueue = capabilities.some((capability) =>
125
- capability.requiredBindings.some((binding) => binding.type === "queue"),
126
- );
127
- if (bindsQueue && !permissions.includes("queues:write")) permissions.push("queues:write");
128
- // **And `artifacts:write` where one binds Artifacts (#673)**, for the same reason: `pithy feature destroy` reaps
129
- // every repo in the feature's namespace under this token, and the app that binds it declares no `ciPermissions`.
130
- const bindsArtifacts = capabilities.some((capability) =>
131
- capability.requiredBindings.some((binding) => binding.type === "artifacts"),
132
- );
133
- if (bindsArtifacts && !permissions.includes("artifacts:write")) permissions.push("artifacts:write");
122
+ for (const key of bindingPermissions(capabilities)) add(key);
134
123
  return {
135
124
  name: CI_SYSTEM_PROFILE,
136
125
  permissions,
@@ -214,7 +203,7 @@ export function resolveProfile(
214
203
  /**
215
204
  * Refuse a zone-scoped key anywhere a policy is being built **account-scoped**.
216
205
  *
217
- * A zone-level permission group handed the account resource is the quiet failure (#651): Cloudflare
206
+ * A zone-level permission group handed the account resource is the quiet failure: Cloudflare
218
207
  * mints the token without complaint and then answers "No access to the specified resource" at the call,
219
208
  * which is a CI failure hours later with nothing in it naming the scope. Which zones a token gets is
220
209
  * never a typed choice — it is the project's declared `domains`, resolved at mint time — so a key naming
@@ -243,17 +232,16 @@ export function permissionsForKeys(keys: PermissionKey[], accountId: string): To
243
232
  *
244
233
  * This is the second policy on the `ci-system` token, beside the account-scoped one every profile
245
234
  * builds. It exists because `pithy deploy` of an environment with a declared domain calls
246
- * `POST /zones/<zone>/workers/routes`, the one deploy step the CI credential could not make (#651) — and
235
+ * `POST /zones/<zone>/workers/routes`, which no account-scoped group reaches — and
247
236
  * it is a policy of its own rather than a permission on the profile because its resources are zones,
248
237
  * which a profile cannot name: they are resolved from the domains at mint time.
249
238
  *
250
239
  * **This limits the route, not the token.** The account policy beside it carries Workers Scripts Write,
251
- * which attaches a custom domain on any zone the account holds (measured, #709; `docs/TOKENS.md`). Only
240
+ * which attaches a custom domain on any zone the account holds (measured; `docs/TOKENS.md`). Only
252
241
  * the route calls are held to the declared zones.
253
242
  *
254
243
  * **Empty for an empty zone list, deliberately.** A project that declares no domain attaches no route,
255
- * so it mints exactly the token it minted before this existed. No new permission for a project that
256
- * needs none.
244
+ * so it gets no zone permission.
257
245
  */
258
246
  export function routePermissions(zoneIds: readonly string[]): TokenPermission[] {
259
247
  if (zoneIds.length === 0) return [];