@pithy-sh/cloudflare 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,6 +23,18 @@ export interface TokenPermission {
23
23
  * exact resource key format lives in a single place instead of being hand-spelled per use case.
24
24
  */
25
25
  export declare function accountResource(accountId: string): Record<string, string>;
26
+ /**
27
+ * The resource scope for a **zone-level** policy: one `com.cloudflare.api.account.zone.<id>` key per
28
+ * zone, each `"*"`.
29
+ *
30
+ * Every zone is named individually, and that is the point (#651). Cloudflare also spells "every zone in
31
+ * this account" — `{ "com.cloudflare.api.account.<id>": { "com.cloudflare.api.account.zone.*": "*" } }` —
32
+ * and a `ci-system` token must never carry it: the credential a pipeline runs under gets the zones the
33
+ * project's own declared domains sit in and no others, so an account holding a customer's zone beside
34
+ * this project's is not one compromise away from both. De-duped, because two environments and two
35
+ * Workers routinely share one zone.
36
+ */
37
+ export declare function zoneResources(zoneIds: readonly string[]): Record<string, string>;
26
38
  /**
27
39
  * A permission group available to account-owned tokens, as returned by the permission-groups list.
28
40
  * Only the id and name matter for resolution — the manager maps a requested name to its id.
@@ -41,26 +53,60 @@ export declare const MintedAccountToken: z.ZodObject<{
41
53
  id: z.ZodString;
42
54
  value: z.ZodString;
43
55
  name: z.ZodOptional<z.ZodString>;
44
- status: z.ZodOptional<z.ZodEnum<{
56
+ status: z.ZodPreprocess<z.ZodOptional<z.ZodEnum<{
45
57
  active: "active";
46
58
  disabled: "disabled";
47
59
  expired: "expired";
48
- }>>;
60
+ }>>>;
49
61
  }, z.core.$strip>;
50
62
  export type MintedAccountToken = z.output<typeof MintedAccountToken>;
63
+ /**
64
+ * One access policy on an existing token, as the token list returns it.
65
+ *
66
+ * **Every field is `nullish`, and that is load-bearing rather than defensive.** `listTokens` decodes
67
+ * each entry with `safeParse` and drops what fails, so a single `null` where this expected a value
68
+ * makes a live token invisible to `findTokenByName` — and the next roll mints a *second* credential of
69
+ * the same name beside the first, with nothing saying so (#651).
70
+ */
71
+ export declare const AccountTokenPolicy: z.ZodObject<{
72
+ effect: z.ZodPreprocess<z.ZodOptional<z.ZodEnum<{
73
+ allow: "allow";
74
+ deny: "deny";
75
+ }>>>;
76
+ permission_groups: z.ZodPreprocess<z.ZodOptional<z.ZodArray<z.ZodObject<{
77
+ id: z.ZodString;
78
+ }, z.core.$strip>>>>;
79
+ resources: z.ZodRecord<z.ZodString, z.ZodUnknown>;
80
+ }, z.core.$strip>;
81
+ export type AccountTokenPolicy = z.output<typeof AccountTokenPolicy>;
51
82
  /**
52
83
  * An existing account-owned token's metadata, as returned by list/get. No `value` — Cloudflare never
53
- * returns a token's secret after creation — so this is only enough to find a token by name and
54
- * address it for deletion.
84
+ * returns a token's secret after creation — so this is only enough to find a token by name, report on
85
+ * it, and address it for deletion.
86
+ *
87
+ * **It reads what it uses and nothing more.** It briefly carried `expires_on`, `not_before` and
88
+ * `condition` so a re-scoping mint could resend them; that mint is gone (see {@link
89
+ * CloudflareAccountTokensManager.rollToken}), and reading a field only to hand it straight back is a
90
+ * field that can be read wrong.
55
91
  */
56
92
  export declare const AccountTokenSummary: z.ZodObject<{
57
93
  id: z.ZodString;
58
94
  name: z.ZodString;
59
- status: z.ZodOptional<z.ZodEnum<{
95
+ status: z.ZodPreprocess<z.ZodOptional<z.ZodEnum<{
60
96
  active: "active";
61
97
  disabled: "disabled";
62
98
  expired: "expired";
63
- }>>;
99
+ }>>>;
100
+ policies: z.ZodCatch<z.ZodPreprocess<z.ZodOptional<z.ZodArray<z.ZodObject<{
101
+ effect: z.ZodPreprocess<z.ZodOptional<z.ZodEnum<{
102
+ allow: "allow";
103
+ deny: "deny";
104
+ }>>>;
105
+ permission_groups: z.ZodPreprocess<z.ZodOptional<z.ZodArray<z.ZodObject<{
106
+ id: z.ZodString;
107
+ }, z.core.$strip>>>>;
108
+ resources: z.ZodRecord<z.ZodString, z.ZodUnknown>;
109
+ }, z.core.$strip>>>>>;
64
110
  }, z.core.$strip>;
65
111
  export type AccountTokenSummary = z.output<typeof AccountTokenSummary>;
66
112
  /**
@@ -153,6 +199,18 @@ export declare class CloudflareAccountTokensManager extends CloudflareManager {
153
199
  * Either way the caller gets a usable secret to store — the idempotent path for a credential that
154
200
  * lives in the Secrets Store, since Cloudflare never returns a token's existing secret. Mirrors the
155
201
  * dashboard's roll-or-create.
202
+ *
203
+ * **A roll never changes the policy set, and that is a decision rather than an omission (#651).** It
204
+ * briefly did: `pithy token list` tells an adopter their CI token predates zone-scoped routes, and
205
+ * re-scoping here made the remedy one command. But Cloudflare's token update is a full
206
+ * representation, so doing it meant every mint had to resend the whole token correctly — `condition`,
207
+ * `expires_on`, `not_before`, `status` — and three review rounds each found a new way that went
208
+ * wrong: a cleared IP allowlist, a re-enabled disabled token, a `null` that hid the token and minted a
209
+ * duplicate beside it.
210
+ *
211
+ * {@link mintToken} plus a delete is what actually re-scopes, and `pithy token rotate` has always been
212
+ * exactly that. So the remedy names rotate, and this stays the one thing it can be relied on to be:
213
+ * a new secret on the same token, with nothing else touched.
156
214
  */
157
215
  rollToken(name: string, permissions: TokenPermission[]): Promise<MintedAccountToken>;
158
216
  }
