@pithy-sh/cloudflare 0.3.1 → 0.4.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.
@@ -1 +1 @@
1
- {"version":3,"file":"accountTokensManager.d.ts","sourceRoot":"","sources":["../../src/tokens/accountTokensManager.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAYxB,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAkB1D;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,kHAAkH;IAClH,oBAAoB,EAAE,MAAM,EAAE,CAAC;IAC/B,kGAAkG;IAClG,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,2EAA2E;IAC3E,MAAM,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;CAC3B;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAEzE;AAED;;;GAGG;AACH,eAAO,MAAM,iBAAiB;;;iBAK8E,CAAC;AAC7G,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAEnE;;;;GAIG;AACH,eAAO,MAAM,kBAAkB;;;;;;;;;iBAO6D,CAAC;AAC7F,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAErE;;;;GAIG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;iBAMuD,CAAC;AACxF,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEvE;;;;;;;;;GASG;AACH,qBAAa,8BAA+B,SAAQ,iBAAiB;IACnE,cAAc,IAAI,MAAM,CAEvB;IAED,qGAAqG;IAC/F,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED;;;;;;;OAOG;IACG,WAAW,IAAI,OAAO,CAAC,mBAAmB,CAAC,CAKhD;IAED;;;;;;;;;;;;OAYG;IACG,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAe1D;IAED,qGAAqG;IAC/F,oBAAoB,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAezD;IAED;;;;;;OAMG;IACG,uBAAuB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAE7E;IAED;;;;;;;OAOG;IACG,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,eAAe,EAAE,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAgDzF;IAED;;;;;OAKG;IACH,OAAO,CAAC,mBAAmB;IAqB3B,2GAA2G;IACrG,UAAU,IAAI,OAAO,CAAC,mBAAmB,EAAE,CAAC,CAajD;IAED,oGAAoG;IAC9F,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAYvE;IAED,qCAAqC;IAC/B,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAMhD;IAED;;;;OAIG;IACG,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAgBtD;IAED;;;;;OAKG;IACG,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAOrD;IAED;;;;;;OAMG;IACG,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,eAAe,EAAE,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAKzF;CACF"}
1
+ {"version":3,"file":"accountTokensManager.d.ts","sourceRoot":"","sources":["../../src/tokens/accountTokensManager.ts"],"names":[],"mappings":"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"}
@@ -29,6 +29,34 @@ function accountResource(accountId) {
29
29
  return { [`com.cloudflare.api.account.${accountId}`]: "*" };
30
30
  }
31
31
  /**
32
+ * The resource scope for a **zone-level** policy: one `com.cloudflare.api.account.zone.<id>` key per
33
+ * zone, each `"*"`.
34
+ *
35
+ * Every zone is named individually, and that is the point (#651). Cloudflare also spells "every zone in
36
+ * this account" — `{ "com.cloudflare.api.account.<id>": { "com.cloudflare.api.account.zone.*": "*" } }` —
37
+ * and a `ci-system` token must never carry it: the credential a pipeline runs under gets the zones the
38
+ * project's own declared domains sit in and no others, so an account holding a customer's zone beside
39
+ * this project's is not one compromise away from both. De-duped, because two environments and two
40
+ * Workers routinely share one zone.
41
+ */
42
+ function zoneResources(zoneIds) {
43
+ const resources = {};
44
+ for (const zoneId of zoneIds) resources[`${ZONE_RESOURCE_PREFIX}${zoneId}`] = "*";
45
+ return resources;
46
+ }
47
+ /**
48
+ * Read an explicit `null` as "the response did not say", keeping the field **optional** rather than
49
+ * required-and-undefined.
50
+ *
51
+ * Every optional field decoded off a token list goes through this, and it is load-bearing rather than
52
+ * defensive (#651): `listTokens` decodes each entry with `safeParse` and drops what fails, so one `null`
53
+ * where a schema wanted a value makes a live token invisible to `findTokenByName` — and the next roll
54
+ * mints a *second* credential of the same name beside the first, with nothing saying so.
55
+ */
56
+ function nullAsAbsent(schema) {
57
+ return z.preprocess((value) => value ?? void 0, schema);
58
+ }
59
+ /**
32
60
  * A permission group available to account-owned tokens, as returned by the permission-groups list.
33
61
  * Only the id and name matter for resolution — the manager maps a requested name to its id.
34
62
  */
@@ -45,25 +73,44 @@ const MintedAccountToken = z.object({
45
73
  id: z.string().describe("The CF-assigned token id, used to address the token for get/delete."),
46
74
  value: z.string().describe("The secret token value — returned once on create, never again. Store immediately."),
47
75
  name: z.string().optional().describe("The token name as registered with Cloudflare."),
48
- status: z.enum([
76
+ status: nullAsAbsent(z.enum([
49
77
  "active",
50
78
  "disabled",
51
79
  "expired"
52
- ]).optional().describe("The token's lifecycle status.")
80
+ ]).describe("The lifecycle state Cloudflare holds for the token.").optional()).describe("The token's lifecycle status.")
53
81
  }).describe("A newly created account-owned API token, including its one-time secret value.");
54
82
  /**
83
+ * One access policy on an existing token, as the token list returns it.
84
+ *
85
+ * **Every field is `nullish`, and that is load-bearing rather than defensive.** `listTokens` decodes
86
+ * each entry with `safeParse` and drops what fails, so a single `null` where this expected a value
87
+ * makes a live token invisible to `findTokenByName` — and the next roll mints a *second* credential of
88
+ * the same name beside the first, with nothing saying so (#651).
89
+ */
90
+ const AccountTokenPolicy = z.object({
91
+ effect: nullAsAbsent(z.enum(["allow", "deny"]).describe("Allow or deny, as Cloudflare states it on the policy.").optional()).describe("Whether this policy grants its groups on its resources or refuses them. Cloudflare evaluates explicit denies first, so a policy naming a group is not evidence the token may use it. Absent is an allow, which is the API's own default."),
92
+ permission_groups: nullAsAbsent(z.array(z.object({ id: z.string().describe("The permission-group id this policy grants.") }).describe("One permission group referenced by a live token's policy, by the id the account assigned it.")).optional()).describe("The permission groups this policy grants, by id. **Read together with `resources`, never apart**: a zone resource says which zone a policy is about and nothing about what it may do there, so coverage judged from resources alone reads a zone-scoped `Zone Read` as a route grant. Absent has not said the policy grants nothing."),
93
+ resources: z.record(z.string(), z.unknown()).describe("The resource scope keys this policy applies to — `com.cloudflare.api.account.<id>` for an account policy, `com.cloudflare.api.account.zone.<id>` for a zone one, and either nested inside the other. Read to tell what a live token is scoped to; the values are not interpreted beyond that nesting.")
94
+ }).describe("One access policy on an existing token: the groups it grants, and the resources it grants them on.");
95
+ /**
55
96
  * An existing account-owned token's metadata, as returned by list/get. No `value` — Cloudflare never
56
- * returns a token's secret after creation — so this is only enough to find a token by name and
57
- * address it for deletion.
97
+ * returns a token's secret after creation — so this is only enough to find a token by name, report on
98
+ * it, and address it for deletion.
99
+ *
100
+ * **It reads what it uses and nothing more.** It briefly carried `expires_on`, `not_before` and
101
+ * `condition` so a re-scoping mint could resend them; that mint is gone (see {@link
102
+ * CloudflareAccountTokensManager.rollToken}), and reading a field only to hand it straight back is a
103
+ * field that can be read wrong.
58
104
  */
59
105
  const AccountTokenSummary = z.object({
60
106
  id: z.string().describe("The CF-assigned token id, used to address the token for deletion."),
61
107
  name: z.string().describe("The token name, the key callers match on for idempotent re-mint."),
62
- status: z.enum([
108
+ status: nullAsAbsent(z.enum([
63
109
  "active",
64
110
  "disabled",
65
111
  "expired"
66
- ]).optional().describe("The token's lifecycle status.")
112
+ ]).describe("The lifecycle state Cloudflare holds for the token.").optional()).describe("The token's lifecycle status. Reported, because a `disabled` or `expired` token cannot attach a route however its policies read. `nullish` for the reason {@link AccountTokenPolicy} gives."),
113
+ policies: nullAsAbsent(z.array(AccountTokenPolicy).optional()).catch(void 0).describe("The token's access policies, when the list response carries them. **A live token's own scope is the record of how it was minted** — it is how a `ci-system` token minted before zone-scoped routes (#651) can be told from one minted after, without reading the token record (which needs a grant these least-privilege tokens deliberately lack). Absent and empty are different facts: a response that says nothing means the scope could not be read, never that the token has none.")
67
114
  }).describe("An existing account-owned API token's metadata (never its secret value).");
68
115
  /**
69
116
  * Out-of-Worker control plane for **account-owned** Cloudflare API tokens: mint scoped, least-privilege
@@ -173,7 +220,7 @@ var CloudflareAccountTokensManager = class extends CloudflareManager {
173
220
  const said = cloudflareSaid("The Cloudflare API token is not allowed to create account tokens.", cloudflareApiErrors(error));
174
221
  throw new CloudflareNotConfiguredError({
175
222
  message: said.message,
176
- action: "Grant it 'Account API Tokens Write' (Account → API Tokens → Edit), then re-run.",
223
+ action: delegationAction(permissions),
177
224
  params: said.params,
178
225
  detail: `mint account token '${name}': ${messageOf(error)}`
179
226
  }, { cause: error });
@@ -270,6 +317,18 @@ var CloudflareAccountTokensManager = class extends CloudflareManager {
270
317
  * Either way the caller gets a usable secret to store — the idempotent path for a credential that
271
318
  * lives in the Secrets Store, since Cloudflare never returns a token's existing secret. Mirrors the
272
319
  * dashboard's roll-or-create.
320
+ *
321
+ * **A roll never changes the policy set, and that is a decision rather than an omission (#651).** It
322
+ * briefly did: `pithy token list` tells an adopter their CI token predates zone-scoped routes, and
323
+ * re-scoping here made the remedy one command. But Cloudflare's token update is a full
324
+ * representation, so doing it meant every mint had to resend the whole token correctly — `condition`,
325
+ * `expires_on`, `not_before`, `status` — and three review rounds each found a new way that went
326
+ * wrong: a cleared IP allowlist, a re-enabled disabled token, a `null` that hid the token and minted a
327
+ * duplicate beside it.
328
+ *
329
+ * {@link mintToken} plus a delete is what actually re-scopes, and `pithy token rotate` has always been
330
+ * exactly that. So the remedy names rotate, and this stays the one thing it can be relied on to be:
331
+ * a new secret on the same token, with nothing else touched.
273
332
  */
274
333
  async rollToken(name, permissions) {
275
334
  const existing = await this.findTokenByName(name);
@@ -283,6 +342,24 @@ var CloudflareAccountTokensManager = class extends CloudflareManager {
283
342
  };
284
343
  }
285
344
  };
