@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.
@@ -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 { isPermissionKey, type PermissionKey, resolvePermissionKeys } from "./permissions";
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 `CloudflareRequestError` carries the given CF error code. Reads the `[cf-codes:…]`
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 CloudflareRequestError)) return false;
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 () => {