@@ -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":"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;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAIhF;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;;;;;;;;;iBAY6D,CAAC;AAC7F,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAErE;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB;;;;;;;;;iBAwBkF,CAAC;AAClH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAErE;;;;;;;;;GASG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;iBAuBuD,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;;;;;;;;;;;;;;;;;;OAkBG;IACG,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,eAAe,EAAE,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAKzF;CACF"}
@@ -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));
@@ -41,6 +41,12 @@ export declare const WorkflowInstance: z.ZodObject<{
41
41
  steps: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
42
42
  }, z.core.$strip>;
43
43
  export type WorkflowInstance = z.output<typeof WorkflowInstance>;
44
+ /** One deployed Workflow definition — its name and the script that hosts it — from the account's list. */
45
+ export declare const WorkflowDefinition: z.ZodObject<{
46
+ name: z.ZodString;
47
+ script_name: z.ZodString;
48
+ }, z.core.$strip>;
49
+ export type WorkflowDefinition = z.output<typeof WorkflowDefinition>;
44
50
  /**
45
51
  * A client over the Cloudflare Workflows API — the only sanctioned way to reach CF Workflows from
46
52
  * outside a Worker (CLAUDE.md: no hand-rolled CF `fetch` outside `@pithy-sh/cloudflare`). The
@@ -58,6 +64,17 @@ export declare class CloudflareWorkflowsClient extends CloudflareManager {
58
64
  getServiceType(): string;
59
65
  /** Prove reach by listing the account's Workflows; never throws. */