345
+ /**
346
+ * The remedy line for a 403 on a mint, which depends on **what was being delegated**.
347
+ *
348
+ * Cloudflare only lets a token create a token whose permissions it already holds, so a mint carrying a
349
+ * zone-scoped policy (#651's Workers Routes Write, on the project's declared zones) fails for a
350
+ * bootstrap token that holds every account grant and no zone one. Telling that operator to grant
351
+ * "Account API Tokens Write" is true, useless, and sends them to a checkbox that is already ticked.
352
+ *
353
+ * So the zone half is named when a zone policy is in the set, and only then.
354
+ */
355
+ function delegationAction(permissions) {
356
+ const base = "Grant it 'Account API Tokens Write' (Account → API Tokens → Edit), then re-run.";
357
+ const zoneScoped = permissions.filter((permission) => Object.keys(permission.resources).some((key) => key.startsWith(ZONE_RESOURCE_PREFIX)));
358
+ if (zoneScoped.length === 0) return base;
359
+ return `${base} It must also already hold what it is delegating: ${[...new Set(zoneScoped.flatMap((permission) => permission.permissionGroupNames))].join(", ")} on the zones being scoped (Zone → Workers Routes → Edit), because Cloudflare only lets a token create a token whose permissions it has.`;
360
+ }
361
+ /** The resource-key prefix every zone-scoped policy is written under. */
362
+ const ZONE_RESOURCE_PREFIX = "com.cloudflare.api.account.zone.";
286
363
  /** The rolled secret value Cloudflare returns from a value-roll — a non-empty bearer string. */
