@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,266 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { cloudflareRequest, decodeResponse, isNotFoundError } from "../client/errors";
|
|
6
|
+
import { CloudflareManager } from "../client/manager";
|
|
7
|
+
|
|
8
|
+
/** The distance metric an index scores nearest neighbors with. Fixed at creation — it cannot be changed later. */
|
|
9
|
+
export const VectorizeMetric = z
|
|
10
|
+
.enum(["cosine", "euclidean", "dot-product"])
|
|
11
|
+
.describe("The distance metric a Vectorize index scores nearest neighbors with. Fixed at index creation.");
|
|
12
|
+
export type VectorizeMetric = z.infer<typeof VectorizeMetric>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A Cloudflare-managed embedding preset. Naming one lets Cloudflare pick the dimensions and metric that
|
|
16
|
+
* match the model, so an index and the model that fills it cannot drift apart.
|
|
17
|
+
*/
|
|
18
|
+
export const VectorizePreset = z
|
|
19
|
+
.enum([
|
|
20
|
+
"@cf/baai/bge-small-en-v1.5",
|
|
21
|
+
"@cf/baai/bge-base-en-v1.5",
|
|
22
|
+
"@cf/baai/bge-large-en-v1.5",
|
|
23
|
+
"openai/text-embedding-ada-002",
|
|
24
|
+
"cohere/embed-multilingual-v2.0",
|
|
25
|
+
])
|
|
26
|
+
.describe("An embedding model preset Cloudflare maps to a fixed dimensions/metric pair at index creation.");
|
|
27
|
+
export type VectorizePreset = z.infer<typeof VectorizePreset>;
|
|
28
|
+
|
|
29
|
+
/** The shape of the vectors an index holds: how many components each carries, and how they are compared. */
|
|
30
|
+
export const VectorizeIndexConfig = z
|
|
31
|
+
.object({
|
|
32
|
+
dimensions: z
|
|
33
|
+
.number()
|
|
34
|
+
.int()
|
|
35
|
+
.positive()
|
|
36
|
+
.describe("The number of components in every vector the index holds. Cloudflare allows 32 to 1536."),
|
|
37
|
+
metric: VectorizeMetric,
|
|
38
|
+
})
|
|
39
|
+
.describe("A Vectorize index's vector shape: its dimension count and the distance metric it scores with.");
|
|
40
|
+
export type VectorizeIndexConfig = z.output<typeof VectorizeIndexConfig>;
|
|
41
|
+
|
|
42
|
+
/** A Vectorize index's identity and shape, decoded from the create/get/list responses. */
|
|
43
|
+
export const VectorizeIndexInfo = z
|
|
44
|
+
.object({
|
|
45
|
+
name: z.string().min(1).describe("The index name — Vectorize's sole address for an index (no separate uuid id)."),
|
|
46
|
+
config: VectorizeIndexConfig.describe("The dimensions and metric the index was created with."),
|
|
47
|
+
description: z.string().optional().describe("The operator-supplied description, when the index carries one."),
|
|
48
|
+
})
|
|
49
|
+
.describe("A Cloudflare Vectorize index's identity and vector shape, as returned by the control-plane endpoints.");
|
|
50
|
+
export type VectorizeIndexInfo = z.output<typeof VectorizeIndexInfo>;
|
|
51
|
+
|
|
52
|
+
/** The type of a metadata property an index can filter on. Chosen per property when the metadata index is created. */
|
|
53
|
+
export const VectorizeMetadataIndexType = z
|
|
54
|
+
.enum(["string", "number", "boolean"])
|
|
55
|
+
.describe("The value type of an indexed metadata property, which decides how filters compare against it.");
|
|
56
|
+
export type VectorizeMetadataIndexType = z.infer<typeof VectorizeMetadataIndexType>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The type as the **list** endpoint reports it, normalized to the casing the **create** endpoint accepts.
|
|
60
|
+
*
|
|
61
|
+
* Live Cloudflare answers `metadataIndex.list` with `"String"` while `metadataIndex.create` takes `"string"`
|
|
62
|
+
* (verified against the account on 2026-07-28). Normalizing on decode is not cosmetic: every consumer compares
|
|
63
|
+
* a declared type against a live one — `@pithy-sh/vector`'s drift check does exactly that — and `"String" !==
|
|
64
|
+
* "string"` would report every correctly-provisioned index as mismatched, then send the operator to re-embed a
|
|
65
|
+
* corpus that was never wrong.
|
|
66
|
+
*/
|
|
67
|
+
const ListedMetadataIndexType = z
|
|
68
|
+
.string()
|
|
69
|
+
.transform((value) => value.toLowerCase())
|
|
70
|
+
.pipe(VectorizeMetadataIndexType)
|
|
71
|
+
.describe("The value type of an indexed metadata property as the list endpoint reports it, lowercased.");
|
|
72
|
+
|
|
73
|
+
/** One indexed metadata property on a Vectorize index — the unit that makes a metadata filter possible. */
|
|
74
|
+
export const VectorizeMetadataIndex = z
|
|
75
|
+
.object({
|
|
76
|
+
propertyName: z.string().min(1).describe("The metadata property this index covers (e.g. `tenantId`)."),
|
|
77
|
+
indexType: ListedMetadataIndexType,
|
|
78
|
+
})
|
|
79
|
+
.describe("An indexed metadata property on a Vectorize index, as returned by the metadata-index list endpoint.");
|
|
80
|
+
export type VectorizeMetadataIndex = z.output<typeof VectorizeMetadataIndex>;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* How a new index is shaped: either an explicit dimensions/metric pair, or a Cloudflare embedding preset
|
|
84
|
+
* that resolves to one. Both are modeled because a caller that owns its embedding model wants the explicit
|
|
85
|
+
* pair, while a caller using a stock Cloudflare model is better served by the preset — naming the model once
|
|
86
|
+
* instead of restating its dimensions and risking a mismatch.
|
|
87
|
+
*/
|
|
88
|
+
export type VectorizeIndexSpec = { dimensions: number; metric: VectorizeMetric } | { preset: VectorizePreset };
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether an error means "that index is not there".
|
|
92
|
+
*
|
|
93
|
+
* Vectorize answers a **deleted** index with `410 Gone` (`vectorize.index.deleted`), not `404` — verified live
|
|
94
|
+
* on 2026-07-28. For find-then-create provisioning the two are the same answer, and only one of them was being
|
|
95
|
+
* treated that way: `findIndexByName` threw on a deleted index, so `pithy vector provision` would fail on
|
|
96
|
+
* precisely the case it exists to repair — an index someone removed.
|
|
97
|
+
*/
|
|
98
|
+
function isAbsentIndexError(error: unknown): boolean {
|
|
99
|
+
if (isNotFoundError(error)) return true;
|
|
100
|
+
return typeof error === "object" && error !== null && (error as { status?: unknown }).status === 410;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Vectorize's error code for "that metadata index is not there" — verified live on 2026-07-28. */
|
|
104
|
+
const METADATA_INDEX_ABSENT_CODE = 40005;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether an error means "that metadata index is not there".
|
|
108
|
+
*
|
|
109
|
+
* A third spelling of absence, on top of the 404 and 410 {@link isAbsentIndexError} already covers.
|
|
110
|
+
* Deleting a metadata index that does not exist answers **`400` with error code 40005** — verified live
|
|
111
|
+
* on 2026-07-28 against `metadata index with name=tenantId does not exist`. So `deleteMetadataIndex`
|
|
112
|
+
* documented itself as idempotent while throwing on the one case idempotency is for, and teardown could
|
|
113
|
+
* not re-run.
|
|
114
|
+
*
|
|
115
|
+
* The check is deliberately narrow. A bare `status === 400` would swallow every malformed request this
|
|
116
|
+
* endpoint rejects — a bad property name, a missing account — and turn an author's mistake into a silent
|
|
117
|
+
* no-op. Matching the code as well keeps "absent" distinct from "wrong".
|
|
118
|
+
*/
|
|
119
|
+
function isAbsentMetadataIndexError(error: unknown): boolean {
|
|
120
|
+
if (isAbsentIndexError(error)) return true;
|
|
121
|
+
if (typeof error !== "object" || error === null) return false;
|
|
122
|
+
const { status, errors } = error as { status?: unknown; errors?: unknown };
|
|
123
|
+
if (status !== 400 || !Array.isArray(errors)) return false;
|
|
124
|
+
return errors.some((entry) => (entry as { code?: unknown } | null)?.code === METADATA_INDEX_ABSENT_CODE);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Options a new index accepts beyond its name and shape. */
|
|
128
|
+
export interface CreateVectorizeIndexOptions {
|
|
129
|
+
/** A human description stored on the index, surfaced in the Cloudflare dashboard. */
|
|
130
|
+
description?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Account-level Vectorize control plane: **create and delete indexes, and manage their metadata indexes**.
|
|
135
|
+
* `pithy vector provision` uses this to stand up a per-environment index and declare which metadata
|
|
136
|
+
* properties are filterable; teardown uses it to remove them. Unlike {@link CloudflareVectorizeManager},
|
|
137
|
+
* which operates *within* one already-provisioned index (insert/query/delete vectors), this manager is
|
|
138
|
+
* account-scoped and addresses indexes **by name** — Vectorize indexes carry no separate uuid id.
|
|
139
|
+
*
|
|
140
|
+
* Every mutation here is enqueued: Vectorize applies index and metadata-index changes asynchronously, so a
|
|
141
|
+
* successful call means "accepted", not "visible". Poll {@link listMetadataIndexes} or the data-plane
|
|
142
|
+
* `describe()` watermarks rather than writing and immediately reading back.
|
|
143
|
+
*/
|
|
144
|
+
export class CloudflareVectorizeProvisioner extends CloudflareManager {
|
|
145
|
+
getServiceType(): string {
|
|
146
|
+
return "Vectorize (control plane)";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Prove access by listing indexes — a read, never a destructive create/delete. Never throws. */
|
|
150
|
+
async validateServiceAccess(): Promise<boolean> {
|
|
151
|
+
try {
|
|
152
|
+
await this.getClient().vectorize.indexes.list({ account_id: this.accountId });
|
|
153
|
+
return true;
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Create a Vectorize index by name with an explicit dimensions/metric pair or a Cloudflare preset. */
|
|
160
|
+
async createIndex(
|
|
161
|
+
name: string,
|
|
162
|
+
config: VectorizeIndexSpec,
|
|
163
|
+
options: CreateVectorizeIndexOptions = {},
|
|
164
|
+
): Promise<VectorizeIndexInfo> {
|
|
165
|
+
const response = await cloudflareRequest(`create Vectorize index ${name}`, () =>
|
|
166
|
+
this.getClient().vectorize.indexes.create({
|
|
167
|
+
account_id: this.accountId,
|
|
168
|
+
name,
|
|
169
|
+
config,
|
|
170
|
+
...(options.description ? { description: options.description } : {}),
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
return decodeResponse(VectorizeIndexInfo, response, "Vectorize index create");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Delete a Vectorize index by name. Idempotent — a missing index is not an error, so teardown can re-run safely. */
|
|
177
|
+
async deleteIndex(name: string): Promise<void> {
|
|
178
|
+
await cloudflareRequest(`delete Vectorize index ${name}`, async () => {
|
|
179
|
+
try {
|
|
180
|
+
await this.getClient().vectorize.indexes.delete(name, { account_id: this.accountId });
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (isAbsentIndexError(error)) return;
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** List every Vectorize index in the account — for prefix-scan reconcile teardown and drift checks. */
|
|
189
|
+
async listIndexes(): Promise<VectorizeIndexInfo[]> {
|
|
190
|
+
return cloudflareRequest("list Vectorize indexes", async () => {
|
|
191
|
+
const raw: unknown[] = [];
|
|
192
|
+
for await (const index of this.getClient().vectorize.indexes.list({ account_id: this.accountId })) {
|
|
193
|
+
raw.push(index);
|
|
194
|
+
}
|
|
195
|
+
return decodeResponse(z.array(VectorizeIndexInfo), raw, "Vectorize index list");
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Find an index by exact name, or `null` — for idempotent provisioning. Reads the one index rather than
|
|
201
|
+
* draining the account list, because a Vectorize index's name *is* its address (unlike a KV namespace,
|
|
202
|
+
* whose title is not addressable). A 404 — or a 410 for a deleted one — means absent, which is an answer,
|
|
203
|
+
* not a failure.
|
|
204
|
+
*/
|
|
205
|
+
async findIndexByName(name: string): Promise<VectorizeIndexInfo | null> {
|
|
206
|
+
return cloudflareRequest(`find Vectorize index ${name}`, async () => {
|
|
207
|
+
try {
|
|
208
|
+
const response = await this.getClient().vectorize.indexes.get(name, { account_id: this.accountId });
|
|
209
|
+
return decodeResponse(VectorizeIndexInfo, response, "Vectorize index get");
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (isAbsentIndexError(error)) return null;
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Make a metadata property filterable. Cloudflare caps an index at 10 indexed properties, and only vectors
|
|
219
|
+
* written **after** the metadata index exists are covered — so declare these before the first insert.
|
|
220
|
+
* The change is enqueued asynchronously; the returned promise resolving means accepted, not live.
|
|
221
|
+
*/
|
|
222
|
+
async createMetadataIndex(
|
|
223
|
+
indexName: string,
|
|
224
|
+
propertyName: string,
|
|
225
|
+
indexType: VectorizeMetadataIndexType,
|
|
226
|
+
): Promise<void> {
|
|
227
|
+
await cloudflareRequest(`create Vectorize metadata index ${indexName}.${propertyName}`, () =>
|
|
228
|
+
this.getClient().vectorize.indexes.metadataIndex.create(indexName, {
|
|
229
|
+
account_id: this.accountId,
|
|
230
|
+
indexType,
|
|
231
|
+
propertyName,
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** List an index's indexed metadata properties — the read that proves a `createMetadataIndex` has landed. */
|
|
237
|
+
async listMetadataIndexes(indexName: string): Promise<VectorizeMetadataIndex[]> {
|
|
238
|
+
const response = await cloudflareRequest(`list Vectorize metadata indexes for ${indexName}`, () =>
|
|
239
|
+
this.getClient().vectorize.indexes.metadataIndex.list(indexName, { account_id: this.accountId }),
|
|
240
|
+
);
|
|
241
|
+
return decodeResponse(
|
|
242
|
+
z.array(VectorizeMetadataIndex),
|
|
243
|
+
response?.metadataIndexes ?? [],
|
|
244
|
+
"Vectorize metadata index list",
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Stop filtering on a metadata property. Idempotent — a missing metadata index is not an error, matching
|
|
250
|
+
* {@link deleteIndex} so teardown can re-run safely. Absence here is `400`/40005, not `404`, which is why
|
|
251
|
+
* this uses {@link isAbsentMetadataIndexError} rather than the plain not-found check.
|
|
252
|
+
*/
|
|
253
|
+
async deleteMetadataIndex(indexName: string, propertyName: string): Promise<void> {
|
|
254
|
+
await cloudflareRequest(`delete Vectorize metadata index ${indexName}.${propertyName}`, async () => {
|
|
255
|
+
try {
|
|
256
|
+
await this.getClient().vectorize.indexes.metadataIndex.delete(indexName, {
|
|
257
|
+
account_id: this.accountId,
|
|
258
|
+
propertyName,
|
|
259
|
+
});
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (isAbsentMetadataIndexError(error)) return;
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { Cloudflare } from "cloudflare";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { cloudflareRequest } from "./errors";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The accounts a bootstrap API token can see — `GET /accounts`.
|
|
10
|
+
*
|
|
11
|
+
* **The one Cloudflare read in this package that is not account-scoped**, which is why it is a function
|
|
12
|
+
* here rather than a manager: {@link CloudflareManager} takes an `accountId` in its config and refuses
|
|
13
|
+
* without one, and this is the call made *before* an account id exists. `pithy init` collects the token
|
|
14
|
+
* two prompts before it lists zones, and until now it took the account id on trust — pasted, unverified,
|
|
15
|
+
* and wrong often enough that "the token is for one account and the id is another" is a documented
|
|
16
|
+
* failure mode (`CLOUDFLARE_CREDENTIAL_KEYS`). Asking the token what it can see removes that class
|
|
17
|
+
* outright, and the account's own name is a better nickname than one invented at a prompt (#206).
|
|
18
|
+
*
|
|
19
|
+
* Read-only, and never required: a narrowly scoped token that cannot list accounts is a legitimate
|
|
20
|
+
* token, and every caller falls back to asking.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** One Cloudflare account, as `GET /accounts` lists it. Two fields, because two is all anything here uses. */
|
|
24
|
+
export const CfAccount = z
|
|
25
|
+
.object({
|
|
26
|
+
id: z
|
|
27
|
+
.string()
|
|
28
|
+
.describe("The account id every provisioned resource is created under, and the value a project pins."),
|
|
29
|
+
name: z
|
|
30
|
+
.string()
|
|
31
|
+
.describe("The account's own name, as the operator set it — free text, e.g. `Leed, Inc.`, never a slug."),
|
|
32
|
+
})
|
|
33
|
+
.describe("One Cloudflare account a bootstrap API token can see.");
|
|
34
|
+
export type CfAccount = z.output<typeof CfAccount>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* More than this and a picker is not a picker. A token seeing hundreds of accounts is somebody's
|
|
38
|
+
* reseller credential, and walking every page of it to build a `select` helps nobody — the cap keeps one
|
|
39
|
+
* unusual token from turning `pithy init` into a paginated crawl.
|
|
40
|
+
*/
|
|
41
|
+
const MAX_ACCOUNTS = 100;
|
|
42
|
+
|
|
43
|
+
/** What {@link listCloudflareAccounts} needs: a token, and — for a test — somewhere other than Cloudflare. */
|
|
44
|
+
export interface ListCloudflareAccountsOptions {
|
|
45
|
+
/** The bootstrap API token. Never logged; it is only ever handed to the SDK. */
|
|
46
|
+
apiToken: string;
|
|
47
|
+
/**
|
|
48
|
+
* Seam: the raw account records. Defaults to the SDK's own paginated `accounts.list()`.
|
|
49
|
+
*
|
|
50
|
+
* A test passing this never reaches Cloudflare, which matters more here than anywhere else in the
|
|
51
|
+
* package: the default would list the operator's real accounts using whatever token their shell
|
|
52
|
+
* exports.
|
|
53
|
+
*/
|
|
54
|
+
accounts?: (client: Cloudflare) => AsyncIterable<unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Every account this token can see, name-sorted so a picker is stable between runs.
|
|
59
|
+
*
|
|
60
|
+
* **Each record is validated, and one that does not parse is dropped rather than passed on.** This is a
|
|
61
|
+
* response from outside crossing into a value that becomes `cloudflare.accountId` in a repository and
|
|
62
|
+
* the account every later resource is created under; a half-record would put an `undefined` id into a
|
|
63
|
+
* config file and a `[object Object]` into an error message. The same rule `listZones` follows.
|
|
64
|
+
*
|
|
65
|
+
* Throws a `cloudflare/request_failed` when the account list could not be read at all — a token without
|
|
66
|
+
* the permission, or no network. Callers treat that as "no picker" and ask instead.
|
|
67
|
+
*/
|
|
68
|
+
export async function listCloudflareAccounts(options: ListCloudflareAccountsOptions): Promise<CfAccount[]> {
|
|
69
|
+
return cloudflareRequest("list accounts", async () => {
|
|
70
|
+
const client = new Cloudflare({ apiToken: options.apiToken });
|
|
71
|
+
const source = options.accounts ?? ((sdk: Cloudflare) => sdk.accounts.list());
|
|
72
|
+
const accounts: CfAccount[] = [];
|
|
73
|
+
for await (const record of source(client)) {
|
|
74
|
+
const parsed = CfAccount.safeParse(record);
|
|
75
|
+
if (parsed.success) accounts.push(parsed.data);
|
|
76
|
+
if (accounts.length >= MAX_ACCOUNTS) break;
|
|
77
|
+
}
|
|
78
|
+
return accounts.sort((a, b) => a.name.localeCompare(b.name));
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { CloudflareAIManager } from "../ai/aiManager";
|
|
5
|
+
import { CloudflareVectorizeManager } from "../ai/vectorizeManager";
|
|
6
|
+
import { CloudflareVectorizeProvisioner } from "../ai/vectorizeProvisioner";
|
|
7
|
+
import { CloudflareD1Manager } from "../d1/d1Manager";
|
|
8
|
+
import { CloudflareD1Provisioner } from "../d1/d1Provisioner";
|
|
9
|
+
import { CloudflareEmailRoutingManager } from "../email/emailRoutingManager";
|
|
10
|
+
import { CloudflareEmailSendManager } from "../email/emailSendManager";
|
|
11
|
+
import { CloudflareCustomHostnamesManager } from "../hostnames/customHostnamesManager";
|
|
12
|
+
import { CloudflareKVManager } from "../kv/kvManager";
|
|
13
|
+
import { CloudflareKVProvisioner } from "../kv/kvProvisioner";
|
|
14
|
+
import { CloudflareImageManager } from "../media/imageManager";
|
|
15
|
+
import { CloudflareStreamManager } from "../media/streamManager";
|
|
16
|
+
import { CloudflareQueueManager } from "../queue/queueManager";
|
|
17
|
+
import type { R2Credentials } from "../r2/r2Credentials";
|
|
18
|
+
import { CloudflareR2Manager } from "../r2/r2Manager";
|
|
19
|
+
import { CloudflareR2Provisioner } from "../r2/r2Provisioner";
|
|
20
|
+
import { CloudflareSecretsStoreManager } from "../secrets/secretsStoreManager";
|
|
21
|
+
import { CloudflareSecretsStoresManager } from "../secrets/secretsStores";
|
|
22
|
+
import { CloudflareAccountTokensManager } from "../tokens/accountTokensManager";
|
|
23
|
+
import { CloudflareTurnstileManager } from "../turnstile/turnstileManager";
|
|
24
|
+
import { CloudflareUserManager } from "../user/userManager";
|
|
25
|
+
import { CloudflareBuildsManager } from "../workers/buildsManager";
|
|
26
|
+
import { CloudflareWorkersManager } from "../workers/workersManager";
|
|
27
|
+
import { WorkersProvisioner } from "../workers/workersProvisioner";
|
|
28
|
+
import { CloudflareZonesManager } from "../zones/zonesManager";
|
|
29
|
+
import type { CloudflareManagerConfig } from "./manager";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One configured entry point to every Cloudflare REST manager. This is a runtime aggregator — it
|
|
33
|
+
* constructs and memoizes managers from a single `{ apiToken, accountId }` config — **not** a
|
|
34
|
+
* re-export barrel (CLAUDE.md forbids those); the composition logic is the reason it exists.
|
|
35
|
+
*
|
|
36
|
+
* Resource-scoped managers (KV, D1, queues, Vectorize indexes, secret stores, hostname zones, R2
|
|
37
|
+
* buckets) are keyed by their resource id, so repeated calls for the same id return one shared
|
|
38
|
+
* instance. Account-scoped managers (AI, Images, Stream, Workers, Builds, Turnstile) are singletons.
|
|
39
|
+
*/
|
|
40
|
+
export class CloudflareClients {
|
|
41
|
+
private readonly config: CloudflareManagerConfig;
|
|
42
|
+
|
|
43
|
+
private readonly kvByNamespace = new Map<string, CloudflareKVManager>();
|
|
44
|
+
|
|
45
|
+
private readonly d1ByDatabase = new Map<string, CloudflareD1Manager>();
|
|
46
|
+
|
|
47
|
+
private readonly queueByName = new Map<string, CloudflareQueueManager>();
|
|
48
|
+
|
|
49
|
+
private readonly vectorizeByIndex = new Map<string, CloudflareVectorizeManager>();
|
|
50
|
+
|
|
51
|
+
private readonly secretsByStore = new Map<string, CloudflareSecretsStoreManager>();
|
|
52
|
+
|
|
53
|
+
private secretsStoresManager?: CloudflareSecretsStoresManager;
|
|
54
|
+
|
|
55
|
+
private readonly hostnamesByZone = new Map<string, CloudflareCustomHostnamesManager>();
|
|
56
|
+
|
|
57
|
+
private aiManager?: CloudflareAIManager;
|
|
58
|
+
|
|
59
|
+
private emailSendManager?: CloudflareEmailSendManager;
|
|
60
|
+
private emailRoutingManager?: CloudflareEmailRoutingManager;
|
|
61
|
+
private imageManager?: CloudflareImageManager;
|
|
62
|
+
|
|
63
|
+
private streamManager?: CloudflareStreamManager;
|
|
64
|
+
|
|
65
|
+
private workersManager?: CloudflareWorkersManager;
|
|
66
|
+
|
|
67
|
+
private buildsManager?: CloudflareBuildsManager;
|
|
68
|
+
|
|
69
|
+
private turnstileManager?: CloudflareTurnstileManager;
|
|
70
|
+
|
|
71
|
+
private accountTokensManager?: CloudflareAccountTokensManager;
|
|
72
|
+
|
|
73
|
+
private userManager?: CloudflareUserManager;
|
|
74
|
+
private zonesManager?: CloudflareZonesManager;
|
|
75
|
+
|
|
76
|
+
private workersProvisioner?: WorkersProvisioner;
|
|
77
|
+
|
|
78
|
+
private d1ProvisionerManager?: CloudflareD1Provisioner;
|
|
79
|
+
|
|
80
|
+
private kvProvisionerManager?: CloudflareKVProvisioner;
|
|
81
|
+
|
|
82
|
+
private r2ProvisionerManager?: CloudflareR2Provisioner;
|
|
83
|
+
|
|
84
|
+
private vectorizeProvisionerManager?: CloudflareVectorizeProvisioner;
|
|
85
|
+
|
|
86
|
+
constructor(config: CloudflareManagerConfig) {
|
|
87
|
+
this.config = config;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The KV manager for a namespace id. */
|
|
91
|
+
kv(namespaceId: string): CloudflareKVManager {
|
|
92
|
+
return memo(this.kvByNamespace, namespaceId, () => new CloudflareKVManager({ ...this.config, namespaceId }));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The D1 manager for a database id (also a Kysely `D1Dialect` database). */
|
|
96
|
+
d1(databaseId: string): CloudflareD1Manager {
|
|
97
|
+
return memo(this.d1ByDatabase, databaseId, () => new CloudflareD1Manager({ ...this.config, databaseId }));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The Queue manager for a queue name. */
|
|
101
|
+
queue(queueName: string): CloudflareQueueManager {
|
|
102
|
+
return memo(this.queueByName, queueName, () => new CloudflareQueueManager({ ...this.config, queueName }));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The Vectorize manager for an index name. */
|
|
106
|
+
vectorize(indexName: string): CloudflareVectorizeManager {
|
|
107
|
+
return memo(this.vectorizeByIndex, indexName, () => new CloudflareVectorizeManager({ ...this.config, indexName }));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The Secrets Store manager for a store id. */
|
|
111
|
+
secrets(storeId: string): CloudflareSecretsStoreManager {
|
|
112
|
+
return memo(this.secretsByStore, storeId, () => new CloudflareSecretsStoreManager({ ...this.config, storeId }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The **account-level** Secrets Store manager — which stores this account has.
|
|
117
|
+
*
|
|
118
|
+
* Separate from {@link secrets} because it is asked before any store id exists: `pithy add secrets`
|
|
119
|
+
* resolves the account's one store and records it, so nothing after that has to ask (#182).
|
|
120
|
+
*/
|
|
121
|
+
secretsStores(): CloudflareSecretsStoresManager {
|
|
122
|
+
this.secretsStoresManager ??= new CloudflareSecretsStoresManager(this.config);
|
|
123
|
+
return this.secretsStoresManager;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The Custom Hostnames manager for a zone id. */
|
|
127
|
+
hostnames(zoneId: string): CloudflareCustomHostnamesManager {
|
|
128
|
+
return memo(this.hostnamesByZone, zoneId, () => new CloudflareCustomHostnamesManager({ ...this.config, zoneId }));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The R2 manager for a bucket. R2 needs S3 credentials beyond the API token, so they are supplied
|
|
133
|
+
* here per call — the manager is **not** memoized: caching by bucket name alone would hand back a
|
|
134
|
+
* stale instance after a credential rotation. Construction is cheap (the S3 client is lazy).
|
|
135
|
+
*/
|
|
136
|
+
r2(bucket: R2Credentials & { bucketName: string }): CloudflareR2Manager {
|
|
137
|
+
return new CloudflareR2Manager({ ...this.config, ...bucket });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The account-scoped Workers AI manager. */
|
|
141
|
+
ai(): CloudflareAIManager {
|
|
142
|
+
this.aiManager ??= new CloudflareAIManager(this.config);
|
|
143
|
+
return this.aiManager;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The account-scoped Email Sending (REST) manager — out-of-Worker sends; in a Worker use the binding. */
|
|
147
|
+
email(): CloudflareEmailSendManager {
|
|
148
|
+
this.emailSendManager ??= new CloudflareEmailSendManager(this.config);
|
|
149
|
+
return this.emailSendManager;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The Email Routing (inbound rules) manager — methods take a zone id. */
|
|
153
|
+
emailRouting(): CloudflareEmailRoutingManager {
|
|
154
|
+
this.emailRoutingManager ??= new CloudflareEmailRoutingManager(this.config);
|
|
155
|
+
return this.emailRoutingManager;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The account-scoped Images manager. */
|
|
159
|
+
images(): CloudflareImageManager {
|
|
160
|
+
this.imageManager ??= new CloudflareImageManager(this.config);
|
|
161
|
+
return this.imageManager;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The account-scoped Stream manager. */
|
|
165
|
+
stream(): CloudflareStreamManager {
|
|
166
|
+
this.streamManager ??= new CloudflareStreamManager(this.config);
|
|
167
|
+
return this.streamManager;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The account-scoped Workers (scripts/deployments) manager. */
|
|
171
|
+
workers(): CloudflareWorkersManager {
|
|
172
|
+
this.workersManager ??= new CloudflareWorkersManager(this.config);
|
|
173
|
+
return this.workersManager;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The account-scoped Workers Builds manager. */
|
|
177
|
+
builds(): CloudflareBuildsManager {
|
|
178
|
+
this.buildsManager ??= new CloudflareBuildsManager(this.config);
|
|
179
|
+
return this.buildsManager;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The account-scoped Turnstile manager. */
|
|
183
|
+
turnstile(): CloudflareTurnstileManager {
|
|
184
|
+
this.turnstileManager ??= new CloudflareTurnstileManager(this.config);
|
|
185
|
+
return this.turnstileManager;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The Workers provisioner (orchestrates the Workers + Builds managers). */
|
|
189
|
+
provisioner(): WorkersProvisioner {
|
|
190
|
+
this.workersProvisioner ??= new WorkersProvisioner(this.config);
|
|
191
|
+
return this.workersProvisioner;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The account-scoped D1 control plane — create and delete databases. */
|
|
195
|
+
d1Provisioner(): CloudflareD1Provisioner {
|
|
196
|
+
this.d1ProvisionerManager ??= new CloudflareD1Provisioner(this.config);
|
|
197
|
+
return this.d1ProvisionerManager;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The account-scoped KV control plane — create, find, list, and delete namespaces. */
|
|
201
|
+
kvProvisioner(): CloudflareKVProvisioner {
|
|
202
|
+
this.kvProvisionerManager ??= new CloudflareKVProvisioner(this.config);
|
|
203
|
+
return this.kvProvisionerManager;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The account-scoped R2 control plane — create, find, list, and delete buckets. */
|
|
207
|
+
r2Provisioner(): CloudflareR2Provisioner {
|
|
208
|
+
this.r2ProvisionerManager ??= new CloudflareR2Provisioner(this.config);
|
|
209
|
+
return this.r2ProvisionerManager;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The account-scoped Vectorize control plane — create, find, list, and delete indexes and their metadata indexes. */
|
|
213
|
+
vectorizeProvisioner(): CloudflareVectorizeProvisioner {
|
|
214
|
+
this.vectorizeProvisionerManager ??= new CloudflareVectorizeProvisioner(this.config);
|
|
215
|
+
return this.vectorizeProvisionerManager;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The account-scoped API-token control plane — mint, find, and delete account-owned tokens. */
|
|
219
|
+
accountTokens(): CloudflareAccountTokensManager {
|
|
220
|
+
this.accountTokensManager ??= new CloudflareAccountTokensManager(this.config);
|
|
221
|
+
return this.accountTokensManager;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The user/token identity reader — resolves the actor behind the calling API token (audit attribution). */
|
|
225
|
+
user(): CloudflareUserManager {
|
|
226
|
+
this.userManager ??= new CloudflareUserManager(this.config);
|
|
227
|
+
return this.userManager;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The account's zones, read-only — what a custom domain may attach to. Needs only `Zone:Read`. */
|
|
231
|
+
zones(): CloudflareZonesManager {
|
|
232
|
+
this.zonesManager ??= new CloudflareZonesManager(this.config);
|
|
233
|
+
return this.zonesManager;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Return the cached value for `key`, constructing and storing it on first access. */
|
|
238
|
+
function memo<T>(cache: Map<string, T>, key: string, make: () => T): T {
|
|
239
|
+
const existing = cache.get(key);
|
|
240
|
+
if (existing !== undefined) return existing;
|
|
241
|
+
const created = make();
|
|
242
|
+
cache.set(key, created);
|
|
243
|
+
return created;
|
|
244
|
+
}
|