@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,177 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { JsonDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { NotFoundError } from "@pithy-sh/core/src/error/pithyError";
6
+ import { z } from "zod";
7
+ import { CloudflareNotConfiguredError, cloudflareRequest, decodeResponse } from "../client/errors";
8
+ import { CloudflareManager, type CloudflareManagerConfig } from "../client/manager";
9
+
10
+ /** Scopes attached to every secret we create. CF requires at least one; "workers" is the bind target. */
11
+ const DEFAULT_SCOPES = ["workers"] as const;
12
+
13
+ /**
14
+ * One secret in the store, decoded from the CF list response. CF Secrets Store never returns
15
+ * plaintext over REST — values are bind-only by design — so this carries only metadata. The
16
+ * `created`/`modified` ISO strings decode through `JsonDate` to real `Date`s at the wire boundary.
17
+ */
18
+ export const CfSecretEntry = z
19
+ .object({
20
+ id: z.string().describe("The CF-assigned secret identifier, used to address delete by id."),
21
+ name: z.string().describe("The secret's name within the store (the key the CLI references)."),
22
+ status: z.enum(["pending", "active", "deleted"]).describe("The secret's lifecycle status in the store."),
23
+ created: JsonDate.describe("When the secret was created (ISO string on the wire, Date in app)."),
24
+ modified: JsonDate.describe("When the secret was last modified (ISO string on the wire, Date in app)."),
25
+ })
26
+ .describe("A single Cloudflare Secrets Store secret's metadata (never its plaintext value).");
27
+ export type CfSecretEntry = z.output<typeof CfSecretEntry>;
28
+
29
+ /** Config for the Secrets Store manager: the shared client config plus the store it targets. */
30
+ export interface SecretsStoreManagerConfig extends CloudflareManagerConfig {
31
+ /** The CF Secrets Store id (the REST API addresses stores by id). */
32
+ storeId: string;
33
+ }
34
+
35
+ /**
36
+ * Out-of-Worker access to the account-level Cloudflare Secrets Store over the REST API: provisioning
37
+ * and audit from a CLI/CI context. Inside a Worker, secret values resolve via bindings — this manager
38
+ * is the REST counterpart for managing the store, addressed by store id.
39
+ *
40
+ * CF Secrets Store does not expose secret plaintext via REST (values are bind-only by design), so
41
+ * there is no `getSecret`. The provisioning and audit flows only need put, delete, and list.
42
+ */
43
+ export class CloudflareSecretsStoreManager extends CloudflareManager {
44
+ private readonly storeId: string;
45
+
46
+ constructor(config: SecretsStoreManagerConfig) {
47
+ super(config);
48
+ if (!config.storeId) {
49
+ throw new CloudflareNotConfiguredError({ detail: "Missing storeId for Secrets Store REST access." });
50
+ }
51
+ this.storeId = config.storeId;
52
+ }
53
+
54
+ getServiceType(): string {
55
+ return "Cloudflare Secrets Store";
56
+ }
57
+
58
+ /** Prove access by listing the store's secrets. Never throws. */
59
+ async validateServiceAccess(): Promise<boolean> {
60
+ try {
61
+ await this.listSecrets();
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Insert or update a secret. An existing entry is updated in place via `edit`; otherwise a fresh
70
+ * secret is created.
71
+ *
72
+ * `edit` is what makes this safe. The old value is overwritten, never deleted first, so a failed
73
+ * update leaves the prior value intact and bound — there is no window where the secret is absent
74
+ * from the store (which, for a secret like the master encryption key, is a platform-level outage).
75
+ * Scopes are re-sent so an entry converges on the same shape whichever branch wrote it.
76
+ */
77
+ async putSecret(name: string, value: string): Promise<void> {
78
+ const existing = await this.findByName(name);
79
+ if (!existing) {
80
+ await this.createSecret(name, value);
81
+ return;
82
+ }
83
+
84
+ await cloudflareRequest(`put secret ${name}`, () =>
85
+ this.getClient().secretsStore.stores.secrets.edit(existing.id, {
86
+ account_id: this.accountId,
87
+ store_id: this.storeId,
88
+ value,
89
+ scopes: [...DEFAULT_SCOPES],
90
+ }),
91
+ );
92
+ }
93
+
94
+ /** Delete a secret by name. Resolves the id via `listSecrets`, then issues DELETE by id. */
95
+ async deleteSecret(name: string): Promise<void> {
96
+ const existing = await this.findByName(name);
97
+ if (!existing) {
98
+ throw new NotFoundError({
99
+ message: `Secret '${name}' was not found in the store.`,
100
+ detail: `delete secret ${name}: no entry with that name`,
101
+ });
102
+ }
103
+ await cloudflareRequest(`delete secret ${name}`, () =>
104
+ this.getClient().secretsStore.stores.secrets.delete(existing.id, {
105
+ account_id: this.accountId,
106
+ store_id: this.storeId,
107
+ }),
108
+ );
109
+ }
110
+
111
+ /**
112
+ * Delete a secret, treating an absent one as already done.
113
+ *
114
+ * The typed not-found on {@link deleteSecret} is right for a caller that named a specific secret and
115
+ * needs to hear it was not there. It is wrong for anything reconciling toward absence — teardown, a
116
+ * reaper, a re-run of a provisioning step — where "gone" is the goal and a second delete is a no-op,
117
+ * not a failure. Two callers race on the store all the time: another runner's sweep, or a listing that
118
+ * has not caught up.
119
+ *
120
+ * Given as its own method rather than a flag, so which semantics a call site wants is legible at the
121
+ * call site. The alternative every caller reaches for otherwise is `.catch(() => {})`, which also
122
+ * swallows the auth failure and the outage — and a teardown that swallows is how debris becomes
123
+ * permanent with no signal at all.
124
+ */
125
+ async deleteSecretIfPresent(name: string): Promise<boolean> {
126
+ const existing = await this.findByName(name);
127
+ if (!existing) return false;
128
+ await cloudflareRequest(`delete secret ${name}`, () =>
129
+ this.getClient().secretsStore.stores.secrets.delete(existing.id, {
130
+ account_id: this.accountId,
131
+ store_id: this.storeId,
132
+ }),
133
+ );
134
+ return true;
135
+ }
136
+
137
+ /**
138
+ * List every secret in the store. The SDK auto-paginates via `for await`, so callers receive the
139
+ * full set in one array. Each entry is Zod-validated (`CfSecretEntry`) at the wire boundary.
140
+ */
141
+ async listSecrets(): Promise<CfSecretEntry[]> {
142
+ return cloudflareRequest("list secrets", async () => {
143
+ const out: CfSecretEntry[] = [];
144
+ for await (const entry of this.getClient().secretsStore.stores.secrets.list(this.storeId, {
145
+ account_id: this.accountId,
146
+ })) {
147
+ out.push(decodeResponse(CfSecretEntry, entry, "Secrets Store list entry"));
148
+ }
149
+ return out;
150
+ });
151
+ }
152
+
153
+ /** Whether a secret with the given name currently exists in the store. */
154
+ async exists(name: string): Promise<boolean> {
155
+ return (await this.findByName(name)) !== undefined;
156
+ }
157
+
158
+ /** The store this manager targets and the account it lives in. */
159
+ getSecretsStoreInfo(): { storeId: string; accountId: string } {
160
+ return { storeId: this.storeId, accountId: this.accountId };
161
+ }
162
+
163
+ /** Create a single secret with the default scopes. */
164
+ private async createSecret(name: string, value: string): Promise<void> {
165
+ await cloudflareRequest(`create secret ${name}`, () =>
166
+ this.getClient().secretsStore.stores.secrets.create(this.storeId, {
167
+ account_id: this.accountId,
168
+ body: [{ name, value, scopes: [...DEFAULT_SCOPES] }],
169
+ }),
170
+ );
171
+ }
172
+
173
+ private async findByName(name: string): Promise<CfSecretEntry | undefined> {
174
+ const all = await this.listSecrets();
175
+ return all.find((entry) => entry.name === name);
176
+ }
177
+ }
@@ -0,0 +1,75 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { JsonDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+ import { cloudflareRequest, decodeResponse } from "../client/errors";
7
+ import { CloudflareManager } from "../client/manager";
8
+
9
+ /**
10
+ * The **account-level** view of Cloudflare Secrets Store: which stores exist, and nothing else.
11
+ *
12
+ * Its sibling {@link CloudflareSecretsStoreManager} is addressed *by store id* — it demands one in its
13
+ * constructor, because every operation it has is inside a store. That is the right shape for putting and
14
+ * deleting secrets and the wrong shape for the one question asked before any store id is known: which
15
+ * store does this account have?
16
+ *
17
+ * **One store per account**, which is what makes the answer usable at all. `pithy add secrets` resolves
18
+ * the id here, once, at provisioning time, and writes it into `<config>/cloudflare.json` — so every later
19
+ * run is a plain file read. Discovery on every invocation was considered and rejected (#182): a CLI that
20
+ * cannot run offline because it has to ask Cloudflare where its own store is has lost more than the key
21
+ * was costing.
22
+ */
23
+
24
+ /**
25
+ * One store in the account, decoded from the CF list response.
26
+ *
27
+ * Metadata only — a store holds secrets whose plaintext the REST API never returns. The `created` and
28
+ * `modified` ISO strings decode through `JsonDate` at the wire boundary, as every other response here
29
+ * does, so nothing downstream parses a date string a second time.
30
+ */
31
+ export const CfSecretsStore = z
32
+ .object({
33
+ id: z.string().describe("The CF-assigned store identifier — the value that becomes SECRETS_STORE_ID."),
34
+ name: z.string().describe("The store's display name, for naming it back to an operator who has two."),
35
+ created: JsonDate.describe("When the store was created (ISO string on the wire, Date in app)."),
36
+ modified: JsonDate.describe("When the store was last modified (ISO string on the wire, Date in app)."),
37
+ })
38
+ .describe("A single Cloudflare Secrets Store's metadata. Never a secret, and never a secret's value.");
39
+ export type CfSecretsStore = z.output<typeof CfSecretsStore>;
40
+
41
+ /** Account-level Secrets Store operations: listing the account's stores. Addressed by account, not store. */
42
+ export class CloudflareSecretsStoresManager extends CloudflareManager {
43
+ getServiceType(): string {
44
+ return "Cloudflare Secrets Store (account)";
45
+ }
46
+
47
+ /** Prove access by listing the account's stores. Never throws. */
48
+ async validateServiceAccess(): Promise<boolean> {
49
+ try {
50
+ await this.listStores();
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Every Secrets Store in the account. The SDK auto-paginates via `for await`, so a caller receives
59
+ * the full set in one array, and each entry is Zod-validated at the wire boundary.
60
+ *
61
+ * **Returns the list, never a choice.** Cloudflare permits one store per account, so the ordinary
62
+ * answer has exactly one element — but "ordinarily one" is not "always one", and picking the first of
63
+ * two would be guessing which store holds an adopter's production secrets. The caller decides, and the
64
+ * only correct decision for two is to refuse and name them.
65
+ */
66
+ async listStores(): Promise<CfSecretsStore[]> {
67
+ return cloudflareRequest("list secrets stores", async () => {
68
+ const out: CfSecretsStore[] = [];
69
+ for await (const store of this.getClient().secretsStore.stores.list({ account_id: this.accountId })) {
70
+ out.push(decodeResponse(CfSecretsStore, store, "Secrets Store list entry"));
71
+ }
72
+ return out;
73
+ });
74
+ }
75
+ }
@@ -0,0 +1,122 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { CloudflareRequestError } from "../client/errors";
6
+ import type { IntegrationCreds } from "./harness";
7
+
8
+ /**
9
+ * A zone's Email Routing rules, read whole.
10
+ *
11
+ * `CloudflareEmailRoutingManager` reads the same endpoint and keeps only `id` and `name`, because
12
+ * matching by name and deleting by id is all provisioning needs. Two callers here need more.
13
+ *
14
+ * **A live test has to see what Cloudflare stored, not what we sent.** #47's open question is whether
15
+ * the `matchers`/`actions` shape `ensureWorkerRoute` posts still matches the current API — and a test
16
+ * that asserts on its own request argument answers nothing. So the rule comes back in full and the
17
+ * assertions are on the stored copy.
18
+ *
19
+ * **The reaper needs names it can hand back to `removeWorkerRoute`.** A run that dies between creating
20
+ * a rule and tearing it down leaves live mail routing to a Worker that is about to be deleted, which is
21
+ * the one piece of debris in this repo that changes what happens to somebody's mail rather than costing
22
+ * a few cents.
23
+ *
24
+ * ## Two documented traps, both live here so nobody has to rediscover them
25
+ *
26
+ * **Every zone has a catch-all rule whether or not anything is configured** — no name, priority
27
+ * `2147483647`, `matchers: [{ type: "all" }]`. A rule count of 1 means nothing. {@link namedRules}
28
+ * drops it, so a caller counting *our* rules counts ours.
29
+ *
30
+ * **`result_info` on this endpoint carries no `total_pages`** on a single-page zone. The paging loop
31
+ * therefore stops on a short page, exactly as the manager's does.
32
+ */
33
+
34
+ /** Rules per page. Cloudflare's maximum, so the common zone is answered in one request. */
35
+ const RULES_PER_PAGE = 50;
36
+
37
+ /** A hard stop on the page loop, so a malformed `result_info` cannot spin forever. */
38
+ const MAX_RULE_PAGES = 100;
39
+
40
+ /** One matcher on a routing rule: which mail the rule claims. */
41
+ export const EmailRoutingMatcher = z
42
+ .object({
43
+ type: z.string().describe("`literal` for one address, `all` for the zone's catch-all."),
44
+ field: z.string().optional().describe("The header the literal matches on — `to` for every rule Pithy writes."),
45
+ value: z.string().optional().describe("The address a `literal` matcher claims. Absent on a catch-all."),
46
+ })
47
+ .describe("One matcher on an Email Routing rule.");
48
+
49
+ /** One action on a routing rule: what happens to the mail it claimed. */
50
+ export const EmailRoutingAction = z
51
+ .object({
52
+ type: z.string().describe("`worker` for a Worker delivery, `drop` for the default catch-all, `forward` otherwise."),
53
+ value: z.array(z.string()).default([]).describe("The action's targets — a single Worker script name for `worker`."),
54
+ })
55
+ .describe("One action on an Email Routing rule.");
56
+
57
+ /** One Email Routing rule, as the zone stores it. */
58
+ export const EmailRoutingRule = z
59
+ .object({
60
+ id: z.string().describe("Cloudflare's id for the rule — what a delete addresses."),
61
+ name: z.string().default("").describe("The rule's name. Empty on the default catch-all every zone carries."),
62
+ enabled: z
63
+ .boolean()
64
+ .default(false)
65
+ .describe("Whether the rule is live. The catch-all's flag doubles as the zone's."),
66
+ priority: z.number().default(0).describe("Match order, lowest first. The catch-all sits at 2147483647."),
67
+ matchers: z.array(EmailRoutingMatcher).default([]).describe("Which mail this rule claims."),
68
+ actions: z.array(EmailRoutingAction).default([]).describe("What happens to the mail this rule claimed."),
69
+ })
70
+ .describe("One Email Routing rule on a zone, read back in full.");
71
+
72
+ /** One Email Routing rule, as the zone stores it. */
73
+ export type EmailRoutingRule = z.output<typeof EmailRoutingRule>;
74
+
75
+ /** The list envelope, validated because a response read is a boundary. */
76
+ const RuleListEnvelope = z
77
+ .object({
78
+ success: z.boolean().describe("Cloudflare's own verdict on the request."),
79
+ result: z.array(EmailRoutingRule).nullable().default([]).describe("This page of rules. Null on a failed call."),
80
+ result_info: z
81
+ .object({
82
+ page: z.number().optional().describe("The page just returned."),
83
+ total_pages: z.number().optional().describe("Total pages, when Cloudflare states one. Often absent."),
84
+ })
85
+ .nullish()
86
+ .describe("The paging block, when the response carries one."),
87
+ })
88
+ .describe("A page of a zone's Email Routing rules.");
89
+
90
+ /** Every Email Routing rule on a zone, paged to exhaustion. */
91
+ export async function listEmailRoutingRules(creds: IntegrationCreds, zoneId: string): Promise<EmailRoutingRule[]> {
92
+ const rules: EmailRoutingRule[] = [];
93
+
94
+ for (let page = 1; page <= MAX_RULE_PAGES; page += 1) {
95
+ const url = `https://api.cloudflare.com/client/v4/zones/${zoneId}/email/routing/rules?page=${page}&per_page=${RULES_PER_PAGE}`;
96
+ const response = await fetch(url, { headers: { Authorization: `Bearer ${creds.apiToken}` } });
97
+ if (!response.ok) {
98
+ throw new CloudflareRequestError({
99
+ message: "Could not read the zone's Email Routing rules.",
100
+ action: "Check the token carries Email Routing Rules: Read on this zone.",
101
+ detail: `Email Routing rule list returned ${response.status}.`,
102
+ });
103
+ }
104
+
105
+ const envelope = RuleListEnvelope.parse(await response.json());
106
+ const batch = envelope.result ?? [];
107
+ rules.push(...batch);
108
+
109
+ const totalPages = envelope.result_info?.total_pages;
110
+ if (totalPages !== undefined ? page >= totalPages : batch.length < RULES_PER_PAGE) break;
111
+ }
112
+
113
+ return rules;
114
+ }
115
+
116
+ /**
117
+ * The rules somebody named — the zone's own rules, without the unnamed catch-all Cloudflare puts on
118
+ * every zone configured or not.
119
+ */
120
+ export function namedRules(rules: readonly EmailRoutingRule[]): EmailRoutingRule[] {
121
+ return rules.filter((rule) => rule.name !== "");
122
+ }
@@ -0,0 +1,31 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { reportFixtureEstate } from "./fixtures";
5
+
6
+ /**
7
+ * The `globalSetup` for an integration config whose suites **create nothing** — the fixture report, and
8
+ * no debris sweep.
9
+ *
10
+ * `integrationSetup.ts` beside this does both, and it is the right entry point for a package that mints
11
+ * a Worker, a database or a bucket. A package whose live suites only *read* a third party — a token
12
+ * endpoint, a siteverify — has nothing to reclaim, and pointing it at the sweeping setup would mean
13
+ * every run of it deleted stale resources across the whole account on behalf of suites it does not run.
14
+ * That is somebody else's housekeeping, done at a surprising moment, and it costs a run half a minute
15
+ * of REST calls that answer nothing about the code under test.
16
+ *
17
+ * The report half is not optional either way, which is the whole reason this file exists rather than
18
+ * the config simply declaring no `globalSetup`. Vitest runs no hooks inside a `describe.skipIf(true)`,
19
+ * so a suite that skips for want of a fixture is exactly the suite that cannot say which fixture — see
20
+ * the long note in `fixtures.ts`. A config with no `globalSetup` gets a silent skip, and a silent skip
21
+ * is the failure mode #106 exists to remove.
22
+ *
23
+ * **Switch a config to `integrationSetup` the moment one of its suites creates a Cloudflare resource.**
24
+ * A suite that mints and a run that never sweeps is how debris becomes permanent.
25
+ *
26
+ * Never throws, for the same reason its neighbor does not: a report that fails the run it was meant to
27
+ * explain is worse than no report.
28
+ */
29
+ export default function setup(): void {
30
+ reportFixtureEstate();
31
+ }