287
364
  const RolledTokenValue = z.string().min(1);
288
365
  /** Just the token's name, the piece the account-token actor path needs. */
@@ -302,4 +379,4 @@ function indexByName(groups) {
302
379
  return index;
303
380
  }
304
381
  //#endregion
305
- export { AccountTokenSummary, CfPermissionGroup, CloudflareAccountTokensManager, MintedAccountToken, accountResource };
382
+ export { AccountTokenPolicy, AccountTokenSummary, CfPermissionGroup, CloudflareAccountTokensManager, MintedAccountToken, accountResource, zoneResources };
@@ -24,11 +24,24 @@ export declare const PERMISSION_GROUPS: {
24
24
  readonly "vectorize:write": readonly ["Vectorize Write"];
25
25
  readonly "ai:read": readonly ["Workers AI Read"];
26
26
  readonly "zone:read": readonly ["Zone Read"];
27
+ readonly "routes:write": readonly ["Workers Routes Write"];
27
28
  };
28
29
  /** A known permission key — one of {@link PERMISSION_GROUPS}'s keys. */
29
30
  export type PermissionKey = keyof typeof PERMISSION_GROUPS;
31
+ /**
32
+ * The keys whose Cloudflare permission group is **zone-scoped**: a policy carrying one must name zone
33
+ * resources (`com.cloudflare.api.account.zone.<id>`), never the account resource.
34
+ *
35
+ * A minted token's resources are account-scoped by default, which is why this distinction has to be
36
+ * stated rather than inferred: hand a zone-level group the account resource and Cloudflare accepts the
37
+ * token and refuses the call, which is exactly the failure #651 opened with. The zones themselves are
38
+ * never picked by hand — they are the zones the project's declared domains sit in.
39
+ */
40
+ export declare const ZONE_SCOPED_PERMISSIONS: readonly PermissionKey[];
30
41
  /** Narrow an arbitrary string to a {@link PermissionKey}. */
