@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.
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/package.json +48 -0
- package/src/ai/aiManager.ts +227 -0
- package/src/ai/vectorizeManager.ts +161 -0
- package/src/ai/vectorizeProvisioner.ts +266 -0
- package/src/client/accounts.ts +80 -0
- package/src/client/clients.ts +244 -0
- package/src/client/errors.ts +143 -0
- package/src/client/manager.ts +85 -0
- package/src/d1/d1Manager.ts +171 -0
- package/src/d1/d1PreparedStatement.ts +114 -0
- package/src/d1/d1Provisioner.ts +75 -0
- package/src/email/emailRoutingManager.ts +143 -0
- package/src/email/emailSendManager.ts +81 -0
- package/src/env/devVars.ts +90 -0
- package/src/hostnames/customHostnamesManager.ts +134 -0
- package/src/kv/kvManager.ts +202 -0
- package/src/kv/kvProvisioner.ts +80 -0
- package/src/media/assetSeeder.ts +87 -0
- package/src/media/imageManager.ts +125 -0
- package/src/media/ownership.ts +59 -0
- package/src/media/streamManager.ts +198 -0
- package/src/queue/queueManager.ts +185 -0
- package/src/r2/r2Credentials.ts +17 -0
- package/src/r2/r2Manager.ts +548 -0
- package/src/r2/r2Provisioner.ts +99 -0
- package/src/secrets/secretsStoreManager.ts +177 -0
- package/src/secrets/secretsStores.ts +75 -0
- package/src/test-utils/emailRoutingRules.ts +122 -0
- package/src/test-utils/fixtureReportSetup.ts +31 -0
- package/src/test-utils/fixtures.ts +372 -0
- package/src/test-utils/harness.ts +413 -0
- package/src/test-utils/inboundRecorder.ts +189 -0
- package/src/test-utils/integrationSetup.ts +46 -0
- package/src/test-utils/reap.ts +297 -0
- package/src/tokens/accountTokensManager.ts +334 -0
- package/src/tokens/permissions.ts +67 -0
- package/src/tokens/profiles.ts +238 -0
- package/src/turnstile/turnstileManager.ts +177 -0
- package/src/user/userManager.ts +73 -0
- package/src/workers/buildsManager.ts +348 -0
- package/src/workers/buildsTypes.ts +122 -0
- package/src/workers/workersBuildEvent.ts +48 -0
- package/src/workers/workersManager.ts +423 -0
- package/src/workers/workersProvisioner.ts +167 -0
- package/src/workflows/stepFailure.ts +280 -0
- package/src/workflows/workflowsClient.ts +213 -0
- 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
|
+
}
|