@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,143 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { CloudflareInvalidResponseError, cloudflareRequest } from "../client/errors";
6
+ import { CloudflareManager } from "../client/manager";
7
+
8
+ /**
9
+ * Cloudflare Email Routing rules — the inbound side. Used by email provisioning to point a domain's
10
+ * bounce/complaint mail at the app worker that hosts the `email()` handler. Email Routing is
11
+ * **zone-scoped** (a rule lives on a zone, and routing must already be enabled on that zone — enabling
12
+ * it sets the zone's MX, an operator action we never take automatically, so the apex MX stays put).
13
+ *
14
+ * Rule *creation and deletion* go through the typed SDK. The lookup both of them turn on cannot:
15
+ * `emailRouting.rules` exposes only create/update/delete/get, and `get` addresses a rule by CF id — we
16
+ * match by name, which needs a listing the SDK has no method for. That one read stays on the documented
17
+ * raw-`fetch` escape hatch; drop it the moment a `rules.list` lands.
18
+ */
19
+
20
+ /** A routing rule's stored shape — only the fields we match on for idempotency. */
21
+ const RuleEnvelope = z.object({
22
+ success: z.boolean(),
23
+ result: z
24
+ .array(z.object({ id: z.string().optional(), name: z.string().optional() }))
25
+ .nullable()
26
+ .default([]),
27
+ /**
28
+ * The paging block. Present on every list response, and read rather than ignored because both
29
+ * callers ask a question a truncated answer gets **wrong in the dangerous direction**: an unseen
30
+ * rule reads as "no such rule", which makes `ensureWorkerRoute` create a duplicate on every run and
31
+ * `removeWorkerRoute` report a removal while mail is still being delivered.
32
+ */
33
+ result_info: z
34
+ .object({
35
+ page: z.number().optional(),
36
+ per_page: z.number().optional(),
37
+ total_count: z.number().optional(),
38
+ total_pages: z.number().optional(),
39
+ })
40
+ .nullish(),
41
+ });
42
+
43
+ /** Rules per page. Cloudflare's maximum, so the common zone is answered in one request. */
44
+ const RULES_PER_PAGE = 50;
45
+
46
+ /** A hard stop on the page loop, so a malformed `result_info` cannot spin forever. */
47
+ const MAX_RULE_PAGES = 100;
48
+
49
+ export class CloudflareEmailRoutingManager extends CloudflareManager {
50
+ getServiceType(): string {
51
+ return "Email Routing";
52
+ }
53
+
54
+ /** Prove reach by listing a zone's routing rules; never throws. (Account-level reach is a weak proxy here.) */
55
+ async validateServiceAccess(): Promise<boolean> {
56
+ return true;
57
+ }
58
+
59
+ /**
60
+ * Ensure a routing rule named `ruleName` exists on `zoneId`, delivering mail addressed to `address`
61
+ * to the Worker `workerName`. Idempotent: if a rule with that name already exists it is left as-is.
62
+ * Assumes Email Routing is already enabled on the zone.
63
+ */
64
+ async ensureWorkerRoute(options: {
65
+ zoneId: string;
66
+ address: string;
67
+ workerName: string;
68
+ ruleName: string;
69
+ }): Promise<{ created: boolean }> {
70
+ const { zoneId, address, workerName, ruleName } = options;
71
+ return cloudflareRequest("Email Routing ensure worker rule", async () => {
72
+ const rules = await this.#listRules(zoneId);
73
+ if (rules.some((rule) => rule.name === ruleName)) return { created: false };
74
+
75
+ await this.getClient().emailRouting.rules.create({
76
+ zone_id: zoneId,
77
+ name: ruleName,
78
+ enabled: true,
79
+ matchers: [{ type: "literal", field: "to", value: address }],
80
+ actions: [{ type: "worker", value: [workerName] }],
81
+ });
82
+ return { created: true };
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Remove the rule named `ruleName` from `zoneId`, so mail stops being delivered to the Worker behind it.
88
+ * Idempotent: a zone carrying no such rule is a no-op, which is what lets a teardown be re-run.
89
+ *
90
+ * **Matched on the rule name, never on the address.** The name is the key `ensureWorkerRoute` created
91
+ * under, so the two agree on which rule belongs to which capability even after somebody edited the
92
+ * address in the dashboard — and a teardown that matched on the address would happily delete a rule an
93
+ * operator wrote by hand for the same mailbox.
94
+ */
95
+ async removeWorkerRoute(options: { zoneId: string; ruleName: string }): Promise<{ removed: boolean }> {
96
+ const { zoneId, ruleName } = options;
97
+ return cloudflareRequest("Email Routing remove worker rule", async () => {
98
+ const rule = (await this.#listRules(zoneId)).find((candidate) => candidate.name === ruleName);
99
+ if (!rule?.id) return { removed: false };
100
+ await this.getClient().emailRouting.rules.delete(rule.id, { zone_id: zoneId });
101
+ return { removed: true };
102
+ });
103
+ }
104
+
105
+ /**
106
+ * **Every** routing rule on a zone, by the raw-`fetch` escape hatch this file's header documents —
107
+ * the SDK has no listing, and both matching by name and deleting by id need one.
108
+ *
109
+ * Paged to exhaustion rather than reading the first page. A zone with more rules than fit one page
110
+ * is unusual but entirely legal, and a partial read is not a smaller answer here — it is a *wrong*
111
+ * one, because both callers treat "not in the list" as "does not exist". Provisioning would then
112
+ * create a second rule for the same address on every run, and a teardown would report mail stopped
113
+ * while it kept arriving.
114
+ *
115
+ * A failed read **throws**, for the same reason: silently degrading to an empty list would produce
116
+ * exactly the two failures above.
117
+ */
118
+ async #listRules(zoneId: string): Promise<{ id?: string; name?: string }[]> {
119
+ const rules: { id?: string; name?: string }[] = [];
120
+
121
+ for (let page = 1; page <= MAX_RULE_PAGES; page += 1) {
122
+ const url = `https://api.cloudflare.com/client/v4/zones/${zoneId}/email/routing/rules?page=${page}&per_page=${RULES_PER_PAGE}`;
123
+ const listed = await fetch(url, { headers: { Authorization: `Bearer ${this.getApiToken()}` } });
124
+ if (!listed.ok) {
125
+ throw new CloudflareInvalidResponseError({
126
+ message: "Could not read the zone's Email Routing rules.",
127
+ detail: `Email Routing rule list returned ${listed.status}: ${await listed.text()}`,
128
+ });
129
+ }
130
+
131
+ const envelope = RuleEnvelope.parse(await listed.json());
132
+ const batch = envelope.result ?? [];
133
+ rules.push(...batch);
134
+
135
+ // Stop on the paging block when the API supplied one, and on a short page otherwise — an older
136
+ // or proxied response that omits `result_info` still terminates correctly.
137
+ const totalPages = envelope.result_info?.total_pages;
138
+ if (totalPages !== undefined ? page >= totalPages : batch.length < RULES_PER_PAGE) break;
139
+ }
140
+
141
+ return rules;
142
+ }
143
+ }
@@ -0,0 +1,81 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { cloudflareRequest } from "../client/errors";
5
+ import { CloudflareManager } from "../client/manager";
6
+
7
+ /**
8
+ * Out-of-Worker sends through the Cloudflare Email Service REST API — the control-plane counterpart to
9
+ * the in-Worker `send_email` binding. Used by the CLI's `pithy email test` to render and deliver one
10
+ * template through a project's configuration without deploying. Inside a Worker, always prefer the
11
+ * binding (no token, principle-1 aligned).
12
+ */
13
+
14
+ /** A message to send over REST. `from` mirrors the binding's `{ email, name }`; the wire uses `address`. */
15
+ export interface EmailSendInput {
16
+ to: string | string[];
17
+ from: { email: string; name?: string };
18
+ subject: string;
19
+ html: string;
20
+ text: string;
21
+ replyTo?: string;
22
+ }
23
+
24
+ /** What the send returned: the assigned message id and any synchronous delivery breakdown. */
25
+ export interface EmailSendResult {
26
+ messageId?: string;
27
+ delivered: string[];
28
+ queued: string[];
29
+ permanentBounces: string[];
30
+ }
31
+
32
+ export class CloudflareEmailSendManager extends CloudflareManager {
33
+ getServiceType(): string {
34
+ return "Email Sending";
35
+ }
36
+
37
+ /**
38
+ * Prove reach by reading the account's sending limits; never throws (returns false on any failure).
39
+ * `/email/sending/limits` has no typed SDK method — only `send` and `subdomains` do — so this stays
40
+ * on the documented raw-`fetch` escape hatch. Probing `subdomains.list` instead would change what
41
+ * the check proves: a token scoped to send but not to read subdomains would start reporting false.
42
+ */
43
+ async validateServiceAccess(): Promise<boolean> {
44
+ try {
45
+ const res = await fetch(
46
+ `https://api.cloudflare.com/client/v4/accounts/${this.getAccountId()}/email/sending/limits`,
47
+ { headers: { Authorization: `Bearer ${this.getApiToken()}` } },
48
+ );
49
+ return res.ok;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Send one message through the Email Sending API. Throws `cloudflare/request_failed` on any API
57
+ * error — the SDK unwraps the envelope and raises on `success: false`, so a resolved call is a send.
58
+ *
59
+ * `from` is sent as the object form only when a display name exists; the SDK's object form requires
60
+ * `name`, and the bare-string form is the wire's own representation of "address, no display name".
61
+ */
62
+ async send(message: EmailSendInput): Promise<EmailSendResult> {
63
+ return cloudflareRequest("Email Service send", async () => {
64
+ const result = await this.getClient().emailSending.send({
65
+ account_id: this.getAccountId(),
66
+ to: message.to,
67
+ from: message.from.name ? { address: message.from.email, name: message.from.name } : message.from.email,
68
+ subject: message.subject,
69
+ html: message.html,
70
+ text: message.text,
71
+ ...(message.replyTo ? { reply_to: message.replyTo } : {}),
72
+ });
73
+ return {
74
+ messageId: result.message_id,
75
+ delivered: result.delivered,
76
+ queued: result.queued,
77
+ permanentBounces: result.permanent_bounces,
78
+ };
79
+ });
80
+ }
81
+ }
@@ -0,0 +1,90 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The Cloudflare credential keys pithy reads out-of-Worker (CLI, provisioning, live tests). These are
6
+ * wrangler's own env-var names plus the Secrets Store id. From this single bootstrap token
7
+ * (`CLOUDFLARE_API_TOKEN`) pithy **mints** the scoped, least-privilege tokens each use case needs —
8
+ * most notably the secrets manager's runtime token, minted at provision time and written straight into
9
+ * the Secrets Store, so no scoped token is ever supplied or kept here.
10
+ *
11
+ * **Where they are read from is not this module's answer any more (#182).** They are account-scoped, so
12
+ * they live in `<config>/cloudflare.json` — or, since #206, in `<config>/cloudflare.<name>.json` when a
13
+ * project's root config names its account. See `@pithy-sh/cli`'s `cloudflare/config`, which owns the
14
+ * file, which file, the `process.env` overlay, and the split diagnostic. The names stay here because
15
+ * both ends need them and the Worker-side package is the one both can import.
16
+ *
17
+ * **The overlay has an off switch, and it is not `PITHY_CONFIG_DIR` (#218).** Relocating the config
18
+ * directory moves the *file*; these four names are still read out of the ambient environment, which is
19
+ * how a CLI run in an empty scratch directory reached a real account off a token a shell had exported
20
+ * hours earlier. `PITHY_OFFLINE` is the word that stops it, and it lives with the overlay it governs —
21
+ * `PITHY_OFFLINE_ENV` in `@pithy-sh/cli`'s `cloudflare/config`. Anything outside the CLI that grows a
22
+ * reason to read one of these names off `process.env` inherits that obligation with it.
23
+ */
24
+ export const CLOUDFLARE_ENV_KEYS = [
25
+ "CLOUDFLARE_ACCOUNT_ID",
26
+ "CLOUDFLARE_API_TOKEN",
27
+ "SECRETS_STORE_ID",
28
+ "R2_CREDENTIALS",
29
+ ] as const;
30
+
31
+ /**
32
+ * Which of {@link CLOUDFLARE_ENV_KEYS} an environment actually carries a value for. The one place that
33
+ * list is turned into a check.
34
+ *
35
+ * **Non-empty, not merely present.** `vitest.shared.ts`'s `NO_ACCOUNT` pins all four keys to `""`, and
36
+ * the `process.env` overlay in `@pithy-sh/cli`'s `cloudflare/config` already reads a blank as unset. A
37
+ * predicate keying on presence would report every guarded unit project as leaking.
38
+ *
39
+ * **This module is bundled into workerd as well as run on the host, and that is why it imports nothing.**
40
+ * The repository-root `vitest.workers.setup.ts` imports this function relatively and every workers
41
+ * project loads it, so a `node:` import here breaks seventeen suites at collection. `vitest.shared.ts`
42
+ * already leans on the same property for {@link CLOUDFLARE_ENV_KEYS} and says so. Nothing gates the
43
+ * import half — `@pithy-sh/core` has `worker-safety.test.ts` and this package has no equivalent — so it
44
+ * is a constraint a reader has to be told, which is what this paragraph is. The other half is gated:
45
+ * `packages/cli/src/ci/testIsolation.test.ts` walks what every workers config imports, so this module
46
+ * is in that scan and a `process.env` read here is red (#437).
47
+ */
48
+ export function visibleCredentialKeys(env: Readonly<Record<string, string | undefined>>): readonly string[] {
49
+ return CLOUDFLARE_ENV_KEYS.filter((key) => (env[key] ?? "").length > 0);
50
+ }
51
+
52
+ /**
53
+ * Parse a `.dev.vars` file body into a map: `KEY=value` lines, `#` comments and blanks skipped, and
54
+ * a single layer of surrounding quotes stripped. Pure — the caller owns the file read.
55
+ */
56
+ export function parseDevVars(content: string): Record<string, string> {
57
+ const vars: Record<string, string> = {};
58
+ for (const line of content.split("\n")) {
59
+ const trimmed = line.trim();
60
+ if (!trimmed || trimmed.startsWith("#")) continue;
61
+ const eq = trimmed.indexOf("=");
62
+ if (eq === -1) continue;
63
+ vars[trimmed.slice(0, eq).trim()] = trimmed
64
+ .slice(eq + 1)
65
+ .trim()
66
+ .replace(/^["']|["']$/g, "");
67
+ }
68
+ return vars;
69
+ }
70
+
71
+ /**
72
+ * The keys that only mean anything **together, from one account**: the account id, and a token minted in
73
+ * that account. Deliberately just the pair.
74
+ *
75
+ * The other {@link CLOUDFLARE_ENV_KEYS} stay out, because each fails loudly and immediately when it comes
76
+ * from the wrong account: a store id this account does not hold 404s on the first call, and an S3 key
77
+ * that does not belong to the account in the endpoint will not sign. Only the pair can *quietly* succeed
78
+ * somewhere unintended — a live token against another account's id is a 403 or an empty listing much
79
+ * later — so only the pair is a group. That is the whole test for membership.
80
+ *
81
+ * **A whole file can be the wrong account too.** This pair is the *half-file, half-environment* case; one
82
+ * level up, a file that is entirely coherent in itself can belong to a different company than the project
83
+ * reading it, which is what a project's `cloudflare.accountName` and its `accountId` pin answer (#206).
84
+ *
85
+ * `SECRETS_STORE_ID` used to be excluded on the grounds that it is "routinely passed per environment
86
+ * while the pair sits in the file". That was never true: Cloudflare permits **one Secrets Store per
87
+ * account**, so nothing about the id is per-environment, and it now sits in `cloudflare.json` beside the
88
+ * pair for exactly that reason (#182). The conclusion was right; the stated reason was not.
89
+ */
90
+ export const CLOUDFLARE_CREDENTIAL_KEYS = ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"] as const;
@@ -0,0 +1,134 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type {
5
+ CustomHostnameCreateResponse,
6
+ CustomHostnameDeleteResponse,
7
+ CustomHostnameGetResponse,
8
+ CustomHostnameListResponse,
9
+ } from "cloudflare/resources/custom-hostnames/custom-hostnames";
10
+ import { CloudflareNotConfiguredError, cloudflareRequest } from "../client/errors";
11
+ import { CloudflareManager, type CloudflareManagerConfig } from "../client/manager";
12
+
13
+ /**
14
+ * Config for the Custom Hostnames manager: the shared client config plus the zone the hostnames
15
+ * live under. Custom hostnames are a zone-scoped resource — the REST API addresses them by
16
+ * `zone_id`, so the zone is required configuration here.
17
+ */
18
+ export interface CustomHostnamesManagerConfig extends CloudflareManagerConfig {
19
+ /** The zone id custom hostnames are created and managed under (the CF zone serving the apex). */
20
+ zoneId: string;
21
+ }
22
+
23
+ /** Options for creating a custom hostname. */
24
+ export interface CreateCustomHostnameOptions {
25
+ /**
26
+ * The minimum TLS version the issued certificate negotiates. Defaults to `1.2`. Custom hostnames
27
+ * use HTTP DCV with a DV certificate.
28
+ */
29
+ minTlsVersion?: "1.0" | "1.1" | "1.2" | "1.3";
30
+ }
31
+
32
+ /**
33
+ * Out-of-Worker Custom Hostnames access over the REST API: create, look up, and remove custom
34
+ * hostnames on a zone from a CLI/CI/provisioning context. Custom hostnames let a zone serve domains
35
+ * it does not own DNS for — a customer CNAMEs their domain to a fallback origin on the zone, and CF
36
+ * issues a certificate via HTTP domain-control validation. Addressed by zone id.
37
+ */
38
+ export class CloudflareCustomHostnamesManager extends CloudflareManager {
39
+ private readonly zoneId: string;
40
+
41
+ constructor(config: CustomHostnamesManagerConfig) {
42
+ super(config);
43
+ if (!config.zoneId) {
44
+ throw new CloudflareNotConfiguredError({ detail: "Missing zoneId for Custom Hostnames REST access." });
45
+ }
46
+ this.zoneId = config.zoneId;
47
+ }
48
+
49
+ /**
50
+ * Create a custom hostname with HTTP DCV and a DV certificate. Idempotent: if a custom hostname
51
+ * already exists for this hostname, the existing record is returned instead of creating a duplicate.
52
+ */
53
+ async addCustomHostname(
54
+ hostname: string,
55
+ options?: CreateCustomHostnameOptions,
56
+ ): Promise<CustomHostnameCreateResponse | CustomHostnameListResponse> {
57
+ const existing = await this.getCustomHostname(hostname);
58
+ if (existing) return existing;
59
+
60
+ return cloudflareRequest(`add custom hostname '${hostname}'`, () =>
61
+ this.getClient().customHostnames.create({
62
+ zone_id: this.zoneId,
63
+ hostname,
64
+ ssl: {
65
+ method: "http",
66
+ type: "dv",
67
+ settings: { min_tls_version: options?.minTlsVersion ?? "1.2" },
68
+ },
69
+ }),
70
+ );
71
+ }
72
+
73
+ /** Look up a custom hostname by its fully qualified name. Returns null when none matches. */
74
+ async getCustomHostname(hostname: string): Promise<CustomHostnameListResponse | null> {
75
+ return cloudflareRequest(`get custom hostname '${hostname}'`, async () => {
76
+ // `hostname` is a filter object in the v7 SDK; `exact` is the FQDN match (the loose
77
+ // `contain`/`startsWith` variants would return neighbors). The equality re-check below
78
+ // stays as a guard — the filter narrows the page, it does not guarantee a single result.
79
+ for await (const candidate of this.getClient().customHostnames.list({
80
+ zone_id: this.zoneId,
81
+ hostname: { exact: hostname },
82
+ })) {
83
+ if (candidate.hostname === hostname) return candidate;
84
+ }
85
+ return null;
86
+ });
87
+ }
88
+
89
+ /** Fetch a custom hostname by its id. */
90
+ async getCustomHostnameById(customHostnameId: string): Promise<CustomHostnameGetResponse> {
91
+ return cloudflareRequest(`get custom hostname by id '${customHostnameId}'`, () =>
92
+ this.getClient().customHostnames.get(customHostnameId, { zone_id: this.zoneId }),
93
+ );
94
+ }
95
+
96
+ /** List every custom hostname on the zone. */
97
+ async listCustomHostnames(): Promise<CustomHostnameListResponse[]> {
98
+ return cloudflareRequest("list custom hostnames", async () => {
99
+ const hostnames: CustomHostnameListResponse[] = [];
100
+ for await (const candidate of this.getClient().customHostnames.list({ zone_id: this.zoneId })) {
101
+ hostnames.push(candidate);
102
+ }
103
+ return hostnames;
104
+ });
105
+ }
106
+
107
+ /** Remove a custom hostname by its id. */
108
+ async removeCustomHostname(customHostnameId: string): Promise<CustomHostnameDeleteResponse> {
109
+ return cloudflareRequest(`remove custom hostname '${customHostnameId}'`, () =>
110
+ this.getClient().customHostnames.delete(customHostnameId, { zone_id: this.zoneId }),
111
+ );
112
+ }
113
+
114
+ /** The zone this manager targets and the account it lives in. */
115
+ getCustomHostnamesInfo(): { zoneId: string; accountId: string } {
116
+ return { zoneId: this.zoneId, accountId: this.accountId };
117
+ }
118
+
119
+ getServiceType(): string {
120
+ return "Cloudflare Custom Hostnames";
121
+ }
122
+
123
+ /** Prove access by listing custom hostnames on the zone. Never throws. */
124
+ async validateServiceAccess(): Promise<boolean> {
125
+ try {
126
+ for await (const _candidate of this.getClient().customHostnames.list({ zone_id: this.zoneId, per_page: 1 })) {
127
+ break;
128
+ }
129
+ return true;
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,202 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Key } from "cloudflare/resources/kv/namespaces/keys";
5
+ import {
6
+ CloudflareInvalidResponseError,
7
+ CloudflareNotConfiguredError,
8
+ cloudflareRequest,
9
+ isNotFoundError,
10
+ reasonOf,
11
+ } from "../client/errors";
12
+ import { CloudflareManager, type CloudflareManagerConfig } from "../client/manager";
13
+
14
+ /** Config for the KV manager: the shared client config plus the namespace it targets. */
15
+ export interface KVManagerConfig extends CloudflareManagerConfig {
16
+ /** The KV namespace id (the REST API addresses namespaces by id, not binding name). */
17
+ namespaceId: string;
18
+ }
19
+
20
+ /** Options for a single KV write. TTL is explicit (CLAUDE.md §KV). */
21
+ export interface KVPutOptions {
22
+ /** Seconds until the key expires. Omit for no expiry. */
23
+ expirationTtl?: number;
24
+ /** Arbitrary metadata stored alongside the value. */
25
+ metadata?: Record<string, unknown>;
26
+ }
27
+
28
+ /** One entry in a `batchSet` call. */
29
+ export interface KVOperation {
30
+ /** The key to write. */
31
+ key: string;
32
+ /** The value to store. */
33
+ value: string;
34
+ /** Seconds until the key expires. */
35
+ expirationTtl?: number;
36
+ /** Metadata to store with the value. */
37
+ metadata?: Record<string, unknown>;
38
+ }
39
+
40
+ /** The per-key outcome of a `batchSet`: a partial-failure report, never a thrown batch. */
41
+ export interface KVBatchResult {
42
+ /** The key this result is for. */
43
+ key: string;
44
+ /** Whether the write succeeded. */
45
+ success: boolean;
46
+ /** The failure reason, present only when `success` is false. */
47
+ error?: string;
48
+ }
49
+
50
+ /** A KV value together with its metadata, as returned by `getWithMetadata`. */
51
+ export interface KVValueWithMetadata<M = unknown> {
52
+ /** The stored value, or null if the key is absent. */
53
+ value: string | null;
54
+ /** The stored metadata, or null if absent. */
55
+ metadata: M | null;
56
+ }
57
+
58
+ /**
59
+ * Out-of-Worker KV access over the REST API: namespace management and key reads/writes from a
60
+ * CLI/CI/provisioning context. Inside a Worker you use the `KVNamespace` binding directly — this
61
+ * manager is the REST counterpart, addressed by namespace id.
62
+ */
63
+ export class CloudflareKVManager extends CloudflareManager {
64
+ private readonly namespaceId: string;
65
+
66
+ constructor(config: KVManagerConfig) {
67
+ super(config);
68
+ if (!config.namespaceId) {
69
+ throw new CloudflareNotConfiguredError({ detail: "Missing namespaceId for KV REST access." });
70
+ }
71
+ this.namespaceId = config.namespaceId;
72
+ }
73
+
74
+ /** Read a key's value as text. Returns null when the key is absent (the API 404s a missing key). */
75
+ async get(key: string): Promise<string | null> {
76
+ return cloudflareRequest(`KV get for key '${key}'`, async () => {
77
+ try {
78
+ // The SDK resolves the values endpoint to a `Response`, not the value — read the body as text.
79
+ const response = await this.getClient().kv.namespaces.values.get(key, {
80
+ account_id: this.accountId,
81
+ namespace_id: this.namespaceId,
82
+ });
83
+ return await response.text();
84
+ } catch (error) {
85
+ // The REST API (and SDK) signal a missing key with a 404 throw, not a null resolve.
86
+ if (isNotFoundError(error)) return null;
87
+ throw error;
88
+ }
89
+ });
90
+ }
91
+
92
+ /** Read a key and `JSON.parse` it. Returns null when absent; throws on malformed JSON. */
93
+ async getJson<T = unknown>(key: string): Promise<T | null> {
94
+ const raw = await this.get(key);
95
+ if (raw === null) return null;
96
+ try {
97
+ return JSON.parse(raw) as T;
98
+ } catch (error) {
99
+ throw new CloudflareInvalidResponseError({
100
+ message: `KV value for key '${key}' is not valid JSON.`,
101
+ detail: error instanceof Error ? error.message : String(error),
102
+ });
103
+ }
104
+ }
105
+
106
+ /** Write a value, with optional TTL and metadata. */
107
+ async set(key: string, value: string, options?: KVPutOptions): Promise<void> {
108
+ await cloudflareRequest(`KV set for key '${key}'`, async () => {
109
+ await this.getClient().kv.namespaces.values.update(key, {
110
+ account_id: this.accountId,
111
+ namespace_id: this.namespaceId,
112
+ value,
113
+ ...(options?.expirationTtl !== undefined ? { expiration_ttl: options.expirationTtl } : {}),
114
+ ...(options?.metadata !== undefined ? { metadata: JSON.stringify(options.metadata) } : {}),
115
+ });
116
+ });
117
+ }
118
+
119
+ /** Delete a key. */
120
+ async delete(key: string): Promise<void> {
121
+ await cloudflareRequest(`KV delete for key '${key}'`, async () => {
122
+ await this.getClient().kv.namespaces.values.delete(key, {
123
+ account_id: this.accountId,
124
+ namespace_id: this.namespaceId,
125
+ });
126
+ });
127
+ }
128
+
129
+ /** List keys in the namespace, optionally filtered by prefix and bounded by limit. */
130
+ async listKeys(prefix?: string, limit?: number): Promise<Key[]> {
131
+ return cloudflareRequest("KV list keys", async () => {
132
+ const response = await this.getClient().kv.namespaces.keys.list(this.namespaceId, {
133
+ account_id: this.accountId,
134
+ ...(prefix !== undefined ? { prefix } : {}),
135
+ ...(limit !== undefined ? { limit } : {}),
136
+ });
137
+ return response.result;
138
+ });
139
+ }
140
+
141
+ /** Read a key's value and metadata together. Both are null when absent. */
142
+ async getWithMetadata<M = unknown>(key: string): Promise<KVValueWithMetadata<M>> {
143
+ return cloudflareRequest(`KV getWithMetadata for key '${key}'`, async () => {
144
+ const [value, metadata] = await Promise.all([
145
+ // Reuse get()'s Response-unwrap + 404→null decode rather than re-deriving it here, and fetch
146
+ // the metadata in parallel. A missing key (or a key with no metadata) 404s on the metadata
147
+ // read; treat only that as absent. A real failure (auth, 5xx, network) is rethrown — never
148
+ // masked as "no metadata", which would let a caller mistake a fetch failure for an empty tag.
149
+ this.get(key),
150
+ this.getClient()
151
+ .kv.namespaces.metadata.get(key, { account_id: this.accountId, namespace_id: this.namespaceId })
152
+ .catch((error: unknown) => {
153
+ if (isNotFoundError(error)) return null;
154
+ throw error;
155
+ }),
156
+ ]);
157
+ return {
158
+ value,
159
+ metadata: (metadata as M | null) ?? null,
160
+ };
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Write many keys, collecting a per-key result instead of failing the whole batch on the first
166
+ * error — the caller decides what to do with partial failure.
167
+ */
168
+ async batchSet(operations: KVOperation[]): Promise<KVBatchResult[]> {
169
+ const results: KVBatchResult[] = [];
170
+ for (const operation of operations) {
171
+ try {
172
+ await this.set(operation.key, operation.value, {
173
+ expirationTtl: operation.expirationTtl,
174
+ metadata: operation.metadata,
175
+ });
176
+ results.push({ key: operation.key, success: true });
177
+ } catch (error) {
178
+ results.push({ key: operation.key, success: false, error: reasonOf(error) });
179
+ }
180
+ }
181
+ return results;
182
+ }
183
+
184
+ /** The namespace this manager targets and the account it lives in. */
185
+ getKVInfo(): { namespaceId: string; accountId: string } {
186
+ return { namespaceId: this.namespaceId, accountId: this.accountId };
187
+ }
188
+
189
+ getServiceType(): string {
190
+ return "KV Storage";
191
+ }
192
+
193
+ /** Prove access by reading the namespace record. Never throws. */
194
+ async validateServiceAccess(): Promise<boolean> {
195
+ try {
196
+ await this.getClient().kv.namespaces.get(this.namespaceId, { account_id: this.accountId });
197
+ return true;
198
+ } catch {
199
+ return false;
200
+ }
201
+ }
202
+ }