31
42
  export declare function isPermissionKey(key: string): key is PermissionKey;
43
+ /** Whether a key's permission group is zone-scoped — see {@link ZONE_SCOPED_PERMISSIONS}. */
44
+ export declare function isZoneScopedPermission(key: string): boolean;
32
45
  /**
33
46
  * Resolve permission keys to the CF permission-group display names they grant, de-duped in first-seen
34
47
  * order. An unknown key fails with an actionable error naming the valid keys — caught at the CLI/config
@@ -1 +1 @@
1
- {"version":3,"file":"permissions.d.ts","sourceRoot":"","sources":["../../src/tokens/permissions.ts"],"names":[],"mappings":"AAKA;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB;aAC5B,SAAS,YAAG,SAAS;aAIrB,UAAU,YAAG,UAAU;aACvB,eAAe,YAAG,uBAAuB;aACzC,cAAc,YAAG,oBAAoB;aACrC,eAAe,YAAG,qBAAqB;aACvC,eAAe,YAAG,2BAA2B;aAC7C,UAAU,YAAG,0BAA0B;aACvC,SAAS,YAAG,yBAAyB;aACrC,UAAU,YAAG,0BAA0B;aACvC,gBAAgB,YAAG,gBAAgB;aACnC,iBAAiB,YAAG,iBAAiB;aACrC,SAAS,YAAG,iBAAiB;aAK7B,WAAW,YAAG,WAAW;CACjB,CAAC;AAEX,wEAAwE;AACxE,MAAM,MAAM,aAAa,GAAG,MAAM,OAAO,iBAAiB,CAAC;AAE3D,6DAA6D;AAC7D,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,IAAI,aAAa,CAEjE;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAe9D"}
1
+ {"version":3,"file":"permissions.d.ts","sourceRoot":"","sources":["../../src/tokens/permissions.ts"],"names":[],"mappings":"AAKA;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB;aAC5B,SAAS,YAAG,SAAS;aAIrB,UAAU,YAAG,UAAU;aACvB,eAAe,YAAG,uBAAuB;aACzC,cAAc,YAAG,oBAAoB;aACrC,eAAe,YAAG,qBAAqB;aACvC,eAAe,YAAG,2BAA2B;aAC7C,UAAU,YAAG,0BAA0B;aACvC,SAAS,YAAG,yBAAyB;aACrC,UAAU,YAAG,0BAA0B;aACvC,gBAAgB,YAAG,gBAAgB;aACnC,iBAAiB,YAAG,iBAAiB;aACrC,SAAS,YAAG,iBAAiB;aAK7B,WAAW,YAAG,WAAW;aAazB,cAAc,YAAG,sBAAsB;CAC/B,CAAC;AAEX,wEAAwE;AACxE,MAAM,MAAM,aAAa,GAAG,MAAM,OAAO,iBAAiB,CAAC;AAE3D;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,EAAE,SAAS,aAAa,EAAqB,CAAC;AAElF,6DAA6D;AAC7D,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,IAAI,aAAa,CAEjE;AAED,6FAA6F;AAC7F,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAE3D;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAe9D"}
@@ -25,12 +25,27 @@ const PERMISSION_GROUPS = {
25
25
  "vectorize:read": ["Vectorize Read"],
26
26
  "vectorize:write": ["Vectorize Write"],
27
27
  "ai:read": ["Workers AI Read"],
28
- "zone:read": ["Zone Read"]
28
+ "zone:read": ["Zone Read"],
29
+ "routes:write": ["Workers Routes Write"]
29
30
  };
31
+ /**
32
+ * The keys whose Cloudflare permission group is **zone-scoped**: a policy carrying one must name zone
33
+ * resources (`com.cloudflare.api.account.zone.<id>`), never the account resource.
34
+ *
35
+ * A minted token's resources are account-scoped by default, which is why this distinction has to be
36
+ * stated rather than inferred: hand a zone-level group the account resource and Cloudflare accepts the
37
+ * token and refuses the call, which is exactly the failure #651 opened with. The zones themselves are
38
+ * never picked by hand — they are the zones the project's declared domains sit in.
39
+ */
40
+ const ZONE_SCOPED_PERMISSIONS = ["routes:write"];
30
41
  /** Narrow an arbitrary string to a {@link PermissionKey}. */
31
42
  function isPermissionKey(key) {
32
43
  return key in PERMISSION_GROUPS;
33
44
  }