60
66
  validateServiceAccess(): Promise<boolean>;
67
+ /**
68
+ * Every Workflow definition on the account, validated at the wire boundary. What `pithy feature destroy` reads
69
+ * to find the Workflows a feature's scripts host, by exact script name (#643).
70
+ */
71
+ listWorkflows(): Promise<WorkflowDefinition[]>;
72
+ /**
73
+ * Delete a Workflow definition by name, resolving `false` when there was none (#643). Cloudflare's own
74
+ * reference says this deletes the Workflow and leaves the Worker script alone; whether deleting the script
75
+ * deletes its Workflows it does not say, so teardown deletes them explicitly rather than rely on either.
76
+ */
77
+ deleteWorkflow(workflowName: string): Promise<boolean>;
61
78
  /** Create (trigger) a Workflow instance and return its id. */
62
79
  createInstance(workflowName: string, params: unknown, instanceId?: string): Promise<string>;
63
80
  /** Fetch an instance's status. Returns `null` on a 404 (a just-created instance can briefly lag). */
@@ -1 +1 @@
1
- {"version":3,"file":"workflowsClient.d.ts","sourceRoot":"","sources":["../../src/workflows/workflowsClient.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,KAAK,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAGpF,gGAAgG;AAChG,MAAM,MAAM,OAAO,GAAG,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAEpD,MAAM,WAAW,+BAAgC,SAAQ,uBAAuB;IAC9E,wEAAwE;IACxE,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,eAAO,MAAM,sBAAsB;;;;;;;;;;;EAawD,CAAC;AAC5F,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAE7E,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;iBAqBuE,CAAC;AACrG,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAOjE;;;;;;;;;;GAUG;AACH,qBAAa,yBAA0B,SAAQ,iBAAiB;;IAG9D,YAAY,MAAM,EAAE,+BAA+B,EAGlD;IAED,cAAc,IAAI,MAAM,CAEvB;IAED,oEAAoE;IAC9D,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED,8DAA8D;IACxD,cAAc,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAYhG;IAED,qGAAqG;IAC/F,iBAAiB,CAAC,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAoBlG;IAED;;;;;;;;;;;;;;;;;OAiBG;IACG,eAAe,CACnB,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,OAAO,EACf,OAAO,GAAE;QAAE,cAAc,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAO,GAChF,OAAO,CAAC,OAAO,CAAC,CAmClB;CACF"}
1
+ {"version":3,"file":"workflowsClient.d.ts","sourceRoot":"","sources":["../../src/workflows/workflowsClient.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,KAAK,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAGpF,gGAAgG;AAChG,MAAM,MAAM,OAAO,GAAG,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAEpD,MAAM,WAAW,+BAAgC,SAAQ,uBAAuB;IAC9E,wEAAwE;IACxE,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,eAAO,MAAM,sBAAsB;;;;;;;;;;;EAawD,CAAC;AAC5F,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAE7E,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;iBAqBuE,CAAC;AACrG,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEjE,0GAA0G;AAC1G,eAAO,MAAM,kBAAkB;;;iBAKuF,CAAC;AACvH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAOrE;;;;;;;;;;GAUG;AACH,qBAAa,yBAA0B,SAAQ,iBAAiB;;IAG9D,YAAY,MAAM,EAAE,+BAA+B,EAGlD;IAED,cAAc,IAAI,MAAM,CAEvB;IAED,oEAAoE;IAC9D,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED;;;OAGG;IACG,aAAa,IAAI,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAUnD;IAED;;;;OAIG;IACG,cAAc,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAU3D;IAED,8DAA8D;IACxD,cAAc,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAYhG;IAED,qGAAqG;IAC/F,iBAAiB,CAAC,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAoBlG;IAED;;;;;;;;;;;;;;;;;OAiBG;IACG,eAAe,CACnB,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,OAAO,EACf,OAAO,GAAE;QAAE,cAAc,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAO,GAChF,OAAO,CAAC,OAAO,CAAC,CAmClB;CACF"}
@@ -28,6 +28,11 @@ const WorkflowInstance = z.object({
28
28
  ]).optional().describe("The instance-level failure, present when `status` is `errored` or `terminated`."),
29
29
  steps: z.array(z.unknown()).optional().describe("The instance's steps, each validated where it is read (`stepFailure`). Held as `unknown` here on purpose: the API has four step shapes, this client reads one field of one of them, and a shape nobody anticipated must not turn a failed Workflow into a parse error about the failure report.")
30
30
  }).describe("A Cloudflare Workflow instance's status, as returned by the instance-detail endpoint.");
