@pithy-sh/cloudflare 0.1.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -0
  3. package/package.json +48 -0
  4. package/src/ai/aiManager.ts +227 -0
  5. package/src/ai/vectorizeManager.ts +161 -0
  6. package/src/ai/vectorizeProvisioner.ts +266 -0
  7. package/src/client/accounts.ts +80 -0
  8. package/src/client/clients.ts +244 -0
  9. package/src/client/errors.ts +143 -0
  10. package/src/client/manager.ts +85 -0
  11. package/src/d1/d1Manager.ts +171 -0
  12. package/src/d1/d1PreparedStatement.ts +114 -0
  13. package/src/d1/d1Provisioner.ts +75 -0
  14. package/src/email/emailRoutingManager.ts +143 -0
  15. package/src/email/emailSendManager.ts +81 -0
  16. package/src/env/devVars.ts +90 -0
  17. package/src/hostnames/customHostnamesManager.ts +134 -0
  18. package/src/kv/kvManager.ts +202 -0
  19. package/src/kv/kvProvisioner.ts +80 -0
  20. package/src/media/assetSeeder.ts +87 -0
  21. package/src/media/imageManager.ts +125 -0
  22. package/src/media/ownership.ts +59 -0
  23. package/src/media/streamManager.ts +198 -0
  24. package/src/queue/queueManager.ts +185 -0
  25. package/src/r2/r2Credentials.ts +17 -0
  26. package/src/r2/r2Manager.ts +548 -0
  27. package/src/r2/r2Provisioner.ts +99 -0
  28. package/src/secrets/secretsStoreManager.ts +177 -0
  29. package/src/secrets/secretsStores.ts +75 -0
  30. package/src/test-utils/emailRoutingRules.ts +122 -0
  31. package/src/test-utils/fixtureReportSetup.ts +31 -0
  32. package/src/test-utils/fixtures.ts +372 -0
  33. package/src/test-utils/harness.ts +413 -0
  34. package/src/test-utils/inboundRecorder.ts +189 -0
  35. package/src/test-utils/integrationSetup.ts +46 -0
  36. package/src/test-utils/reap.ts +297 -0
  37. package/src/tokens/accountTokensManager.ts +334 -0
  38. package/src/tokens/permissions.ts +67 -0
  39. package/src/tokens/profiles.ts +238 -0
  40. package/src/turnstile/turnstileManager.ts +177 -0
  41. package/src/user/userManager.ts +73 -0
  42. package/src/workers/buildsManager.ts +348 -0
  43. package/src/workers/buildsTypes.ts +122 -0
  44. package/src/workers/workersBuildEvent.ts +48 -0
  45. package/src/workers/workersManager.ts +423 -0
  46. package/src/workers/workersProvisioner.ts +167 -0
  47. package/src/workflows/stepFailure.ts +280 -0
  48. package/src/workflows/workflowsClient.ts +213 -0
  49. package/src/zones/zonesManager.ts +92 -0