45
+ /** Whether a key's permission group is zone-scoped — see {@link ZONE_SCOPED_PERMISSIONS}. */
46
+ function isZoneScopedPermission(key) {
47
+ return ZONE_SCOPED_PERMISSIONS.includes(key);
48
+ }
34
49
  /**
35
50
  * Resolve permission keys to the CF permission-group display names they grant, de-duped in first-seen
36
51
  * order. An unknown key fails with an actionable error naming the valid keys — caught at the CLI/config
@@ -49,4 +64,4 @@ function resolvePermissionKeys(keys) {
49
64
  return names;
50
65
  }
51
66
  //#endregion
52
- export { PERMISSION_GROUPS, isPermissionKey, resolvePermissionKeys };
67
+ export { PERMISSION_GROUPS, ZONE_SCOPED_PERMISSIONS, isPermissionKey, isZoneScopedPermission, resolvePermissionKeys };
@@ -95,6 +95,21 @@ export declare function resolveTokenProfiles(capabilities: readonly Capability[]
95
95
  export declare function resolveProfile(profiles: Record<string, TokenProfile>, name: string, override?: ProfileOverride): TokenProfile;
96
96
  /** Build the account-scoped {@link TokenPermission}s for a set of permission keys — the mint input. */
97
97
  export declare function permissionsForKeys(keys: PermissionKey[], accountId: string): TokenPermission[];
98
+ /**
99
+ * The **zone-scoped** policy a project's declared domains require: Workers Routes Write on exactly those
100
+ * zones, and nothing else.
101
+ *
102
+ * This is the second policy on the `ci-system` token, beside the account-scoped one every profile
103
+ * builds. It exists because `pithy deploy` of an environment with a declared domain calls
104
+ * `POST /zones/<zone>/workers/routes`, the one deploy step the CI credential could not make (#651) — and
105
+ * it is a policy of its own rather than a permission on the profile because its resources are zones,
106
+ * which a profile cannot name: they are resolved from the domains at mint time.
107
+ *
108
+ * **Empty for an empty zone list, deliberately.** A project that declares no domain attaches no route,
109
+ * so it mints exactly the token it minted before this existed. No new permission for a project that
110
+ * needs none.
111
+ */
112
+ export declare function routePermissions(zoneIds: readonly string[]): TokenPermission[];
98
113
  /**
99
114
  * Turn a resolved profile into the {@link TokenPermission}s the account-tokens manager mints from: the
100
115
  * profile's permission keys resolved to CF group names, scoped to the account (or the profile's
@@ -1 +1 @@
1
- {"version":3,"file":"profiles.d.ts","sourceRoot":"","sources":["../../src/tokens/profiles.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,0CAA0C,CAAC;AAE7F,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAC/E,OAAO,EAAmB,KAAK,aAAa,EAAyB,MAAM,eAAe,CAAC;AAE3F;;;;;;;;;;;;;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;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,gBAAgB,CAAC;IAC/B,2GAA2G;IAC3G,YAAY,CAAC,EAAE,UAAU,CAAC;IAC1B,sDAAsD;IACtD,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,kGAAkG;AAClG,MAAM,MAAM,gBAAgB,GAAG,aAAa,GAAG,QAAQ,CAAC;AAKxD,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;AAkCD;;;;;;;;;GASG;AACH,MAAM,WAAW,qBAAsB,SAAQ,gBAAgB;IAC7D,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AA6BD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,SAAS,UAAU,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CActG;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;AAED,uGAAuG;AACvG,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,aAAa,EAAE,EAAE,SAAS,EAAE,MAAM,GAAG,eAAe,EAAE,CAE9F;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,GAAG,eAAe,EAAE,CAI9F"}
1
+ {"version":3,"file":"profiles.d.ts","sourceRoot":"","sources":["../../src/tokens/profiles.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,0CAA0C,CAAC;AAE7F,OAAO,EAAmB,KAAK,eAAe,EAAiB,MAAM,wBAAwB,CAAC;AAC9F,OAAO,EAIL,KAAK,aAAa,EAEnB,MAAM,eAAe,CAAC;AAEvB;;;;;;;;;;;;;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;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,gBAAgB,CAAC;IAC/B,2GAA2G;IAC3G,YAAY,CAAC,EAAE,UAAU,CAAC;IAC1B,sDAAsD;IACtD,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,kGAAkG;AAClG,MAAM,MAAM,gBAAgB,GAAG,aAAa,GAAG,QAAQ,CAAC;AAKxD,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;AAkCD;;;;;;;;;GASG;AACH,MAAM,WAAW,qBAAsB,SAAQ,gBAAgB;IAC7D,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AA6BD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,SAAS,UAAU,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CActG;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;;;;;;;;;;;;;GAaG;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,8 +1,8 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { CloudflareNotConfiguredError } from "../client/errors.js";
4
- import { accountResource } from "./accountTokensManager.js";
5
- import { isPermissionKey, resolvePermissionKeys } from "./permissions.js";
4
+ import { accountResource, zoneResources } from "./accountTokensManager.js";
5
+ import { PERMISSION_GROUPS, isPermissionKey, isZoneScopedPermission, resolvePermissionKeys } from "./permissions.js";
6
6
  //#region src/tokens/profiles.ts
7
7
  /** The valid stores, for validating a `--store` flag or a profile's `defaultStore`. */
