@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
package/src/tokens/profiles.ts
CHANGED
|
@@ -3,8 +3,14 @@
|
|
|
3
3
|
|
|
4
4
|
import type { Capability, TokenProfileSeam } from "@pithy-sh/core/src/capability/capability";
|
|
5
5
|
import { CloudflareNotConfiguredError } from "../client/errors";
|
|
6
|
-
import { accountResource, type TokenPermission } from "./accountTokensManager";
|
|
7
|
-
import {
|
|
6
|
+
import { accountResource, type TokenPermission, zoneResources } from "./accountTokensManager";
|
|
7
|
+
import {
|
|
8
|
+
isPermissionKey,
|
|
9
|
+
isZoneScopedPermission,
|
|
10
|
+
PERMISSION_GROUPS,
|
|
11
|
+
type PermissionKey,
|
|
12
|
+
resolvePermissionKeys,
|
|
13
|
+
} from "./permissions";
|
|
8
14
|
|
|
9
15
|
/**
|
|
10
16
|
* Where a minted token's value is written.
|
|
@@ -221,17 +227,58 @@ export function resolveProfile(
|
|
|
221
227
|
};
|
|
222
228
|
}
|
|
223
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Refuse a zone-scoped key anywhere a policy is being built **account-scoped**.
|
|
232
|
+
*
|
|
233
|
+
* A zone-level permission group handed the account resource is the quiet failure (#651): Cloudflare
|
|
234
|
+
* mints the token without complaint and then answers "No access to the specified resource" at the call,
|
|
235
|
+
* which is a CI failure hours later with nothing in it naming the scope. Which zones a token gets is
|
|
236
|
+
* never a typed choice — it is the project's declared `domains`, resolved at mint time — so a key naming
|
|
237
|
+
* one in a profile or behind `--permission` is a mistake, and this is where it is caught.
|
|
238
|
+
*/
|
|
239
|
+
function assertAccountScoped(keys: readonly string[], where: string): void {
|
|
240
|
+
const zoneScoped = keys.filter(isZoneScopedPermission);
|
|
241
|
+
if (zoneScoped.length === 0) return;
|
|
242
|
+
throw new CloudflareNotConfiguredError({
|
|
243
|
+
message: `${where} names zone-scoped permission(s): ${zoneScoped.join(", ")}.`,
|
|
244
|
+
action:
|
|
245
|
+
"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.",
|
|
246
|
+
detail: `build token policy: ${zoneScoped.join(", ")} cannot take the account resource`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
224
250
|
/** Build the account-scoped {@link TokenPermission}s for a set of permission keys — the mint input. */
|
|
225
251
|
export function permissionsForKeys(keys: PermissionKey[], accountId: string): TokenPermission[] {
|
|
252
|
+
assertAccountScoped(keys, "This token");
|
|
226
253
|
return [{ permissionGroupNames: resolvePermissionKeys(keys), resources: accountResource(accountId) }];
|
|
227
254
|
}
|
|
228
255
|
|
|
256
|
+
/**
|
|
257
|
+
* The **zone-scoped** policy a project's declared domains require: Workers Routes Write on exactly those
|
|
258
|
+
* zones, and nothing else.
|
|
259
|
+
*
|
|
260
|
+
* This is the second policy on the `ci-system` token, beside the account-scoped one every profile
|
|
261
|
+
* builds. It exists because `pithy deploy` of an environment with a declared domain calls
|
|
262
|
+
* `POST /zones/<zone>/workers/routes`, the one deploy step the CI credential could not make (#651) — and
|
|
263
|
+
* it is a policy of its own rather than a permission on the profile because its resources are zones,
|
|
264
|
+
* which a profile cannot name: they are resolved from the domains at mint time.
|
|
265
|
+
*
|
|
266
|
+
* **Empty for an empty zone list, deliberately.** A project that declares no domain attaches no route,
|
|
267
|
+
* so it mints exactly the token it minted before this existed. No new permission for a project that
|
|
268
|
+
* needs none.
|
|
269
|
+
*/
|
|
270
|
+
export function routePermissions(zoneIds: readonly string[]): TokenPermission[] {
|
|
271
|
+
if (zoneIds.length === 0) return [];
|
|
272
|
+
return [{ permissionGroupNames: [...PERMISSION_GROUPS["routes:write"]], resources: zoneResources(zoneIds) }];
|
|
273
|
+
}
|
|
274
|
+
|
|
229
275
|
/**
|
|
230
276
|
* Turn a resolved profile into the {@link TokenPermission}s the account-tokens manager mints from: the
|
|
231
277
|
* profile's permission keys resolved to CF group names, scoped to the account (or the profile's
|
|
232
278
|
* explicit resources). One policy per profile — a token carries exactly the access its profile declares.
|
|
233
279
|
*/
|
|
234
280
|
export function profilePermissions(profile: TokenProfile, accountId: string): TokenPermission[] {
|
|
281
|
+
assertAccountScoped(profile.permissions, `Token profile "${profile.name}"`);
|
|
235
282
|
const resources =
|
|
236
283
|
profile.resources && profile.resources !== "account" ? profile.resources : accountResource(accountId);
|
|
237
284
|
return [{ permissionGroupNames: resolvePermissionKeys(profile.permissions), resources }];
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
+
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import {
|
|
6
7
|
CloudflareInvalidResponseError,
|
|
7
|
-
CloudflareRequestError,
|
|
8
8
|
cloudflareApiErrors,
|
|
9
9
|
cloudflareRefusal,
|
|
10
10
|
cloudflareRequest,
|
|
@@ -350,12 +350,19 @@ export class CloudflareBuildsManager extends CloudflareManager {
|
|
|
350
350
|
}
|
|
351
351
|
|
|
352
352
|
/**
|
|
353
|
-
* Whether a thrown
|
|
353
|
+
* Whether a thrown Cloudflare refusal carries the given CF error code. Reads the `[cf-codes:…]`
|
|
354
354
|
* marker `call()` pins to the front of `detail` (stable across the 2000-char truncation); falls back
|
|
355
355
|
* to a substring scan for errors raised without the structured marker.
|
|
356
|
+
*
|
|
357
|
+
* **Narrowed on `PithyError`, not on one subclass of it (#647).** This read used to require a
|
|
358
|
+
* `CloudflareRequestError`, which was true of every refusal for as long as `cloudflareRefusal` composed
|
|
359
|
+
* exactly one class. It no longer does: a 429 or a 5xx is now an `UpstreamError`, so that narrowing
|
|
360
|
+
* would have silently stopped seeing a CF error code the moment Cloudflare answered one under a status
|
|
361
|
+
* that retries. The code lives in `detail`, which every `PithyError` carries, so the payload is what to
|
|
362
|
+
* read and the class is not.
|
|
356
363
|
*/
|
|
357
364
|
function hasErrorCode(error: unknown, code: number): boolean {
|
|
358
|
-
if (!(error instanceof
|
|
365
|
+
if (!(error instanceof PithyError)) return false;
|
|
359
366
|
const detail = error.payload.detail ?? "";
|
|
360
367
|
const marker = detail.match(/^\[cf-codes:([\d,]+)\]/);
|
|
361
368
|
if (marker?.[1]) return marker[1].split(",").includes(String(code));
|
|
@@ -55,6 +55,15 @@ export const WorkflowInstance = z
|
|
|
55
55
|
.describe("A Cloudflare Workflow instance's status, as returned by the instance-detail endpoint.");
|
|
56
56
|
export type WorkflowInstance = z.output<typeof WorkflowInstance>;
|
|
57
57
|
|
|
58
|
+
/** One deployed Workflow definition — its name and the script that hosts it — from the account's list. */
|
|
59
|
+
export const WorkflowDefinition = z
|
|
60
|
+
.object({
|
|
61
|
+
name: z.string().describe("The Workflow's account-wide name, e.g. `acme-prod-email-send`."),
|
|
62
|
+
script_name: z.string().describe("The Worker script whose exported class runs the Workflow."),
|
|
63
|
+
})
|
|
64
|
+
.describe("A Workflow definition on the account, as far as teardown needs one: what it is called and who hosts it.");
|
|
65
|
+
export type WorkflowDefinition = z.output<typeof WorkflowDefinition>;
|
|
66
|
+
|
|
58
67
|
/** Terminal states a poll stops on. */
|
|
59
68
|
const TERMINAL: ReadonlySet<WorkflowInstanceStatus> = new Set(["complete", "errored", "terminated"]);
|
|
60
69
|
|
|
@@ -93,6 +102,39 @@ export class CloudflareWorkflowsClient extends CloudflareManager {
|
|
|
93
102
|
}
|
|
94
103
|
}
|
|
95
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Every Workflow definition on the account, validated at the wire boundary. What `pithy feature destroy` reads
|
|
107
|
+
* to find the Workflows a feature's scripts host, by exact script name (#643).
|
|
108
|
+
*/
|
|
109
|
+
async listWorkflows(): Promise<WorkflowDefinition[]> {
|
|
110
|
+
return cloudflareRequest("list Workflows", async () => {
|
|
111
|
+
const out: WorkflowDefinition[] = [];
|
|
112
|
+
for await (const workflow of this.getClient().workflows.list({ account_id: this.accountId })) {
|
|
113
|
+
const parsed = WorkflowDefinition.safeParse(workflow);
|
|
114
|
+
if (!parsed.success) throw new CloudflareInvalidResponseError({ detail: "list Workflows: unexpected shape" });
|
|
115
|
+
out.push(parsed.data);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Delete a Workflow definition by name, resolving `false` when there was none (#643). Cloudflare's own
|
|
123
|
+
* reference says this deletes the Workflow and leaves the Worker script alone; whether deleting the script
|
|
124
|
+
* deletes its Workflows it does not say, so teardown deletes them explicitly rather than rely on either.
|
|
125
|
+
*/
|
|
126
|
+
async deleteWorkflow(workflowName: string): Promise<boolean> {
|
|
127
|
+
return cloudflareRequest(`delete Workflow ${workflowName}`, async () => {
|
|
128
|
+
try {
|
|
129
|
+
await this.getClient().workflows.delete(workflowName, { account_id: this.accountId });
|
|
130
|
+
return true;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (isNotFoundError(error)) return false;
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
96
138
|
/** Create (trigger) a Workflow instance and return its id. */
|
|
97
139
|
async createInstance(workflowName: string, params: unknown, instanceId?: string): Promise<string> {
|
|
98
140
|
return cloudflareRequest(`create instance of ${workflowName}`, async () => {
|