@@ -0,0 +1,297 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { Cloudflare } from "cloudflare";
5
+ import { CloudflareClients } from "../client/clients";
6
+ import { listEmailRoutingRules, namedRules } from "./emailRoutingRules";
7
+ import {
8
+ emptyTestBucket,
9
+ type IntegrationCreds,
10
+ type ReapableKind,
11
+ staleTestResourceNames,
12
+ testResourceAge,
13
+ } from "./harness";
14
+
15
+ /**
16
+ * The reap registry: every throwaway resource kind a live suite can mint, in one place, swept once per
17
+ * integration run.
18
+ *
19
+ * ## Why this exists, and why per-suite reaping was never enough
20
+ *
21
+ * {@link ReapableKind} and `reapStaleTestResources` are generic — they know how to reap *a* kind, and
22
+ * nothing about which kinds exist. So a kind was reaped only where some suite happened to hand the
23
+ * reaper a `list`/`remove` pair, and five such call sites covered the whole repo. Secrets Store
24
+ * entries, Queues, and API tokens had none at all, which is why eight `pithy-int-secret-…` entries sat
25
+ * on a real account with nothing in the repo able to reclaim them, ever.
26
+ *
27
+ * Two failures compound, and the second is the one that makes per-suite reaping structurally wrong.
28
+ *
29
+ * **A reaper registered in a suite's `beforeAll` does not run when that suite skips.** Every live suite
30
+ * is a `describe.skipIf(...)`, and Vitest runs no hooks inside a skipped suite. So the reaper is gated
31
+ * on exactly the credential whose absence lets debris accumulate: without `R2_CREDENTIALS` the R2
32
+ * suite skips, and because D1 reaping lived inside `@pithy-sh/storage`'s bundle, the **D1** reaper went
33
+ * offline with it. A reaper must never be gated on the same condition as the suite that dirties the
34
+ * account.
35
+ *
36
+ * **And reaping was per-suite, not per-run.** `test:integration --filter @pithy-sh/vector` mints
37
+ * Vectorize indexes and reaps nothing, because the only index reaper lived in `@pithy-sh/cloudflare`.
38
+ * Cross-package coverage was accidental.
39
+ *
40
+ * So the sweep moved to a Vitest `globalSetup` (see `integrationSetup.ts`), which runs once per project
41
+ * before any suite is collected and cannot be skipped by a suite's gate. A new live suite inherits
42
+ * cleanup instead of remembering to arrange it.
43
+ *
44
+ * ## What is deliberately still not reaped
45
+ *
46
+ * Resources composed under {@link RESERVED_TEST_PROJECT} — `pithy provision --feature`'s ephemeral D1 and
47
+ * KV, which a live test provisions through the *product's* namer because the names are the thing under
48
+ * test. `testResourceAge` cannot read an age out of `pithy-int-test-dev-73-slug-db`, and returns null
49
+ * rather than guessing. That conservatism is correct and stays: failing to clean up costs pennies, and
50
+ * deleting a resource a running suite still owns turns a green run red for reasons nobody can see. Those
51
+ * suites tear down in an unconditional `afterAll`; an interrupted run leaves debris that must be removed
52
+ * by hand. `CONTRIBUTING.md` says so out loud rather than implying the sweep covers everything.
53
+ */
54
+
55
+ /**
56
+ * What this run is allowed to sweep beyond the account itself.
57
+ *
58
+ * Every other kind is account-scoped: the credentials name the account, and the account holds the
59
+ * debris. An Email Routing rule is not — it lives on a **zone**, and a sweep that guessed which zone
60
+ * would be editing mail delivery on a domain nobody pointed it at. So the zone is passed in, from the
61
+ * fixture that declared it, and a run that was told nothing sweeps nothing.
62
+ */
63
+ export interface ReapScope {
64
+ /** The zone whose `pithy-int-` routing rules may be reclaimed. Absent means the kind reports itself skipped. */
65
+ emailRoutingZoneId?: string;
66
+ }
67
+
68
+ /** A kind that cannot be reaped this run, and the credential it is waiting on. */
69
+ export interface SkippedReapKind {
70
+ /** The human label, matching the kind it stands in for. */
71
+ label: string;
72
+ /** Why it was skipped — names the missing variable, so the report is actionable. */
73
+ skipped: string;
74
+ }
75
+
76
+ /** One entry in a reap plan: a kind that can be swept, or a named reason it cannot. */
77
+ export type ReapPlanEntry = (ReapableKind & { label: string }) | SkippedReapKind;
78
+
79
+ /** What one kind's sweep did. `skipped` is non-null only when the kind never ran. */
80
+ export interface ReapKindResult {
81
+ /** The kind's label, as it appears in the plan. */
82
+ label: string;
83
+ /** Names successfully removed. */
84
+ reaped: string[];
85
+ /** Names that matched but could not be removed — reported, never swallowed. */
86
+ failed: string[];
87
+ /** The reason this kind did not run, or null when it did. */
88
+ skipped: string | null;
89
+ }
90
+
91
+ /** Whether a plan entry is a stand-in for a kind that cannot run. */
92
+ function isSkipped(entry: ReapPlanEntry): entry is SkippedReapKind {
93
+ return "skipped" in entry;
94
+ }
95
+
96
+ /** Every label in a plan, in plan order — what a test asserts against so a dropped kind is visible. */
97
+ export function reapPlanLabels(plan: readonly ReapPlanEntry[]): string[] {
98
+ return plan.map((entry) => entry.label);
99
+ }
100
+
101
+ /**
102
+ * Sweep every kind in a plan and report what went.
103
+ *
104
+ * Never throws, and never lets one kind's failure end the sweep: a reaper exists to help a suite, and a
105
+ * housekeeping error that fails the run is worse than the debris it was cleaning. A listing that throws
106
+ * yields an empty result for that kind; a removal that throws lands in `failed`, where it is visible.
107
+ */
108
+ export async function reapKinds(
109
+ plan: readonly ReapPlanEntry[],
110
+ options: { now?: number; staleAfterMs?: number } = {},
111
+ ): Promise<ReapKindResult[]> {
112
+ const results: ReapKindResult[] = [];
113
+
114
+ for (const entry of plan) {
115
+ if (isSkipped(entry)) {
116
+ results.push({ label: entry.label, reaped: [], failed: [], skipped: entry.skipped });
117
+ continue;
118
+ }
119
+
120
+ const reaped: string[] = [];
121
+ const failed: string[] = [];
122
+ let names: string[];
123
+ try {
124
+ names = await entry.list();
125
+ } catch {
126
+ results.push({ label: entry.label, reaped, failed, skipped: null });
127
+ continue;
128
+ }
129
+
130
+ for (const name of staleTestResourceNames(names, options)) {
131
+ try {
132
+ await entry.remove(name);
133
+ reaped.push(name);
134
+ } catch {
135
+ failed.push(name);
136
+ }
137
+ }
138
+ results.push({ label: entry.label, reaped, failed, skipped: null });
139
+ }
140
+
141
+ return results;
142
+ }
143
+
144
+ /**
145
+ * Build the sweep plan for an account.
146
+ *
147
+ * Alphabetical by label, so the set is readable as a list rather than as an accident of construction
148
+ * order, and so a test can pin it. Every kind appears whether or not it can run — a kind that is missing
149
+ * a credential reports *why*, because "nothing to reap" and "unable to reap" look identical in a log and
150
+ * only one of them is fine.
151
+ *
152
+ * R2 is the one kind an API token cannot reclaim: Cloudflare refuses to delete a non-empty bucket, and
153
+ * emptying one is an S3-protocol operation. So it needs the key pair, and says so when it lacks it.
154
+ */
155
+ export function testResourceReapPlan(creds: IntegrationCreds, options: ReapScope = {}): ReapPlanEntry[] {
156
+ const clients = new CloudflareClients({ accountId: creds.accountId, apiToken: creds.apiToken });
157
+ const sdk = new Cloudflare({ apiToken: creds.apiToken });
158
+ const workers = clients.workers();
159
+ const tokens = clients.accountTokens();
160
+ const d1 = clients.d1Provisioner();
161
+ const kv = clients.kvProvisioner();
162
+ const vectorize = clients.vectorizeProvisioner();
163
+ const r2 = clients.r2Provisioner();
164
+
165
+ return [
166
+ {
167
+ // Minted by `accountTokensManager.integration.test.ts`. Delete addresses a token by id, so the
168
+ // listing is what turns a stale name back into one.
169
+ label: "API token",
170
+ list: async () => (await tokens.listTokens()).map((token) => token.name),
171
+ remove: async (name) => {
172
+ await tokens.deleteTokensByName(name);
173
+ },
174
+ },
175
+ {
176
+ label: "D1 database",
177
+ list: async () => (await d1.listDatabases()).map((database) => database.name),
178
+ remove: async (name) => {
179
+ const found = (await d1.listDatabases()).find((database) => database.name === name);
180
+ if (found) await d1.deleteDatabase(found.uuid);
181
+ },
182
+ },
183
+ options.emailRoutingZoneId
184
+ ? {
185
+ // The one kind whose debris changes what happens to somebody's mail rather than costing a few
186
+ // cents: a rule left behind keeps delivering to a Worker script the same run already deleted.
187
+ // Zone-scoped, so it reaps only where a run was told which zone it was allowed to touch.
188
+ label: "Email Routing rule",
189
+ list: async () =>
190
+ namedRules(await listEmailRoutingRules(creds, options.emailRoutingZoneId ?? "")).map((rule) => rule.name),
191
+ // `removeWorkerRoute` is already name-keyed and idempotent, which is `remove`'s contract —
192
+ // another runner's sweep reaching the rule first must not read as a failure.
193
+ remove: async (name) => {
194
+ await clients
195
+ .emailRouting()
196
+ .removeWorkerRoute({ zoneId: options.emailRoutingZoneId ?? "", ruleName: name });
197
+ },
198
+ }
199
+ : {
200
+ label: "Email Routing rule",
201
+ skipped: "no EMAIL_ROUTING_ZONE_ID: the zone to sweep is unknown.",
202
+ },
203
+ {
204
+ label: "KV namespace",
205
+ list: async () => (await kv.listNamespaces()).map((namespace) => namespace.title),
206
+ remove: async (title) => {
207
+ const found = (await kv.listNamespaces()).find((namespace) => namespace.title === title);
208
+ if (found) await kv.deleteNamespace(found.id);
209
+ },
210
+ },
211
+ {
212
+ // Queues are created with the raw SDK in the live suite (the manager addresses an existing queue
213
+ // by name), so the reaper reaches for the same surface rather than inventing a provisioner.
214
+ label: "Queue",
215
+ list: async () => {
216
+ const names: string[] = [];
217
+ for await (const queue of sdk.queues.list({ account_id: creds.accountId })) {
218
+ if (queue.queue_name) names.push(queue.queue_name);
219
+ }
220
+ return names;
221
+ },
222
+ remove: async (name) => {
223
+ for await (const queue of sdk.queues.list({ account_id: creds.accountId })) {
224
+ if (queue.queue_name === name && queue.queue_id) {
225
+ await sdk.queues.delete(queue.queue_id, { account_id: creds.accountId });
226
+ return;
227
+ }
228
+ }
229
+ },
230
+ },
231
+ creds.r2
232
+ ? {
233
+ label: "R2 bucket",
234
+ list: async () => (await r2.listBuckets()).map((bucket) => bucket.name),
235
+ remove: async (name) => {
236
+ await emptyTestBucket(creds, name);
237
+ await r2.deleteBucket(name);
238
+ },
239
+ }
240
+ : {
241
+ label: "R2 bucket",
242
+ skipped: "no R2_CREDENTIALS: a bucket cannot be emptied, and R2 refuses to delete a non-empty one.",
243
+ },
244
+ creds.secretsStoreId
245
+ ? {
246
+ label: "Secrets Store entry",
247
+ list: async () => (await clients.secrets(creds.secretsStoreId).listSecrets()).map((entry) => entry.name),
248
+ // `deleteSecretIfPresent`, not `deleteSecret`: `ReapableKind.remove` is contractually
249
+ // idempotent, and another runner's sweep reaching the entry first must not read as a failure.
250
+ remove: async (name) => {
251
+ await clients.secrets(creds.secretsStoreId).deleteSecretIfPresent(name);
252
+ },
253
+ }
254
+ : {
255
+ label: "Secrets Store entry",
256
+ skipped: "no SECRETS_STORE_ID: the store to sweep is unknown.",
257
+ },
258
+ {
259
+ label: "Vectorize index",
260
+ list: async () => (await vectorize.listIndexes()).map((index) => index.name),
261
+ remove: (name) => vectorize.deleteIndex(name),
262
+ },
263
+ {
264
+ label: "Worker script",
265
+ list: async () => (await workers.listWorkers()).map((script) => script.id ?? "").filter(Boolean),
266
+ remove: (name) => workers.deleteWorker(name),
267
+ },
268
+ ];
269
+ }
270
+
271
+ /**
272
+ * Reclaim every stale throwaway resource on the account, across every kind.
273
+ *
274
+ * The entry point a `globalSetup` calls. Reports one line per kind that did something or could not run;
275
+ * a kind with nothing to do stays quiet, because a clean account should produce a clean log.
276
+ */
277
+ export async function reapAllStaleTestResources(
278
+ creds: IntegrationCreds,
279
+ options: ReapScope & { now?: number; staleAfterMs?: number } = {},
280
+ ): Promise<ReapKindResult[]> {
281
+ const results = await reapKinds(testResourceReapPlan(creds, options), options);
282
+
283
+ for (const result of results) {
284
+ if (result.skipped) console.warn(`stale ${result.label}(s) were not swept: ${result.skipped}`);
285
+ if (result.reaped.length > 0) {
286
+ console.warn(`reaped ${result.reaped.length} stale ${result.label}(s): ${result.reaped.join(", ")}`);
287
+ }
288
+ if (result.failed.length > 0) {
289
+ console.warn(`could not reap ${result.failed.length} stale ${result.label}(s): ${result.failed.join(", ")}`);
290
+ }
291
+ }
292
+
293
+ return results;
294
+ }
295
+
296
+ /** Re-exported so a caller reasoning about one name does not need two imports. */
297
+ export { testResourceAge };
@@ -0,0 +1,334 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import {
6
+ CloudflareNotConfiguredError,
7
+ CloudflareRequestError,
8
+ cloudflareRequest,
9
+ decodeResponse,
10
+ isAuthorizationError,
11
+ messageOf,
12
+ } from "../client/errors";
13
+ import { CloudflareManager } from "../client/manager";
14
+ import { CfTokenVerification } from "../user/userManager";
15
+
16
+ /**
17
+ * One access policy to attach to a minted token: a set of permission groups (named, resolved to ids
18
+ * at mint time) and the resources they apply to. This is the reusable unit — every use case that
19
+ * needs a scoped CF token (the secrets manager is the first) describes itself as a list of these,
20
+ * and the manager turns names into the CF policy shape. `effect` defaults to `allow`.
21
+ */
22
+ export interface TokenPermission {
23
+ /** Permission-group names to grant, resolved to ids against the live account list (e.g. "Secrets Store Read"). */
24
+ permissionGroupNames: string[];
25
+ /** The resource scope the groups apply to (e.g. `{ "com.cloudflare.api.account.<id>": "*" }`). */
26
+ resources: Record<string, string>;
27
+ /** Allow or deny the groups against the resources. Defaults to `allow`. */
28
+ effect?: "allow" | "deny";
29
+ }
30
+
31
+ /**
32
+ * The whole-account resource scope for a token policy: `{ "com.cloudflare.api.account.<id>": "*" }`.
33
+ * Account-level permission groups (Secrets Store, D1, Workers, …) scope to this. One helper so the
34
+ * exact resource key format lives in a single place instead of being hand-spelled per use case.
35
+ */
36
+ export function accountResource(accountId: string): Record<string, string> {
37
+ return { [`com.cloudflare.api.account.${accountId}`]: "*" };
38
+ }
39
+
40
+ /**
41
+ * A permission group available to account-owned tokens, as returned by the permission-groups list.
42
+ * Only the id and name matter for resolution — the manager maps a requested name to its id.
43
+ */
44
+ export const CfPermissionGroup = z
45
+ .object({
46
+ id: z.string().describe("The CF-assigned permission-group id, referenced in a token policy."),
47
+ name: z.string().describe("The human-readable permission-group name (e.g. 'Secrets Store Read')."),
48
+ })
49
+ .describe("A Cloudflare account-token permission group: the id a policy references and its display name.");
50
+ export type CfPermissionGroup = z.output<typeof CfPermissionGroup>;
51
+
52
+ /**
53
+ * A freshly minted account-owned token. The `value` is the secret bearer credential — present **only**
54
+ * in the create response, never re-readable — so it is captured here and must be stored at once
55
+ * (e.g. into the Secrets Store). Never log it.
56
+ */
57
+ export const MintedAccountToken = z
58
+ .object({
59
+ id: z.string().describe("The CF-assigned token id, used to address the token for get/delete."),
60
+ value: z.string().describe("The secret token value — returned once on create, never again. Store immediately."),
61
+ name: z.string().optional().describe("The token name as registered with Cloudflare."),
62
+ status: z.enum(["active", "disabled", "expired"]).optional().describe("The token's lifecycle status."),
63
+ })
64
+ .describe("A newly created account-owned API token, including its one-time secret value.");
65
+ export type MintedAccountToken = z.output<typeof MintedAccountToken>;
66
+
67
+ /**
68
+ * An existing account-owned token's metadata, as returned by list/get. No `value` — Cloudflare never
69
+ * returns a token's secret after creation — so this is only enough to find a token by name and
70
+ * address it for deletion.
71
+ */
72
+ export const AccountTokenSummary = z
73
+ .object({
74
+ id: z.string().describe("The CF-assigned token id, used to address the token for deletion."),
75
+ name: z.string().describe("The token name, the key callers match on for idempotent re-mint."),
76
+ status: z.enum(["active", "disabled", "expired"]).optional().describe("The token's lifecycle status."),
77
+ })
78
+ .describe("An existing account-owned API token's metadata (never its secret value).");
79
+ export type AccountTokenSummary = z.output<typeof AccountTokenSummary>;
80
+
81
+ /**
82
+ * Out-of-Worker control plane for **account-owned** Cloudflare API tokens: mint scoped, least-privilege
83
+ * tokens, find them by name, and delete them. Account-owned (not user-bound) by design — CLAUDE.md
84
+ * prefers org-level tokens that outlive any one person.
85
+ *
86
+ * This is the reusable seam behind every "pithy mints its own scoped token" case. A caller hands it a
87
+ * token name and a set of {@link TokenPermission}s (permission-group names + resource scope); the
88
+ * manager resolves the names to ids against the live account list and creates the token. The secrets
89
+ * manager's runtime credential is the first concrete use; more follow without touching this class.
90
+ */
91
+ export class CloudflareAccountTokensManager extends CloudflareManager {
92
+ getServiceType(): string {
93
+ return "Cloudflare Account API Tokens";
94
+ }
95
+
96
+ /** Prove access by listing permission groups — a read, never a token create/delete. Never throws. */
97
+ async validateServiceAccess(): Promise<boolean> {
98
+ try {
99
+ await this.listPermissionGroups();
100
+ return true;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Verify the **calling** token against this account — `GET /accounts/{id}/tokens/verify`.
108
+ *
109
+ * Distinct from {@link CloudflareUserManager.verifyToken}, which hits the *user*-scoped
110
+ * `/user/tokens/verify` and returns `Invalid API Token` for an account-owned (`cfat_*`) credential.
111
+ * Since `CLAUDE.md` prefers account-owned tokens over user-bound ones, the user endpoint is the wrong
112
+ * one for the common case, and using it reports a working token as invalid.
113
+ */
114
+ async verifyToken(): Promise<CfTokenVerification> {
115
+ const raw = await cloudflareRequest("verify account token", () =>
116
+ this.getClient().accounts.tokens.verify({ account_id: this.accountId }),
117
+ );
118
+ return decodeResponse(CfTokenVerification, raw, "account token verify");
119
+ }
120
+
121
+ /**
122
+ * The name of an account-owned token by id, or `null` — `GET /accounts/{id}/tokens/{token_id}`.
123
+ *
124
+ * Distinct from {@link CloudflareUserManager.getTokenName}, which hits `GET /user/tokens/:id` and
125
+ * answers `9109 — Valid user-level authentication not found` for a `cfat_*` caller. Both halves of
126
+ * the account-token identity read — verify and name — are account-scoped, or neither is.
127
+ *
128
+ * **Best effort, by contract.** Reading a token record needs `API Tokens Read`, which the
129
+ * least-privilege tokens {@link mintToken} produces deliberately do not carry: a 403 is the normal
130
+ * answer for a CI credential asking its own name, not a fault. So a denied read returns `null` and
131
+ * the caller falls back to the token id from {@link verifyToken} — which any account token can call
132
+ * with no permission at all. Every other failure still throws.
133
+ */
134
+ async getTokenName(tokenId: string): Promise<string | null> {
135
+ const raw = await cloudflareRequest(`get account token ${tokenId}`, async () => {
136
+ try {
137
+ return await this.getClient().accounts.tokens.get(tokenId, { account_id: this.accountId });
138
+ } catch (error) {
139
+ if (isAuthorizationError(error)) return null;
140
+ throw error;
141
+ }
142
+ });
143
+ const parsed = TokenName.safeParse(raw);
144
+ return parsed.success ? parsed.data.name : null;
145
+ }
146
+
147
+ /** Every permission group available to account-owned tokens in this account (SDK auto-paginates). */
148
+ async listPermissionGroups(): Promise<CfPermissionGroup[]> {
149
+ return cloudflareRequest("list account token permission groups", async () => {
150
+ const out: CfPermissionGroup[] = [];
151
+ for await (const group of this.getClient().accounts.tokens.permissionGroups.list({
152
+ account_id: this.accountId,
153
+ })) {
154
+ const parsed = CfPermissionGroup.safeParse(group);
155
+ if (parsed.success) out.push(parsed.data);
156
+ }
157
+ return out;
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Resolve permission-group names to their `{ id }` references against the account's live group list.
163
+ * Throws a clear, actionable error naming any name that does not exist, or any name that is
164
+ * **ambiguous** — Cloudflare reuses display names across resource scopes (e.g. account vs zone), so a
165
+ * name that maps to more than one id can't be resolved without picking the wrong scope. Either way a
166
+ * typo'd or ambiguous group fails loudly at mint time, never silently widening or narrowing scope.
167
+ */
168
+ async resolvePermissionGroups(names: string[]): Promise<Array<{ id: string }>> {
169
+ return this.resolveAgainstIndex(indexByName(await this.listPermissionGroups()), names);
170
+ }
171
+
172
+ /**
173
+ * Mint a new account-owned token named `name` carrying `permissions`. Resolves every permission
174
+ * group name to its id (one group-list fetch for all policies), builds the CF policy set, and
175
+ * creates the token. Returns the token's one-time secret `value` — store it immediately. A 403 (the
176
+ * calling token cannot create tokens) is reraised as an actionable "grant 'Account API Tokens
177
+ * Write'" error so the cause is obvious; this doubles as the fail-fast preflight when provisioning
178
+ * runs the mint first.
179
+ */
180
+ async mintToken(name: string, permissions: TokenPermission[]): Promise<MintedAccountToken> {
181
+ const index = indexByName(await this.listPermissionGroups());
182
+ const policies = permissions.map((permission) => ({
183
+ effect: permission.effect ?? ("allow" as const),
184
+ permission_groups: this.resolveAgainstIndex(index, permission.permissionGroupNames),
185
+ resources: permission.resources,
186
+ }));
187
+ let raw: unknown;
188
+ try {
189
+ raw = await this.getClient().accounts.tokens.create({ account_id: this.accountId, name, policies });
190
+ } catch (error) {
191
+ if (isAuthorizationError(error)) {
192
+ throw new CloudflareNotConfiguredError(
193
+ {
194
+ message: "The Cloudflare API token is not allowed to create account tokens.",
195
+ action: "Grant it 'Account API Tokens Write' (Account → API Tokens → Edit), then re-run.",
196
+ detail: `mint account token '${name}': ${messageOf(error)}`,
197
+ },
198
+ { cause: error },
199
+ );
200
+ }
201
+ throw new CloudflareRequestError(
202
+ { message: `Failed to mint account token '${name}'.`, detail: messageOf(error) },
203
+ { cause: error },
204
+ );
205
+ }
206
+ return decodeResponse(MintedAccountToken, raw, "account token create");
207
+ }
208
+
209
+ /**
210
+ * Resolve names against a prefetched name→ids index, throwing on any unknown or ambiguous name.
211
+ * The shared core of {@link resolvePermissionGroups} and {@link mintToken}: take the group list once,
212
+ * resolve every name against it. Ambiguity (a name with more than one id) is a hard error, not a
213
+ * silent pick — see {@link resolvePermissionGroups}.
214
+ */
215
+ private resolveAgainstIndex(index: Map<string, string[]>, names: string[]): Array<{ id: string }> {
216
+ const unknown = names.filter((name) => !index.has(name));
217
+ if (unknown.length > 0) {
218
+ throw new CloudflareNotConfiguredError({
219
+ message: `Unknown Cloudflare permission group(s): ${unknown.join(", ")}.`,
220
+ action: "Check the exact permission-group names against the account's available groups.",
221
+ detail: `resolve permission groups: not found in account ${this.accountId} — ${unknown.join(", ")}`,
222
+ });
223
+ }
224
+ const ambiguous = names.filter((name) => (index.get(name)?.length ?? 0) > 1);
225
+ if (ambiguous.length > 0) {
226
+ throw new CloudflareNotConfiguredError({
227
+ message: `Ambiguous Cloudflare permission group(s): ${ambiguous.join(", ")}.`,
228
+ action: "These names map to more than one permission group; scope the token by a unique group name.",
229
+ detail: `resolve permission groups: name maps to multiple ids in account ${this.accountId} — ${ambiguous.join(", ")}`,
230
+ });
231
+ }
232
+ // biome-ignore lint/style/noNonNullAssertion: each name is present and unambiguous — the guards above proved it.
233
+ return names.map((name) => ({ id: index.get(name)![0]! }));
234
+ }
235
+
236
+ /** Every account-owned token's metadata (SDK auto-paginates). No secret values — list is metadata only. */
237
+ async listTokens(): Promise<AccountTokenSummary[]> {
238
+ return cloudflareRequest("list account tokens", async () => {
239
+ const out: AccountTokenSummary[] = [];
240
+ for await (const token of this.getClient().accounts.tokens.list({ account_id: this.accountId })) {
241
+ const parsed = AccountTokenSummary.safeParse(token);
242
+ if (parsed.success) out.push(parsed.data);
243
+ }
244
+ return out;
245
+ });
246
+ }
247
+
248
+ /** Find an account token by exact name, or `null` — for idempotent re-mint (SDK auto-paginates). */
249
+ async findTokenByName(name: string): Promise<AccountTokenSummary | null> {
250
+ return cloudflareRequest(`find account token ${name}`, async () => {
251
+ for await (const token of this.getClient().accounts.tokens.list({ account_id: this.accountId })) {
252
+ const parsed = AccountTokenSummary.safeParse(token);
253
+ if (parsed.success && parsed.data.name === name) return parsed.data;
254
+ }
255
+ return null;
256
+ });
257
+ }
258
+
259
+ /** Delete an account token by id. */
260
+ async deleteToken(tokenId: string): Promise<void> {
261
+ await cloudflareRequest(`delete account token ${tokenId}`, () =>
262
+ this.getClient().accounts.tokens.delete(tokenId, { account_id: this.accountId }),
263
+ );
264
+ }
265
+
266
+ /**
267
+ * Delete every account token with the given name; returns how many were removed. Names are not
268
+ * unique in Cloudflare, so any duplicates (e.g. a prior interrupted run) are swept here. A no-op
269
+ * when none match. Used by teardown.
270
+ */
271
+ async deleteTokensByName(name: string): Promise<number> {
272
+ return cloudflareRequest(`delete account tokens named ${name}`, async () => {
273
+ const ids: string[] = [];
274
+ for await (const token of this.getClient().accounts.tokens.list({ account_id: this.accountId })) {
275
+ const parsed = AccountTokenSummary.safeParse(token);
276
+ if (parsed.success && parsed.data.name === name) ids.push(parsed.data.id);
277
+ }
278
+ for (const id of ids) {
279
+ await this.getClient().accounts.tokens.delete(id, { account_id: this.accountId });
280
+ }
281
+ return ids.length;
282
+ });
283
+ }
284
+
285
+ /**
286
+ * Roll an existing token's secret: regenerate its value **in place**, keeping the same token id,
287
+ * name, and policies (the dashboard's "Roll" action). Returns the new secret value — the only time
288
+ * Cloudflare hands it back — so it must be stored at once. The seam the deferred value-rotation
289
+ * builds on: a credential self-rolls without ever changing identity.
290
+ */
291
+ async rollTokenValue(tokenId: string): Promise<string> {
292
+ const raw = await cloudflareRequest(`roll account token value ${tokenId}`, () =>
293
+ this.getClient().accounts.tokens.value.update(tokenId, { account_id: this.accountId, body: {} }),
294
+ );
295
+ return decodeResponse(RolledTokenValue, raw, "account token value roll");
296
+ }
297
+
298
+ /**
299
+ * Ensure a named token exists and return a **fresh** secret for it. If a token of this name already
300
+ * exists, roll its value in place (same id and policies, new secret); otherwise mint a new one.
301
+ * Either way the caller gets a usable secret to store — the idempotent path for a credential that
302
+ * lives in the Secrets Store, since Cloudflare never returns a token's existing secret. Mirrors the
303
+ * dashboard's roll-or-create.
304
+ */
305
+ async rollToken(name: string, permissions: TokenPermission[]): Promise<MintedAccountToken> {
306
+ const existing = await this.findTokenByName(name);
307
+ if (!existing) return this.mintToken(name, permissions);
308
+ const value = await this.rollTokenValue(existing.id);
309
+ return { id: existing.id, value, name: existing.name, status: existing.status };
310
+ }
311
+ }
312
+
313
+ /** The rolled secret value Cloudflare returns from a value-roll — a non-empty bearer string. */
314
+ const RolledTokenValue = z.string().min(1);
315
+
316
+ /** Just the token's name, the piece the account-token actor path needs. */
317
+ const TokenName = z
318
+ .object({ name: z.string().describe("The token's registered name — the audit actor id for an account token.") })
319
+ .describe("The one field a token-record read is narrowed to: its name.");
320
+
321
+ /**
322
+ * Index permission groups by display name, collecting **every** id that bears a name. A list rather
323
+ * than one id per name on purpose: Cloudflare reuses display names across scopes, and a name with
324
+ * more than one id is ambiguous — the resolver rejects it rather than silently pick one.
325
+ */
326
+ function indexByName(groups: CfPermissionGroup[]): Map<string, string[]> {
327
+ const index = new Map<string, string[]>();
328
+ for (const group of groups) {
329
+ const ids = index.get(group.name) ?? [];
330
+ ids.push(group.id);
331
+ index.set(group.name, ids);
332
+ }
333
+ return index;
334
+ }