8
8
  const TOKEN_STORES = [
@@ -116,19 +116,60 @@ function resolveProfile(profiles, name, override) {
116
116
  defaultStore: override.store ?? base.defaultStore
117
117
  };
118
118
  }
119
+ /**
120
+ * Refuse a zone-scoped key anywhere a policy is being built **account-scoped**.
121
+ *
122
+ * A zone-level permission group handed the account resource is the quiet failure (#651): Cloudflare
123
+ * mints the token without complaint and then answers "No access to the specified resource" at the call,
124
+ * which is a CI failure hours later with nothing in it naming the scope. Which zones a token gets is
125
+ * never a typed choice — it is the project's declared `domains`, resolved at mint time — so a key naming
126
+ * one in a profile or behind `--permission` is a mistake, and this is where it is caught.
127
+ */
128
+ function assertAccountScoped(keys, where) {
129
+ const zoneScoped = keys.filter(isZoneScopedPermission);
130
+ if (zoneScoped.length === 0) return;
131
+ throw new CloudflareNotConfiguredError({
132
+ message: `${where} names zone-scoped permission(s): ${zoneScoped.join(", ")}.`,
133
+ action: "A zone permission is not chosen by hand — declare the Worker's `domains` in pithy.config.ts and the mint scopes the token to those zones. Remove the key.",
134
+ detail: `build token policy: ${zoneScoped.join(", ")} cannot take the account resource`
135
+ });
136
+ }
119
137
  /** Build the account-scoped {@link TokenPermission}s for a set of permission keys — the mint input. */
120
138
  function permissionsForKeys(keys, accountId) {
139
+ assertAccountScoped(keys, "This token");
121
140
  return [{
122
141
  permissionGroupNames: resolvePermissionKeys(keys),
123
142
  resources: accountResource(accountId)
124
143
  }];
125
144
  }
126
145
  /**
146
+ * The **zone-scoped** policy a project's declared domains require: Workers Routes Write on exactly those
147
+ * zones, and nothing else.
148
+ *
149
+ * This is the second policy on the `ci-system` token, beside the account-scoped one every profile
150
+ * builds. It exists because `pithy deploy` of an environment with a declared domain calls
151
+ * `POST /zones/<zone>/workers/routes`, the one deploy step the CI credential could not make (#651) — and
152
+ * it is a policy of its own rather than a permission on the profile because its resources are zones,
153
+ * which a profile cannot name: they are resolved from the domains at mint time.
154
+ *
155
+ * **Empty for an empty zone list, deliberately.** A project that declares no domain attaches no route,
156
+ * so it mints exactly the token it minted before this existed. No new permission for a project that
157
+ * needs none.
158
+ */
159
+ function routePermissions(zoneIds) {
160
+ if (zoneIds.length === 0) return [];
161
+ return [{
162
+ permissionGroupNames: [...PERMISSION_GROUPS["routes:write"]],
163
+ resources: zoneResources(zoneIds)
164
+ }];
165
+ }
166
+ /**
127
167
  * Turn a resolved profile into the {@link TokenPermission}s the account-tokens manager mints from: the
128
168
  * profile's permission keys resolved to CF group names, scoped to the account (or the profile's
129
169
  * explicit resources). One policy per profile — a token carries exactly the access its profile declares.
130
170
  */
131
171
  function profilePermissions(profile, accountId) {
172
+ assertAccountScoped(profile.permissions, `Token profile "${profile.name}"`);
132
173
  const resources = profile.resources && profile.resources !== "account" ? profile.resources : accountResource(accountId);
133
174
  return [{
134
175
  permissionGroupNames: resolvePermissionKeys(profile.permissions),
@@ -136,4 +177,4 @@ function profilePermissions(profile, accountId) {
136
177
  }];
137
178
  }
138
179
  //#endregion
139
- export { CI_SYSTEM_PROFILE, TOKEN_STORES, defineTokenProfile, permissionsForKeys, profilePermissions, resolveProfile, resolveTokenProfiles, tokenSecretName };
180
+ export { CI_SYSTEM_PROFILE, TOKEN_STORES, defineTokenProfile, permissionsForKeys, profilePermissions, resolveProfile, resolveTokenProfiles, routePermissions, tokenSecretName };
@@ -1,9 +1,10 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
- import { CloudflareInvalidResponseError, CloudflareRequestError, cloudflareApiErrors, cloudflareRefusal, cloudflareRequest, decodeResponse, messageOf } from "../client/errors.js";
3
+ import { CloudflareInvalidResponseError, cloudflareApiErrors, cloudflareRefusal, cloudflareRequest, decodeResponse, messageOf } from "../client/errors.js";
4
4
  import { CloudflareManager } from "../client/manager.js";
5
5
  import { CfBuild, CfBuildLog, CfBuildToken, CfBuildTrigger, CfRepoConnection } from "./buildsTypes.js";
6
6
  import { z } from "zod";
7
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
7
8
  //#region src/workers/buildsManager.ts
8
9
  /** The Cloudflare REST API v4 base URL. The Builds endpoints live under `/accounts/{id}/builds`. */
9
10
  const CF_API_BASE = "https://api.cloudflare.com/client/v4";
@@ -228,12 +229,19 @@ var CloudflareBuildsManager = class extends CloudflareManager {
228
229
  }
229
230
  };
