@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.
- package/dist/client/errors.d.ts +7 -4
- package/dist/client/errors.d.ts.map +1 -1
- package/dist/client/errors.js +75 -6
- package/dist/secrets/secretsStoreManager.d.ts +78 -2
- package/dist/secrets/secretsStoreManager.d.ts.map +1 -1
- package/dist/secrets/secretsStoreManager.js +159 -16
- package/dist/test-utils/fixtures.d.ts +7 -0
- package/dist/test-utils/fixtures.d.ts.map +1 -1
- package/dist/test-utils/fixtures.js +7 -0
- package/dist/tokens/accountTokensManager.d.ts +64 -6
- package/dist/tokens/accountTokensManager.d.ts.map +1 -1
- package/dist/tokens/accountTokensManager.js +85 -8
- package/dist/tokens/permissions.d.ts +13 -0
- package/dist/tokens/permissions.d.ts.map +1 -1
- package/dist/tokens/permissions.js +17 -2
- package/dist/tokens/profiles.d.ts +15 -0
- package/dist/tokens/profiles.d.ts.map +1 -1
- package/dist/tokens/profiles.js +44 -3
- package/dist/workers/buildsManager.js +11 -3
- package/dist/workflows/workflowsClient.d.ts +17 -0
- package/dist/workflows/workflowsClient.d.ts.map +1 -1
- package/dist/workflows/workflowsClient.js +37 -1
- package/package.json +2 -2
- package/src/client/errors.ts +78 -14
- package/src/secrets/secretsStoreManager.ts +180 -12
- package/src/test-utils/fixtures.ts +7 -0
- package/src/tokens/accountTokensManager.ts +132 -5
- package/src/tokens/permissions.ts +29 -0
- package/src/tokens/profiles.ts +49 -2
- package/src/workers/buildsManager.ts +10 -3
- package/src/workflows/workflowsClient.ts +42 -0
|
@@ -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
|
|
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;;;;;;;;;
|
|
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
|
|
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:
|
|
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;
|
|
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,
|
|
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"}
|
package/dist/tokens/profiles.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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;
|
|
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
|
+
"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.
|
|
43
|
+
"@pithy-sh/core": "^0.7.4",
|
|
44
44
|
"zod": "^4.4.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|