@pithy-sh/cloudflare 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +57 -6
- package/dist/secrets/secretsStoreManager.d.ts.map +1 -1
- package/dist/secrets/secretsStoreManager.js +113 -18
- 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/package.json +2 -2
- package/src/client/errors.ts +78 -14
- package/src/secrets/secretsStoreManager.ts +126 -18
- 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
|
@@ -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));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pithy-sh/cloudflare",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"js-base64": "^3.9.2"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"@pithy-sh/core": "^0.
|
|
43
|
+
"@pithy-sh/core": "^0.8.0",
|
|
44
44
|
"zod": "^4.4.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
package/src/client/errors.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
-
import { PithyError, UpstreamTimeoutError } from "@pithy-sh/core/src/error/pithyError";
|
|
4
|
+
import { PithyError, UpstreamError, UpstreamTimeoutError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
5
|
import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
|
|
6
6
|
import { MAX_WORKFLOW_STEP_TEXT } from "@pithy-sh/core/src/workflow/stepMessage";
|
|
7
7
|
import type { z } from "zod";
|
|
@@ -196,7 +196,7 @@ const ACCOUNT_PERMISSIONS: Record<string, string> = {
|
|
|
196
196
|
};
|
|
197
197
|
|
|
198
198
|
/**
|
|
199
|
-
* Run a Cloudflare SDK call and turn any failure into a
|
|
199
|
+
* Run a Cloudflare SDK call and turn any failure into a typed refusal, preserving the
|
|
200
200
|
* original error as `cause` and its message as internal `detail`. `operation` names the call for
|
|
201
201
|
* the audit trail (`"KV get for key 'x'"`). A `PithyError` already in flight (e.g. a
|
|
202
202
|
* not-configured guard) passes through untouched — only foreign throws get wrapped.
|
|
@@ -267,8 +267,11 @@ export async function cloudflareRequest<T>(
|
|
|
267
267
|
* translating client can render them under the error code. "Add this grant, then re-run" names a
|
|
268
268
|
* console the operator is sitting in front of, so it is an `action`. The raw body stays `detail`.
|
|
269
269
|
*
|
|
270
|
-
* The code
|
|
271
|
-
*
|
|
270
|
+
* **The code is keyed on the status, because a retry policy reads it and nothing else can.** See
|
|
271
|
+
* {@link isTransientStatus}. A refusal Cloudflare answered with a 4xx stays `cloudflare/request_failed`
|
|
272
|
+
* at 502 — a dedicated sibling of `core/upstream_failed`, already the upstream class CLAUDE.md asks
|
|
273
|
+
* for, and pinned by `core`'s payload test. The words are composed the same way either side of that
|
|
274
|
+
* branch, so only the two machine-readable fields differ.
|
|
272
275
|
*
|
|
273
276
|
* **The message is one line, and that is a kit-wide rule this had to learn the hard way.** Round one
|
|
274
277
|
* of #534 made it a small document — problem line, `errors[]` indented beneath, action last — which
|
|
@@ -299,20 +302,81 @@ export function cloudflareRefusal(args: {
|
|
|
299
302
|
detail: string;
|
|
300
303
|
/** The original throw. */
|
|
301
304
|
cause?: unknown;
|
|
302
|
-
}):
|
|
305
|
+
}): PithyError {
|
|
303
306
|
// Sliced here for `permissionAction`; `cloudflareSaid` bounds what it is given for itself, so a call
|
|
304
307
|
// site reaching it directly cannot exceed the same limit by arriving from somewhere else.
|
|
305
308
|
const shown = args.apiErrors.slice(0, MAX_API_ERRORS);
|
|
306
309
|
const said = cloudflareSaid(args.problem, shown);
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
{ cause: args.cause }
|
|
315
|
-
|
|
310
|
+
const refusal = {
|
|
311
|
+
message: said.message,
|
|
312
|
+
action: permissionAction(shown, args.status, args.permission),
|
|
313
|
+
params: said.params,
|
|
314
|
+
detail: args.detail,
|
|
315
|
+
};
|
|
316
|
+
if (isTransientStatus(args.status) || isConnectionFailure(args.cause)) {
|
|
317
|
+
return new UpstreamError(refusal, { cause: args.cause });
|
|
318
|
+
}
|
|
319
|
+
return new CloudflareRequestError(refusal, { cause: args.cause });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Whether the status Cloudflare answered under means "ask again" rather than "this request is wrong".
|
|
324
|
+
*
|
|
325
|
+
* **The classification lives here because here is where the status is still in hand.** Everything a
|
|
326
|
+
* refusal crosses afterwards — a durable step record, a `--json` line, a retry policy — sees the
|
|
327
|
+
* `code` and nothing else, so a fault that is worth retrying has to say so in the code at the moment
|
|
328
|
+
* it is raised. `@pithy-sh/secrets`' `secretsWorkflowRetry` lists `core/upstream_failed` and
|
|
329
|
+
* `core/upstream_timeout` and treats everything absent as terminal; with every non-timeout failure
|
|
330
|
+
* arriving as `cloudflare/request_failed`, a 503 on the at-rest master-key write ended the rotation
|
|
331
|
+
* instance permanently, in the one state where ending it is expensive.
|
|
332
|
+
*
|
|
333
|
+
* **It is keyed here and not relaxed in the policy, and the difference is a revoked token.** Making
|
|
334
|
+
* the whole `cloudflare/*` family retryable would loop a 403 forever at whatever cadence the Workflow
|
|
335
|
+
* sleeps on, and nothing would ever say why. 4xx is Cloudflare answering and refusing — the same call
|
|
336
|
+
* refuses again — so it stays terminal, and CLAUDE.md §Errors is what says the other half belongs to
|
|
337
|
+
* the upstream pair: a failure in something we do not control is never a code that says we broke.
|
|
338
|
+
*
|
|
339
|
+
* 5xx is every server-side status, Cloudflare's own `52x` family included; `429` is rate limiting,
|
|
340
|
+
* which is the transient failure by definition. The timeout statuses never reach here — `isTimeout`
|
|
341
|
+
* claims 408, 504 and 524 first, and they are the 504 half of the same pair.
|
|
342
|
+
*
|
|
343
|
+
* **A statusless failure is not covered here, and deliberately so (#647 review).** A connection that reset
|
|
344
|
+
* mid-write is genuinely transient and ending a rotation on one is expensive — but "carries no status" is
|
|
345
|
+
* far wider than "the network faltered". A `TypeError` raised in our own request builder carries no status
|
|
346
|
+
* either, and a predicate that called it transient would retry a code defect on the Workflow's own cadence,
|
|
347
|
+
* forever, silently. `errors.test.ts` pins that: a throw that never reached Cloudflare stays terminal.
|
|
348
|
+
* {@link isConnectionFailure} names the narrow case instead, the way {@link statesTimeout} already does.
|
|
349
|
+
*/
|
|
350
|
+
function isTransientStatus(status: number | undefined): boolean {
|
|
351
|
+
return status !== undefined && (status === 429 || status >= 500);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The producer names for "the connection did not hold", as distinct from "out of time" and from a defect
|
|
356
|
+
* in our own code.
|
|
357
|
+
*
|
|
358
|
+
* Duck-typed by name and `code`, for the reason {@link TIMEOUT_NAMES} gives: the SDK is one producer and
|
|
359
|
+
* `fetch` is another, and an `instanceof` across two copies of the SDK answers false.
|
|
360
|
+
*/
|
|
361
|
+
const CONNECTION_NAMES = new Set(["APIConnectionError", "FetchError"]);
|
|
362
|
+
const CONNECTION_CODES = new Set(["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "EPIPE", "UND_ERR_SOCKET"]);
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Whether a failure is a connection that did not hold — a reset, a refused socket, a name that would not
|
|
366
|
+
* resolve. Cloudflare never answered, so nothing was refused, and the next attempt may well succeed.
|
|
367
|
+
*
|
|
368
|
+
* Narrow on purpose. This is the half of "no status" that is genuinely worth another attempt; everything
|
|
369
|
+
* else with no status stays terminal, so a defect in our own code still stops rather than looping.
|
|
370
|
+
*/
|
|
371
|
+
function isConnectionFailure(error: unknown): boolean {
|
|
372
|
+
let current: unknown = error;
|
|
373
|
+
for (let depth = 0; depth < 5 && current !== null && typeof current === "object"; depth++) {
|
|
374
|
+
const { name, code } = current as { name?: unknown; code?: unknown };
|
|
375
|
+
if (typeof name === "string" && CONNECTION_NAMES.has(name)) return true;
|
|
376
|
+
if (typeof code === "string" && CONNECTION_CODES.has(code)) return true;
|
|
377
|
+
current = (current as { cause?: unknown }).cause;
|
|
378
|
+
}
|
|
379
|
+
return false;
|
|
316
380
|
}
|
|
317
381
|
|
|
318
382
|
/**
|