230
231
  /**
231
- * Whether a thrown `CloudflareRequestError` carries the given CF error code. Reads the `[cf-codes:…]`
232
+ * Whether a thrown Cloudflare refusal carries the given CF error code. Reads the `[cf-codes:…]`
232
233
  * marker `call()` pins to the front of `detail` (stable across the 2000-char truncation); falls back
233
234
  * to a substring scan for errors raised without the structured marker.
235
+ *
236
+ * **Narrowed on `PithyError`, not on one subclass of it (#647).** This read used to require a
237
+ * `CloudflareRequestError`, which was true of every refusal for as long as `cloudflareRefusal` composed
238
+ * exactly one class. It no longer does: a 429 or a 5xx is now an `UpstreamError`, so that narrowing
239
+ * would have silently stopped seeing a CF error code the moment Cloudflare answered one under a status
240
+ * that retries. The code lives in `detail`, which every `PithyError` carries, so the payload is what to
241
+ * read and the class is not.
234
242
  */
235
243
  function hasErrorCode(error, code) {
236
- if (!(error instanceof CloudflareRequestError)) return false;
244
+ if (!(error instanceof PithyError)) return false;
237
245
  const detail = error.payload.detail ?? "";
238
246
  const marker = detail.match(/^\[cf-codes:([\d,]+)\]/);
239
247
  if (marker?.[1]) return marker[1].split(",").includes(String(code));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pithy-sh/cloudflare",
3
- "version": "0.3.1",
3
+ "version": "0.4.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.7.4",
43
+ "@pithy-sh/core": "^0.8.0",
44
44
  "zod": "^4.4.0"
45
45
  },
