@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,372 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
5
+ import { loadIntegrationEnv } from "./harness";
6
+
7
+ /**
8
+ * The live test fixture estate: which real-world things a live suite needs, whether this run has them,
9
+ * and what a run says when it does not.
10
+ *
11
+ * ## Absent means skip, never fail
12
+ *
13
+ * A contributor with no Cloudflare account, no Turnstile widget and no zone must be able to run the
14
+ * whole suite and get green. That is the rule, and both ways of getting it subtly wrong are worse than
15
+ * having no helper at all. A gate that **throws** on a missing fixture turns "you have no credentials"
16
+ * into "the kit is broken", and every one of those costs somebody an afternoon proving it is not. A gate
17
+ * that **silently passes** turns "nothing ran" into "everything is fine", which is how a release gate
18
+ * comes to certify a suite that has never executed.
19
+ *
20
+ * So the skip is loud. {@link reportFixtureEstate} runs from `globalSetup` — once per run, before a
21
+ * single suite is collected — and prints a line per fixture naming what is missing, what will therefore
22
+ * skip, and the document that creates it. The `describe.skipIf` in a suite is only the mechanism; this
23
+ * report is the sentence a human reads.
24
+ *
25
+ * **From `globalSetup` for the same reason the debris sweep is** (see `integrationSetup.ts`): Vitest runs
26
+ * no hooks inside a `describe.skipIf(true)`, so anything living in a suite hook is gated on exactly the
27
+ * condition it exists to report. A report that goes quiet precisely when there is something to say is
28
+ * not a report.
29
+ *
30
+ * ## A value never appears
31
+ *
32
+ * These names resolve API tokens, OAuth client secrets and Turnstile secret keys. A line here names the
33
+ * **variable** and never its contents, in every outcome — including the malformed ones, where the
34
+ * temptation to print what was found is strongest. `fixtures.test.ts` plants a distinctive value and
35
+ * asserts no report line contains it.
36
+ */
37
+
38
+ /**
39
+ * How a fixture's value is judged.
40
+ *
41
+ * `credential` — any usable text is the fixture. An account id, a token, a zone id, a sitekey.
42
+ *
43
+ * `switch` — a deliberate opt-in, where the *word* is the whole meaning. `PITHY_LIVE_DEPLOY` is the one
44
+ * that matters: it arms a suite that deploys real Workers and deletes them again, so `0` has to mean no
45
+ * rather than "a non-empty string, therefore yes". Judging it as a credential is not a style choice —
46
+ * it is the difference between an opt-out and a deploy.
47
+ */
48
+ export type FixtureShape = "credential" | "switch";
49
+
50
+ /** One live fixture: what it is, where its value comes from, what skips without it, and how to make it. */
51
+ export interface LiveFixture {
52
+ /** The fixture's name — what a skip line says, and the anchor its documentation lives under. */
53
+ name: string;
54
+ /** Every environment variable that must resolve before the fixture is usable. All of them, not any. */
55
+ keys: readonly string[];
56
+ /** How the value is judged. See {@link FixtureShape}. */
57
+ shape: FixtureShape;
58
+ /**
59
+ * What a run loses without it, as a whole sentence — "Turnstile sign-in gating (#84) skips."
60
+ *
61
+ * A sentence rather than a noun phrase, because the report is read by somebody deciding whether the
62
+ * green they just got means anything, and "the Custom Hostnames lifecycle" does not answer that.
63
+ */
64
+ consequence: string;
65
+ /** The document section that creates it. Must exist; `fixtures.test.ts` proves every one of them does. */
66
+ doc: string;
67
+ }
68
+
69
+ /**
70
+ * What this run has, per fixture.
71
+ *
72
+ * Four outcomes rather than a boolean, because three different things skip a suite and only one of them
73
+ * is fine. **`absent`** is the contributor with no credentials, and is the normal case. **`declined`** is
74
+ * a switch deliberately turned off. **`malformed`** is somebody who tried to set the fixture and failed —
75
+ * a CI export of an unset secret, a shell interpolation that produced the literal text `undefined`.
76
+ *
77
+ * All three skip. Only the reporting differs, and that is the whole point: #323 landed the same
78
+ * distinction one layer down — *"'not recorded' is a claim about the file, and it is now made only when
79
+ * the file makes no claim"* — after two investigations died inside a "missing" that meant "malformed".
80
+ * A fixture blanked by a broken export must not read as a fixture nobody configured.
81
+ */
82
+ export type FixtureOutcome = "present" | "absent" | "malformed" | "declined";
83
+
84
+ /** One key's own verdict, and why — the grain the fixture's sentence is assembled from. */
85
+ interface KeyVerdict {
86
+ /** The variable name. Never accompanied by its value. */
87
+ key: string;
88
+ /** This key's outcome, before the fixture's keys are folded together. */
89
+ outcome: FixtureOutcome;
90
+ /** Why, in a fragment that reads after the key name: "is not set", "is set to an empty value". */
91
+ note: string;
92
+ }
93
+
94
+ /** A fixture, resolved against one environment. */
95
+ export interface FixtureResolution {
96
+ /** The fixture that was resolved. */
97
+ fixture: LiveFixture;
98
+ /** The fixture's outcome — the worst of its keys'. */
99
+ outcome: FixtureOutcome;
100
+ /** True only for `present`. The boolean a `describe.skipIf` negates. */
101
+ ready: boolean;
102
+ /** One sentence naming the offending variables and their state. Never a value. */
103
+ reason: string;
104
+ }
105
+
106
+ /** Words a switch reads as on. Case-insensitive. */
107
+ const SWITCH_ON: ReadonlySet<string> = new Set(["1", "true", "yes", "on"]);
108
+
109
+ /** Words a switch reads as off — a deliberate decline, not a misconfiguration. */
110
+ const SWITCH_OFF: ReadonlySet<string> = new Set(["0", "false", "no", "off"]);
111
+
112
+ /**
113
+ * Text a value must never be, whatever its shape.
114
+ *
115
+ * These are not values. They are what a templating layer writes when it had nothing: `export
116
+ * KEY=$MISSING` under some shells, a JSON `null` stringified, a GitHub Actions expression that resolved
117
+ * to nothing and got quoted anyway. Each is a non-empty string, so every `Boolean(value)` check in the
118
+ * world reads it as present and hands it to Cloudflare, which answers 401 three frames later.
119
+ */
120
+ const PLACEHOLDER_TEXT: ReadonlySet<string> = new Set(["undefined", "null"]);
121
+
122
+ /** Worst-first, so folding a fixture's keys is a `Math.min` over this order. */
123
+ const OUTCOME_RANK: Readonly<Record<FixtureOutcome, number>> = {
124
+ malformed: 0,
125
+ absent: 1,
126
+ declined: 2,
127
+ present: 3,
128
+ };
129
+
130
+ /**
131
+ * The estate, declared once.
132
+ *
133
+ * Every live fixture the repository knows about, whether or not a suite reads it yet — a fixture that
134
+ * exists only in the head of whoever wrote the suite is a fixture nobody can be told to create. The
135
+ * `consequence` line names the issue, so the report is a to-do list for anyone who wants a red X to become
136
+ * a run.
137
+ *
138
+ * Alphabetical, as the reap plan is, so the set reads as a list rather than as an accident of the order
139
+ * somebody happened to add things in.
140
+ */
141
+ export const LIVE_FIXTURES = {
142
+ "cloudflare-account": {
143
+ name: "cloudflare-account",
144
+ keys: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"],
145
+ shape: "credential",
146
+ consequence: "Every live suite skips, and the run sweeps no debris.",
147
+ doc: "docs/FIXTURES.md#cloudflare-account",
148
+ },
149
+ "custom-hostname-zones": {
150
+ name: "custom-hostname-zones",
151
+ keys: ["CUSTOM_HOSTNAME_ZONE_ID", "CUSTOM_HOSTNAME_CUSTOMER_ZONE_ID"],
152
+ shape: "credential",
153
+ consequence: "The Custom Hostnames lifecycle (#41) skips.",
154
+ doc: "docs/FIXTURES.md#custom-hostname-zones",
155
+ },
156
+ "email-routing": {
157
+ name: "email-routing",
158
+ keys: ["EMAIL_ROUTING_ZONE_ID", "EMAIL_ROUTING_ADDRESS"],
159
+ shape: "credential",
160
+ consequence: "The inbound Email Routing rule (#47) skips.",
161
+ doc: "docs/FIXTURES.md#email-routing",
162
+ },
163
+ "email-sending": {
164
+ name: "email-sending",
165
+ keys: ["EMAIL_SENDING_FROM"],
166
+ shape: "credential",
167
+ consequence: "The inbound delivery round trip (#47) skips: nothing can post a message to the routed address.",
168
+ doc: "docs/FIXTURES.md#email-sending",
169
+ },
170
+ "google-oauth": {
171
+ name: "google-oauth",
172
+ keys: ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"],
173
+ shape: "credential",
174
+ consequence: "The Google provider suite, on localhost (#84), skips.",
175
+ doc: "docs/FIXTURES.md#google-oauth",
176
+ },
177
+ "live-deploy": {
178
+ name: "live-deploy",
179
+ keys: ["PITHY_LIVE_DEPLOY"],
180
+ shape: "switch",
181
+ consequence: "The secrets provision, write, rotate and teardown round trip skips.",
182
+ doc: "docs/FIXTURES.md#live-deploy",
183
+ },
184
+ "r2-s3-keys": {
185
+ name: "r2-s3-keys",
186
+ keys: ["R2_CREDENTIALS"],
187
+ shape: "credential",
188
+ consequence: "The R2 presigned-URL suite skips, and stale buckets are not reclaimed.",
189
+ doc: "docs/FIXTURES.md#r2-s3-keys",
190
+ },
191
+ "secrets-store": {
192
+ name: "secrets-store",
193
+ keys: ["SECRETS_STORE_ID"],
194
+ shape: "credential",
195
+ consequence: "The Secrets Store suites skip, and stale entries are not reclaimed.",
196
+ doc: "docs/FIXTURES.md#secrets-store",
197
+ },
198
+ "turnstile-widget": {
199
+ name: "turnstile-widget",
200
+ keys: ["TURNSTILE_SITE_KEY", "TURNSTILE_SECRET_KEY"],
201
+ shape: "credential",
202
+ consequence: "Turnstile sign-in gating on a workers.dev hostname (#84) skips.",
203
+ doc: "docs/FIXTURES.md#turnstile-widget",
204
+ },
205
+ } as const satisfies Record<string, LiveFixture>;
206
+
207
+ /** Every declared fixture name. A typo is a type error rather than a silently skipped suite. */
208
+ export type FixtureName = keyof typeof LIVE_FIXTURES;
209
+
210
+ /** How a fixture is resolved: from an injected environment, or from the run's own. */
211
+ export interface FixtureOptions {
212
+ /**
213
+ * The environment to read. Defaults to {@link fixtureEnv}.
214
+ *
215
+ * A test passes its own, so a maintainer's real `.dev.vars` can never decide whether a unit test
216
+ * passes — the failure mode `vitest.setup.ts` exists to prevent, one level up.
217
+ */
218
+ env?: Record<string, string | undefined>;
219
+ }
220
+
221
+ /**
222
+ * The environment a fixture resolves against: `packages/cloudflare/.dev.vars` first, then `process.env`.
223
+ *
224
+ * The same pair {@link loadIntegrationEnv} already reads for credentials, so a fixture and a credential
225
+ * cannot disagree about which file they came from. `.dev.vars` wins where both state a key, because a
226
+ * file somebody wrote on purpose beats a variable their shell exported hours ago.
227
+ */
228
+ export function fixtureEnv(): Record<string, string | undefined> {
229
+ return { ...process.env, ...loadIntegrationEnv() };
230
+ }
231
+
232
+ /** Judge one variable, with no knowledge of the fixture it belongs to. */
233
+ function verdictFor(key: string, shape: FixtureShape, raw: string | undefined): KeyVerdict {
234
+ if (raw === undefined) return { key, outcome: "absent", note: "is not set" };
235
+ if (raw === "") return { key, outcome: "malformed", note: "is set to an empty value" };
236
+
237
+ const value = raw.trim();
238
+ if (value === "") return { key, outcome: "malformed", note: "is set to whitespace" };
239
+ if (PLACEHOLDER_TEXT.has(value.toLowerCase())) {
240
+ return { key, outcome: "malformed", note: "is set to placeholder text, not a value" };
241
+ }
242
+
243
+ if (shape === "switch") {
244
+ const word = value.toLowerCase();
245
+ if (SWITCH_ON.has(word)) return { key, outcome: "present", note: "is on" };
246
+ if (SWITCH_OFF.has(word)) return { key, outcome: "declined", note: "is off" };
247
+ return { key, outcome: "malformed", note: "is set to a word that is neither on nor off" };
248
+ }
249
+
250
+ return { key, outcome: "present", note: "is set" };
251
+ }
252
+
253
+ /** Fold a fixture's keys into one outcome: the worst wins, so one broken key is never averaged away. */
254
+ function foldOutcome(verdicts: readonly KeyVerdict[]): FixtureOutcome {
255
+ let worst: FixtureOutcome = "present";
256
+ for (const verdict of verdicts) {
257
+ if (OUTCOME_RANK[verdict.outcome] < OUTCOME_RANK[worst]) worst = verdict.outcome;
258
+ }
259
+ return worst;
260
+ }
261
+
262
+ /** The sentence for a fixture's outcome: every key in the offending state, and what state that is. */
263
+ function reasonFor(outcome: FixtureOutcome, verdicts: readonly KeyVerdict[]): string {
264
+ if (outcome === "present") return "Ready.";
265
+ const offenders = verdicts.filter((verdict) => verdict.outcome === outcome);
266
+ const note = offenders[0]?.note ?? "is not set";
267
+ const keys = offenders.map((verdict) => verdict.key).join(", ");
268
+ return `${keys} ${offenders.length === 1 ? note : note.replace(/^is /, "are ")}.`;
269
+ }
270
+
271
+ /**
272
+ * Resolve one fixture against an environment.
273
+ *
274
+ * Never throws and never reaches the network. It answers what this run has; deciding what to do about it
275
+ * is {@link fixtureReady}'s job, and saying so out loud is {@link reportFixtureEstate}'s.
276
+ */
277
+ export function resolveFixture(name: FixtureName, options: FixtureOptions = {}): FixtureResolution {
278
+ const fixture: LiveFixture = LIVE_FIXTURES[name];
279
+ const env = options.env ?? fixtureEnv();
280
+ const verdicts = fixture.keys.map((key) => verdictFor(key, fixture.shape, env[key]));
281
+ const outcome = foldOutcome(verdicts);
282
+ return { fixture, outcome, ready: outcome === "present", reason: reasonFor(outcome, verdicts) };
283
+ }
284
+
285
+ /**
286
+ * Whether a suite gated on this fixture can run — the boolean a `describe.skipIf` negates:
287
+ *
288
+ * ```ts
289
+ * describe.skipIf(!fixtureReady("turnstile-widget"))("turnstile — LIVE", () => { … });
290
+ * ```
291
+ *
292
+ * False for every outcome that is not `present`, malformed included. The distinction between a fixture
293
+ * nobody configured and one somebody configured wrongly belongs in the report, not in whether the suite
294
+ * runs: neither can be tested against, and a run that failed on the second would fail a contributor
295
+ * whose CI template exports blanks — the same "the kit is broken" this file exists to prevent.
296
+ */
297
+ export function fixtureReady(name: FixtureName, options: FixtureOptions = {}): boolean {
298
+ return resolveFixture(name, options).ready;
299
+ }
300
+
301
+ /**
302
+ * One of a ready fixture's values.
303
+ *
304
+ * Throws when the fixture is not ready, and that is not a contradiction of the rule above: reaching here
305
+ * means a suite read a value it never gated on, which is a defect in the suite rather than a missing
306
+ * credential. The error names the key and never carries a value — `detail` reaches a log.
307
+ */
308
+ export function fixtureValue(name: FixtureName, key: string, options: FixtureOptions = {}): string {
309
+ const fixture: LiveFixture = LIVE_FIXTURES[name];
310
+ if (!fixture.keys.includes(key)) {
311
+ throw new ValidationError({
312
+ message: `Fixture ${name} declares no ${key}.`,
313
+ action: `Read one of: ${fixture.keys.join(", ")}.`,
314
+ detail: `fixtureValue was asked for ${key}, which is not in the ${name} fixture's keys.`,
315
+ });
316
+ }
317
+ const resolution = resolveFixture(name, options);
318
+ if (!resolution.ready) {
319
+ throw new ValidationError({
320
+ message: `Fixture ${name} is ${resolution.outcome}.`,
321
+ action: `Gate the suite with fixtureReady("${name}"), or create the fixture: ${fixture.doc}.`,
322
+ detail: `${resolution.reason} A suite read a fixture value it had not gated on.`,
323
+ });
324
+ }
325
+ const env = options.env ?? fixtureEnv();
326
+ return (env[key] ?? "").trim();
327
+ }
328
+
329
+ /**
330
+ * The lines a run prints about its fixtures — pure, so a test reads exactly what a human would.
331
+ *
332
+ * One line per fixture, then a count. A present fixture still gets a line: a report that only speaks when
333
+ * something is wrong cannot tell "the estate is complete" from "the report never ran", and that is the
334
+ * same indistinguishability this whole file is about.
335
+ */
336
+ export function fixtureReportLines(resolutions: readonly FixtureResolution[]): string[] {
337
+ const lines = resolutions.map((resolution) => {
338
+ if (resolution.ready) return `fixture present: ${resolution.fixture.name}.`;
339
+ return [
340
+ `fixture ${resolution.outcome}: ${resolution.fixture.name}.`,
341
+ resolution.reason,
342
+ resolution.fixture.consequence,
343
+ `See ${resolution.fixture.doc}.`,
344
+ ].join(" ");
345
+ });
346
+
347
+ const counted = (outcome: FixtureOutcome) => resolutions.filter((entry) => entry.outcome === outcome).length;
348
+ lines.push(
349
+ `fixtures: ${counted("present")} present, ${counted("absent")} absent, ` +
350
+ `${counted("malformed")} malformed, ${counted("declined")} declined. ` +
351
+ "A skipped suite is not a passing one.",
352
+ );
353
+ return lines;
354
+ }
355
+
356
+ /**
357
+ * Resolve every fixture, say what the run has, and hand back the resolutions.
358
+ *
359
+ * The `globalSetup` entry point. Never throws — a report that fails the run it was meant to explain is
360
+ * worse than no report, exactly as the debris sweep beside it is.
361
+ */
362
+ export function reportFixtureEstate(options: FixtureOptions = {}): FixtureResolution[] {
363
+ let resolutions: FixtureResolution[] = [];
364
+ try {
365
+ const env = options.env ?? fixtureEnv();
366
+ resolutions = (Object.keys(LIVE_FIXTURES) as FixtureName[]).map((name) => resolveFixture(name, { env }));
367
+ for (const line of fixtureReportLines(resolutions)) console.warn(line);
368
+ } catch (error) {
369
+ console.warn(`the fixture estate could not be reported: ${error instanceof Error ? error.message : String(error)}`);
370
+ }
371
+ return resolutions;
372
+ }