31
+ /** One deployed Workflow definition — its name and the script that hosts it — from the account's list. */
32
+ const WorkflowDefinition = z.object({
33
+ name: z.string().describe("The Workflow's account-wide name, e.g. `acme-prod-email-send`."),
34
+ script_name: z.string().describe("The Worker script whose exported class runs the Workflow.")
35
+ }).describe("A Workflow definition on the account, as far as teardown needs one: what it is called and who hosts it.");
31
36
  /** Terminal states a poll stops on. */
32
37
  const TERMINAL = /* @__PURE__ */ new Set([
33
38
  "complete",
@@ -64,6 +69,37 @@ var CloudflareWorkflowsClient = class extends CloudflareManager {
64
69
  return false;
65
70
  }
66
71
  }
72
+ /**
73
+ * Every Workflow definition on the account, validated at the wire boundary. What `pithy feature destroy` reads
74
+ * to find the Workflows a feature's scripts host, by exact script name (#643).
75
+ */
76
+ async listWorkflows() {
77
+ return cloudflareRequest("list Workflows", async () => {
78
+ const out = [];
79
+ for await (const workflow of this.getClient().workflows.list({ account_id: this.accountId })) {
80
+ const parsed = WorkflowDefinition.safeParse(workflow);
81
+ if (!parsed.success) throw new CloudflareInvalidResponseError({ detail: "list Workflows: unexpected shape" });
82
+ out.push(parsed.data);
83
+ }
84
+ return out;
85
+ });
86
+ }
87
+ /**
88
+ * Delete a Workflow definition by name, resolving `false` when there was none (#643). Cloudflare's own
89
+ * reference says this deletes the Workflow and leaves the Worker script alone; whether deleting the script
90
+ * deletes its Workflows it does not say, so teardown deletes them explicitly rather than rely on either.
91
+ */
92
+ async deleteWorkflow(workflowName) {
93
+ return cloudflareRequest(`delete Workflow ${workflowName}`, async () => {
94
+ try {
95
+ await this.getClient().workflows.delete(workflowName, { account_id: this.accountId });
96
+ return true;
97
+ } catch (error) {
98
+ if (isNotFoundError(error)) return false;
99
+ throw error;
100
+ }
101
+ });
102
+ }
67
103
  /** Create (trigger) a Workflow instance and return its id. */
68
104
  async createInstance(workflowName, params, instanceId) {
69
105
  return cloudflareRequest(`create instance of ${workflowName}`, async () => {
@@ -155,4 +191,4 @@ function describeFailure(failure, error) {
155
191
  return `${failure.step === void 0 ? "step" : `step '${failure.step}'`} raised${failure.code === void 0 ? "" : ` [${failure.code}]`} ${failure.raw} — instance error: ${instance}`;
156
192
  }
157
193
  //#endregion
158
- export { CloudflareWorkflowsClient, WorkflowInstance, WorkflowInstanceStatus };
194
+ export { CloudflareWorkflowsClient, WorkflowDefinition, WorkflowInstance, WorkflowInstanceStatus };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pithy-sh/cloudflare",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,7 @@
40
40
  "js-base64": "^3.9.2"
41
41
  },
42
42
  "peerDependencies": {
43
- "@pithy-sh/core": "^0.7.0",
43
+ "@pithy-sh/core": "^0.7.4",
44
44
  "zod": "^4.4.0"
45
45
  },
46
46
  "devDependencies": {