46
46
  "devDependencies": {
@@ -1,7 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- import { PithyError, UpstreamTimeoutError } from "@pithy-sh/core/src/error/pithyError";
4
+ import { PithyError, UpstreamError, UpstreamTimeoutError } from "@pithy-sh/core/src/error/pithyError";
5
5
  import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
6
6
  import { MAX_WORKFLOW_STEP_TEXT } from "@pithy-sh/core/src/workflow/stepMessage";
7
7
  import type { z } from "zod";
@@ -196,7 +196,7 @@ const ACCOUNT_PERMISSIONS: Record<string, string> = {
196
196
  };
197
197
 
198
198
  /**
199
- * Run a Cloudflare SDK call and turn any failure into a `CloudflareRequestError`, preserving the
199
+ * Run a Cloudflare SDK call and turn any failure into a typed refusal, preserving the
200
200
  * original error as `cause` and its message as internal `detail`. `operation` names the call for
201
201
  * the audit trail (`"KV get for key 'x'"`). A `PithyError` already in flight (e.g. a
202
202
  * not-configured guard) passes through untouched — only foreign throws get wrapped.
@@ -267,8 +267,11 @@ export async function cloudflareRequest<T>(
267
267
  * translating client can render them under the error code. "Add this grant, then re-run" names a
268
268
  * console the operator is sitting in front of, so it is an `action`. The raw body stays `detail`.
269
269
  *
270
- * The code stays `cloudflare/request_failed` at 502: a dedicated sibling of `core/upstream_failed`,
271
- * already the upstream class CLAUDE.md asks for, and pinned by `core`'s payload test.
270
+ * **The code is keyed on the status, because a retry policy reads it and nothing else can.** See
271
+ * {@link isTransientStatus}. A refusal Cloudflare answered with a 4xx stays `cloudflare/request_failed`
272
+ * at 502 — a dedicated sibling of `core/upstream_failed`, already the upstream class CLAUDE.md asks
273
+ * for, and pinned by `core`'s payload test. The words are composed the same way either side of that
274
+ * branch, so only the two machine-readable fields differ.
272
275
  *
273
276
  * **The message is one line, and that is a kit-wide rule this had to learn the hard way.** Round one
274
277
  * of #534 made it a small document — problem line, `errors[]` indented beneath, action last — which
@@ -299,20 +302,81 @@ export function cloudflareRefusal(args: {
299
302
  detail: string;
300
303
  /** The original throw. */
301
304
  cause?: unknown;
302
- }): CloudflareRequestError {
305
+ }): PithyError {
303
306
  // Sliced here for `permissionAction`; `cloudflareSaid` bounds what it is given for itself, so a call
304
307
  // site reaching it directly cannot exceed the same limit by arriving from somewhere else.
305
308
  const shown = args.apiErrors.slice(0, MAX_API_ERRORS);
306
309
  const said = cloudflareSaid(args.problem, shown);
307
- return new CloudflareRequestError(
308
- {
309
- message: said.message,
310
- action: permissionAction(shown, args.status, args.permission),
311
- params: said.params,
312
- detail: args.detail,
313
- },
314
- { cause: args.cause },
315
- );
310
+ const refusal = {
311
+ message: said.message,
312
+ action: permissionAction(shown, args.status, args.permission),
313
+ params: said.params,
314
+ detail: args.detail,
315
+ };
316
+ if (isTransientStatus(args.status) || isConnectionFailure(args.cause)) {
317
+ return new UpstreamError(refusal, { cause: args.cause });
318
+ }
319
+ return new CloudflareRequestError(refusal, { cause: args.cause });
320
+ }
321
+
322
+ /**
323
+ * Whether the status Cloudflare answered under means "ask again" rather than "this request is wrong".
324
+ *
325
+ * **The classification lives here because here is where the status is still in hand.** Everything a
326
+ * refusal crosses afterwards — a durable step record, a `--json` line, a retry policy — sees the
327
+ * `code` and nothing else, so a fault that is worth retrying has to say so in the code at the moment
328
+ * it is raised. `@pithy-sh/secrets`' `secretsWorkflowRetry` lists `core/upstream_failed` and
329
+ * `core/upstream_timeout` and treats everything absent as terminal; with every non-timeout failure
330
+ * arriving as `cloudflare/request_failed`, a 503 on the at-rest master-key write ended the rotation
331
+ * instance permanently, in the one state where ending it is expensive.
332
+ *
333
+ * **It is keyed here and not relaxed in the policy, and the difference is a revoked token.** Making
334
+ * the whole `cloudflare/*` family retryable would loop a 403 forever at whatever cadence the Workflow
335
+ * sleeps on, and nothing would ever say why. 4xx is Cloudflare answering and refusing — the same call
336
+ * refuses again — so it stays terminal, and CLAUDE.md §Errors is what says the other half belongs to
337
+ * the upstream pair: a failure in something we do not control is never a code that says we broke.
338
+ *
339
+ * 5xx is every server-side status, Cloudflare's own `52x` family included; `429` is rate limiting,
340
+ * which is the transient failure by definition. The timeout statuses never reach here — `isTimeout`
341
+ * claims 408, 504 and 524 first, and they are the 504 half of the same pair.
342
+ *
343
+ * **A statusless failure is not covered here, and deliberately so (#647 review).** A connection that reset
344
+ * mid-write is genuinely transient and ending a rotation on one is expensive — but "carries no status" is
345
+ * far wider than "the network faltered". A `TypeError` raised in our own request builder carries no status
346
+ * either, and a predicate that called it transient would retry a code defect on the Workflow's own cadence,
347
+ * forever, silently. `errors.test.ts` pins that: a throw that never reached Cloudflare stays terminal.
348
+ * {@link isConnectionFailure} names the narrow case instead, the way {@link statesTimeout} already does.
349
+ */
350
+ function isTransientStatus(status: number | undefined): boolean {
351
+ return status !== undefined && (status === 429 || status >= 500);
352
+ }
353
+
354
+ /**
355
+ * The producer names for "the connection did not hold", as distinct from "out of time" and from a defect
356
+ * in our own code.
357
+ *
358
+ * Duck-typed by name and `code`, for the reason {@link TIMEOUT_NAMES} gives: the SDK is one producer and
359
+ * `fetch` is another, and an `instanceof` across two copies of the SDK answers false.
360
+ */
361
+ const CONNECTION_NAMES = new Set(["APIConnectionError", "FetchError"]);
362
+ const CONNECTION_CODES = new Set(["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "EPIPE", "UND_ERR_SOCKET"]);
363
+
364
+ /**
365
+ * Whether a failure is a connection that did not hold — a reset, a refused socket, a name that would not
366
+ * resolve. Cloudflare never answered, so nothing was refused, and the next attempt may well succeed.
367
+ *
368
+ * Narrow on purpose. This is the half of "no status" that is genuinely worth another attempt; everything
369
+ * else with no status stays terminal, so a defect in our own code still stops rather than looping.
370
+ */
371
+ function isConnectionFailure(error: unknown): boolean {
372
+ let current: unknown = error;
373
+ for (let depth = 0; depth < 5 && current !== null && typeof current === "object"; depth++) {
374
+ const { name, code } = current as { name?: unknown; code?: unknown };
375
+ if (typeof name === "string" && CONNECTION_NAMES.has(name)) return true;
376
+ if (typeof code === "string" && CONNECTION_CODES.has(code)) return true;
377
+ current = (current as { cause?: unknown }).cause;
378
+ }
379
+ return false;
316
380
  }
317
381
 
318
382
  /**