@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,413 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import {
|
|
8
|
+
AbortMultipartUploadCommand,
|
|
9
|
+
DeleteObjectCommand,
|
|
10
|
+
ListMultipartUploadsCommand,
|
|
11
|
+
ListObjectsV2Command,
|
|
12
|
+
S3Client,
|
|
13
|
+
} from "@aws-sdk/client-s3";
|
|
14
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
15
|
+
import { fitSegment, kebab, MAX_RESOURCE_NAME, RESERVED_TEST_PREFIX } from "@pithy-sh/core/src/naming/resource";
|
|
16
|
+
import { CloudflareClients } from "../client/clients";
|
|
17
|
+
import { CLOUDFLARE_ENV_KEYS, parseDevVars } from "../env/devVars";
|
|
18
|
+
import { R2Credentials } from "../r2/r2Credentials";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Shared scaffolding for the package's `*.integration.test.ts` live suites. Every live test does the
|
|
22
|
+
* same three things — find credentials, create a throwaway resource, and guarantee it is torn down —
|
|
23
|
+
* so that logic lives here once instead of being re-derived per manager. Generalized from the D1
|
|
24
|
+
* provisioner's first live test; see `README.md` § "Live integration tests" for the template.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* **This** package's root, whichever package's live suite is running — `@pithy-sh/storage` imports this
|
|
29
|
+
* harness, so its credentials come from `packages/cloudflare/.dev.vars` too, and a `.dev.vars` beside the
|
|
30
|
+
* importing package is read by nothing. See `README.md` § "Live integration tests".
|
|
31
|
+
*
|
|
32
|
+
* Nothing creates that file, and nothing in the CLI reads one any more — adopter credentials moved to
|
|
33
|
+
* the account-scoped `<config>/cloudflare.json` (#182), and this package cannot resolve that directory
|
|
34
|
+
* without a second implementation of where Pithy's config lives. So this stays a **maintainer-authored
|
|
35
|
+
* file for the live suites and nothing else**: no command writes it, no adopter has one, and it holds
|
|
36
|
+
* whatever account the person running `test:integration` wants the throwaway resources created in.
|
|
37
|
+
* Write it by hand, or export the keys — {@link loadIntegrationEnv} overlays `process.env` per key for
|
|
38
|
+
* anything the file does not set, which is how CI runs with no file at all.
|
|
39
|
+
*/
|
|
40
|
+
const PACKAGE_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The live-suite credentials: this package's own `.dev.vars`, then `process.env` per key for anything it
|
|
44
|
+
* did not set. The `.dev.vars` half is why this is here rather than in `../env/devVars` — nothing outside
|
|
45
|
+
* a live test reads a `.dev.vars` for credentials now, and a shared function that still did would be an
|
|
46
|
+
* invitation to point a command back at the checkout.
|
|
47
|
+
*
|
|
48
|
+
* Exported for `fixtures.ts`, which resolves the whole fixture estate against the same pair. Two readers
|
|
49
|
+
* of two files is how a suite and its own report come to disagree about what this run has.
|
|
50
|
+
*/
|
|
51
|
+
export function loadIntegrationEnv(): Record<string, string> {
|
|
52
|
+
let vars: Record<string, string> = {};
|
|
53
|
+
try {
|
|
54
|
+
vars = parseDevVars(readFileSync(path.join(PACKAGE_ROOT, ".dev.vars"), "utf8"));
|
|
55
|
+
} catch {
|
|
56
|
+
// No file — rely on the environment overlay below.
|
|
57
|
+
}
|
|
58
|
+
for (const key of CLOUDFLARE_ENV_KEYS) {
|
|
59
|
+
const fromEnv = process.env[key];
|
|
60
|
+
if (!vars[key] && fromEnv) vars[key] = fromEnv;
|
|
61
|
+
}
|
|
62
|
+
return vars;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Cloudflare credentials for a live run, plus whether enough of them are present to run at all. */
|
|
66
|
+
export interface IntegrationCreds {
|
|
67
|
+
/** The target account id (`CLOUDFLARE_ACCOUNT_ID`). Empty string when unset. */
|
|
68
|
+
accountId: string;
|
|
69
|
+
/** The scoped API token (`CLOUDFLARE_API_TOKEN`). Empty string when unset. */
|
|
70
|
+
apiToken: string;
|
|
71
|
+
/** The Secrets Store id (`SECRETS_STORE_ID`), for the secrets-store live test. Empty string when unset. */
|
|
72
|
+
secretsStoreId: string;
|
|
73
|
+
/** R2 S3 keys (from `R2_CREDENTIALS` JSON), for the R2 presigned-URL test. Null when unset. */
|
|
74
|
+
r2: R2Credentials | null;
|
|
75
|
+
/** True only when both an account id and a token are present — the gate for `describe.skipIf`. */
|
|
76
|
+
hasCreds: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parse the `R2_CREDENTIALS` JSON blob through the canonical {@link R2Credentials} schema — the same
|
|
81
|
+
* object the R2 storage feature manages as a JSON payload, so the harness and the feature validate it
|
|
82
|
+
* identically. Returns null when unset (the R2 suite then skips); a set-but-malformed/invalid blob
|
|
83
|
+
* fails `.parse()` and throws, surfacing the misconfiguration loudly rather than silently skipping.
|
|
84
|
+
*/
|
|
85
|
+
export function parseR2Creds(raw: string | undefined): R2Credentials | null {
|
|
86
|
+
if (!raw) return null;
|
|
87
|
+
return R2Credentials.parse(JSON.parse(raw));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Load the live-CF credentials a `*.integration.test.ts` needs, reading the package `.dev.vars` and
|
|
92
|
+
* overlaying `process.env` (the loader's own fallback, so CI passes them as plain env vars). Gate the
|
|
93
|
+
* suite with `describe.skipIf(!loadIntegrationCreds().hasCreds)` so it skips cleanly with no creds.
|
|
94
|
+
*/
|
|
95
|
+
export function loadIntegrationCreds(): IntegrationCreds {
|
|
96
|
+
const vars = loadIntegrationEnv();
|
|
97
|
+
const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
|
|
98
|
+
const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
|
|
99
|
+
return {
|
|
100
|
+
accountId,
|
|
101
|
+
apiToken,
|
|
102
|
+
secretsStoreId: vars.SECRETS_STORE_ID ?? "",
|
|
103
|
+
r2: parseR2Creds(vars.R2_CREDENTIALS),
|
|
104
|
+
hasCreds: Boolean(accountId && apiToken),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The project name a live test provisions under, so that names composed by the *product's* provisioners —
|
|
110
|
+
* `<project>-<env>-<thing>`, where the project segment is verbatim and first — land inside the reservation
|
|
111
|
+
* too. A live test of `pithy provision --feature` cannot route through {@link uniqueName}: the names are the
|
|
112
|
+
* thing under test. Giving it a reserved project puts them in the namespace anyway.
|
|
113
|
+
*
|
|
114
|
+
* The reservation itself is {@link RESERVED_TEST_PREFIX}, in `@pithy-sh/core`. It lives beside the name
|
|
115
|
+
* composer because both sides of it are naming facts: this harness mints inside it, and `scaffoldProject`
|
|
116
|
+
* refuses to mint a project that would land in it. One literal, or the two drift apart.
|
|
117
|
+
*/
|
|
118
|
+
export const RESERVED_TEST_PROJECT = `${RESERVED_TEST_PREFIX}test`;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The compatibility date every throwaway Worker a live suite uploads runs on.
|
|
122
|
+
*
|
|
123
|
+
* `createWorker` has no default any more (#396): a compatibility date is a behavior contract in
|
|
124
|
+
* somebody's account, so the caller names it. That makes every live suite a caller, and one place for
|
|
125
|
+
* them to read it from is the difference between one date and six.
|
|
126
|
+
*
|
|
127
|
+
* **It is stated rather than imported, and the reason is a wall rather than a preference.** The floor
|
|
128
|
+
* lives in `compatibility.ts` at the repository root, and this package's `rootDir` is `src` — a static
|
|
129
|
+
* import of a file above it is TS6059, the same wall `compatibilityDates.test.ts` records for
|
|
130
|
+
* `packages/cli`. So this is a fixture stating a date, which is the one shape that gate excludes on
|
|
131
|
+
* purpose. Keep it level with the floor when the floor moves.
|
|
132
|
+
*/
|
|
133
|
+
export const INTEGRATION_COMPATIBILITY_DATE = "2026-06-01";
|
|
134
|
+
|
|
135
|
+
/** Deduplicates names minted within one test file; the random suffix covers the cross-file case. */
|
|
136
|
+
let nameCounter = 0;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Mint a collision-proof name for a throwaway live resource:
|
|
140
|
+
* `pithy-int-<label>-<timestamp>-<counter>-<rand>`.
|
|
141
|
+
*
|
|
142
|
+
* The caller supplies only the distinguishing part — {@link RESERVED_TEST_PREFIX} is composed on, never
|
|
143
|
+
* passed in. That is the point: when the prefix was a *default* a caller could override, a suite could
|
|
144
|
+
* silently create resources under a name the reaper does not recognize, which is exactly how orphans
|
|
145
|
+
* came to sit on a real account. A label that already carries the prefix is refused rather than doubled.
|
|
146
|
+
*
|
|
147
|
+
* The 6-char random suffix guarantees uniqueness across separate test files (Vitest isolates each file, so
|
|
148
|
+
* the counter only deduplicates within one); the timestamp is what {@link testResourceAge} reads, so every
|
|
149
|
+
* name this mints is reapable by construction. The label is kebabbed and fitted, so the result always
|
|
150
|
+
* stays inside R2's {@link MAX_RESOURCE_NAME} cap and the lowercase `a-z0-9-` charset every CF namespace
|
|
151
|
+
* accepts, whatever the caller passed.
|
|
152
|
+
*/
|
|
153
|
+
export function uniqueName(label: string): string {
|
|
154
|
+
const segment = kebab(label);
|
|
155
|
+
if (!segment) {
|
|
156
|
+
throw new ValidationError({
|
|
157
|
+
message: "A throwaway resource needs a label.",
|
|
158
|
+
action: "Pass a short label, e.g. uniqueName('kv').",
|
|
159
|
+
detail: `uniqueName received ${JSON.stringify(label)}, which kebabs to nothing.`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (segment.startsWith(RESERVED_TEST_PREFIX)) {
|
|
163
|
+
throw new ValidationError({
|
|
164
|
+
message: `A throwaway label must not repeat the reserved "${RESERVED_TEST_PREFIX}" prefix.`,
|
|
165
|
+
action: `Pass only the distinguishing part, e.g. uniqueName('${segment.slice(RESERVED_TEST_PREFIX.length) || "kv"}').`,
|
|
166
|
+
detail: `uniqueName composes ${RESERVED_TEST_PREFIX} itself; ${JSON.stringify(label)} would double it.`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
nameCounter += 1;
|
|
171
|
+
// `Math.random()` can produce a short base-36 string, and a name must never end on a hyphen.
|
|
172
|
+
const rand = Math.random().toString(36).slice(2, 8).padEnd(6, "0");
|
|
173
|
+
const suffix = `-${Date.now()}-${nameCounter}-${rand}`;
|
|
174
|
+
const budget = MAX_RESOURCE_NAME - RESERVED_TEST_PREFIX.length - suffix.length;
|
|
175
|
+
return `${RESERVED_TEST_PREFIX}${fitSegment(segment, budget)}${suffix}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Create a live resource, exercise it, and guarantee teardown — even if `exercise` throws. This is the
|
|
180
|
+
* spine of every live integration test: a failed assertion must never orphan a real Cloudflare resource.
|
|
181
|
+
* `create` runs outside the `try`, so a creation failure does not call `teardown` on something that was
|
|
182
|
+
* never created; once `create` resolves, `teardown` always runs in the `finally`. The exercise result is
|
|
183
|
+
* returned for the rare caller that wants it.
|
|
184
|
+
*/
|
|
185
|
+
export async function withThrowawayResource<R, T>(
|
|
186
|
+
create: () => Promise<R>,
|
|
187
|
+
exercise: (resource: R) => Promise<T>,
|
|
188
|
+
teardown: (resource: R) => Promise<void>,
|
|
189
|
+
): Promise<T> {
|
|
190
|
+
const resource = await create();
|
|
191
|
+
try {
|
|
192
|
+
return await exercise(resource);
|
|
193
|
+
} finally {
|
|
194
|
+
await teardown(resource);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Create a live resource **at a name the caller already chose**, exercise it, and guarantee teardown —
|
|
200
|
+
* including when `create` itself rejects.
|
|
201
|
+
*
|
|
202
|
+
* {@link withThrowawayResource} runs `create` outside the `try` on purpose: for a kind whose id only
|
|
203
|
+
* exists once creation succeeds, tearing down a failed create would be tearing down nothing. But a whole
|
|
204
|
+
* family of Cloudflare calls are **named writes** — `putSecret(name, …)`, `createBucket(name)`,
|
|
205
|
+
* `createDatabase(name)`, `createWorker(name)` — where the address is the caller's own argument. There a
|
|
206
|
+
* rejection is ambiguous: the server may have accepted the write and failed on the way back, and the
|
|
207
|
+
* resource now exists under a name the test is still holding. `withThrowawayResource` walks away from it.
|
|
208
|
+
*
|
|
209
|
+
* This variant tears down on the **name**, and arms the teardown before `create` is ever called, so the
|
|
210
|
+
* ambiguous case cleans up. `teardown` must therefore tolerate a resource that was never created — every
|
|
211
|
+
* caller here passes an idempotent delete, and the reaper is the backstop if one is not.
|
|
212
|
+
*/
|
|
213
|
+
export async function withNamedResource<T>(
|
|
214
|
+
name: string,
|
|
215
|
+
create: (name: string) => Promise<unknown>,
|
|
216
|
+
exercise: (name: string) => Promise<T>,
|
|
217
|
+
teardown: (name: string) => Promise<void>,
|
|
218
|
+
): Promise<T> {
|
|
219
|
+
try {
|
|
220
|
+
await create(name);
|
|
221
|
+
return await exercise(name);
|
|
222
|
+
} finally {
|
|
223
|
+
await teardown(name);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* How old a resource must be before a reaper will remove it.
|
|
229
|
+
*
|
|
230
|
+
* **Twelve hours, and the size is the safety argument.** Reaping is a race with any suite that is still
|
|
231
|
+
* running: a resource deleted mid-flight surfaces as an unexplained 404 in a test that was passing, which
|
|
232
|
+
* is a far worse failure than debris. Cloudflare offers no way to mark a D1, KV namespace, bucket, or
|
|
233
|
+
* index as owned by a live process — no lease, no tag our client can set on every kind — so the only
|
|
234
|
+
* defense available is a window nothing plausible can cross.
|
|
235
|
+
*
|
|
236
|
+
* A single live test is capped at 120s and a hook at 120s (`vitest.integration.config.ts`); a whole
|
|
237
|
+
* package's suite is minutes, and every package's suites together are well under an hour even when
|
|
238
|
+
* Vectorize is settling. Twelve hours is two orders of magnitude past that, and still short enough that
|
|
239
|
+
* debris never survives a day. The cost of the wider window is a handful of orphans lingering longer;
|
|
240
|
+
* the cost of the narrower one is a green suite turned red by its own housekeeping.
|
|
241
|
+
*/
|
|
242
|
+
export const DEFAULT_STALE_AFTER_MS = 12 * 60 * 60 * 1000;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The ms-epoch {@link uniqueName} embedded, or `null` if this is not one of our names.
|
|
246
|
+
*
|
|
247
|
+
* `uniqueName` composes `pithy-int-<label>-<epoch>-<counter>-<rand>`, so the timestamp is the third
|
|
248
|
+
* segment from the end. Anything that does not match that shape returns `null` and is therefore never
|
|
249
|
+
* reaped — including a name inside the reservation that some *other* generator produced, such as a
|
|
250
|
+
* feature resource provisioned under {@link RESERVED_TEST_PROJECT}. Conservative on purpose: failing to
|
|
251
|
+
* clean up costs pennies, deleting someone's data does not.
|
|
252
|
+
*/
|
|
253
|
+
export function testResourceAge(name: string, now: number): number | null {
|
|
254
|
+
if (!name.startsWith(RESERVED_TEST_PREFIX)) return null;
|
|
255
|
+
const segments = name.split("-");
|
|
256
|
+
if (segments.length < 3) return null;
|
|
257
|
+
const stamp = Number(segments[segments.length - 3]);
|
|
258
|
+
if (!Number.isInteger(stamp) || stamp <= 0 || stamp > now) return null;
|
|
259
|
+
return now - stamp;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Which of these names are stale test debris — ours, and old enough that no running suite owns them.
|
|
264
|
+
*
|
|
265
|
+
* Pure, so the age arithmetic is unit-tested without touching Cloudflare.
|
|
266
|
+
*/
|
|
267
|
+
export function staleTestResourceNames(
|
|
268
|
+
names: readonly string[],
|
|
269
|
+
options: { now?: number; staleAfterMs?: number } = {},
|
|
270
|
+
): string[] {
|
|
271
|
+
const now = options.now ?? Date.now();
|
|
272
|
+
const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
273
|
+
return names.filter((name) => {
|
|
274
|
+
const age = testResourceAge(name, now);
|
|
275
|
+
return age !== null && age >= staleAfterMs;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** One reapable resource kind: what it is called, how to list it, and how to remove one. */
|
|
280
|
+
export interface ReapableKind {
|
|
281
|
+
/** Human label for the reap log, e.g. "Vectorize index". */
|
|
282
|
+
label: string;
|
|
283
|
+
/** Every resource name of this kind currently in the account. */
|
|
284
|
+
list: () => Promise<string[]>;
|
|
285
|
+
/** Remove one by name. Must be idempotent — another runner may have reaped it first. */
|
|
286
|
+
remove: (name: string) => Promise<void>;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Remove stale throwaway resources of one kind, and report what went.
|
|
291
|
+
*
|
|
292
|
+
* `withThrowawayResource` guarantees teardown only while the process lives. A run killed by a test
|
|
293
|
+
* timeout, a Ctrl-C, or a crash orphans whatever it had created — which is exactly how two Vectorize
|
|
294
|
+
* indexes came to sit on a real account for a month. Calling this in a suite's `beforeAll` makes each
|
|
295
|
+
* run clean up after the last one, so the failure mode self-heals instead of accumulating.
|
|
296
|
+
*
|
|
297
|
+
* Never throws: a reaper that fails must not fail the suite it is trying to help. Failures are reported
|
|
298
|
+
* in the returned list of what could not be removed.
|
|
299
|
+
*/
|
|
300
|
+
export async function reapStaleTestResources(
|
|
301
|
+
kind: ReapableKind,
|
|
302
|
+
options: { now?: number; staleAfterMs?: number } = {},
|
|
303
|
+
): Promise<{ reaped: string[]; failed: string[] }> {
|
|
304
|
+
const reaped: string[] = [];
|
|
305
|
+
const failed: string[] = [];
|
|
306
|
+
let names: string[];
|
|
307
|
+
try {
|
|
308
|
+
names = await kind.list();
|
|
309
|
+
} catch {
|
|
310
|
+
return { reaped, failed };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
for (const name of staleTestResourceNames(names, options)) {
|
|
314
|
+
try {
|
|
315
|
+
await kind.remove(name);
|
|
316
|
+
reaped.push(name);
|
|
317
|
+
} catch {
|
|
318
|
+
failed.push(name);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (reaped.length > 0) console.warn(`reaped ${reaped.length} stale ${kind.label}(s): ${reaped.join(", ")}`);
|
|
323
|
+
if (failed.length > 0) console.warn(`could not reap ${failed.length} stale ${kind.label}(s): ${failed.join(", ")}`);
|
|
324
|
+
return { reaped, failed };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* An S3 client for the account's R2, built from the harness's own key pair.
|
|
329
|
+
*
|
|
330
|
+
* **R2 is the one kind the API token cannot reap.** Cloudflare refuses to delete a non-empty bucket, and
|
|
331
|
+
* emptying one is an S3-protocol operation — `ListObjectsV2`, `DeleteObject`, `AbortMultipartUpload` —
|
|
332
|
+
* that the REST API does not offer and a scoped API token cannot authenticate. So the harness holds the
|
|
333
|
+
* S3 credentials itself: {@link IntegrationCreds.r2}, parsed from `R2_CREDENTIALS`, is what makes a
|
|
334
|
+
* throwaway bucket reclaimable at all.
|
|
335
|
+
*
|
|
336
|
+
* Raw S3 rather than `CloudflareR2Manager` on purpose. This is teardown, and teardown must not depend on
|
|
337
|
+
* the code under test: if `emptyBucket` is the thing that broke, the bucket still has to come back empty
|
|
338
|
+
* or a failing test leaks a real resource instead of reporting a bug.
|
|
339
|
+
*/
|
|
340
|
+
function testS3(creds: IntegrationCreds): S3Client {
|
|
341
|
+
if (!creds.r2) {
|
|
342
|
+
throw new ValidationError({
|
|
343
|
+
message: "Emptying an R2 bucket needs the S3 key pair.",
|
|
344
|
+
action: "Set R2_CREDENTIALS in .dev.vars, or gate the suite on `creds.r2`.",
|
|
345
|
+
detail: "R2 refuses to delete a non-empty bucket, and draining one is an S3 operation, not a REST one.",
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
return new S3Client({
|
|
349
|
+
region: "auto",
|
|
350
|
+
endpoint: `https://${creds.accountId}.r2.cloudflarestorage.com`,
|
|
351
|
+
credentials: { accessKeyId: creds.r2.accessKeyId, secretAccessKey: creds.r2.secretAccessKey },
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Empty a throwaway bucket so R2 will delete it.
|
|
357
|
+
*
|
|
358
|
+
* Two things hold a bucket against deletion: stored objects, and the parts of a multipart upload that was
|
|
359
|
+
* never completed or aborted — precisely what a test that failed mid-upload leaves behind. Both are
|
|
360
|
+
* cleared, dangling uploads first. Every page is drained, so a bucket with more than a thousand keys is
|
|
361
|
+
* emptied rather than half-emptied.
|
|
362
|
+
*
|
|
363
|
+
* Throws when the S3 key pair is absent: a bucket that cannot be emptied cannot be deleted, and silence
|
|
364
|
+
* there is how debris becomes permanent.
|
|
365
|
+
*/
|
|
366
|
+
export async function emptyTestBucket(creds: IntegrationCreds, bucketName: string): Promise<void> {
|
|
367
|
+
const s3 = testS3(creds);
|
|
368
|
+
const uploads = await s3.send(new ListMultipartUploadsCommand({ Bucket: bucketName }));
|
|
369
|
+
for (const upload of uploads.Uploads ?? []) {
|
|
370
|
+
if (!upload.Key || !upload.UploadId) continue;
|
|
371
|
+
await s3.send(new AbortMultipartUploadCommand({ Bucket: bucketName, Key: upload.Key, UploadId: upload.UploadId }));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
let token: string | undefined;
|
|
375
|
+
do {
|
|
376
|
+
const page = await s3.send(new ListObjectsV2Command({ Bucket: bucketName, ContinuationToken: token }));
|
|
377
|
+
for (const entry of page.Contents ?? []) {
|
|
378
|
+
if (entry.Key) await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: entry.Key }));
|
|
379
|
+
}
|
|
380
|
+
token = page.IsTruncated ? page.NextContinuationToken : undefined;
|
|
381
|
+
} while (token);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Reclaim stale throwaway R2 buckets — the one kind that needs more than an API token, so it lives here
|
|
386
|
+
* once instead of being re-derived by every suite that makes a bucket.
|
|
387
|
+
*
|
|
388
|
+
* Without `R2_CREDENTIALS` this reaps nothing and says so loudly rather than failing quietly: the buckets
|
|
389
|
+
* are still there, still cost money, and the only fix is the key pair. Never throws — a reaper must not
|
|
390
|
+
* fail the suite it is trying to help.
|
|
391
|
+
*/
|
|
392
|
+
export async function reapStaleTestBuckets(
|
|
393
|
+
creds: IntegrationCreds,
|
|
394
|
+
options: { now?: number; staleAfterMs?: number } = {},
|
|
395
|
+
): Promise<{ reaped: string[]; failed: string[] }> {
|
|
396
|
+
if (!creds.r2) {
|
|
397
|
+
console.warn("no R2_CREDENTIALS: stale pithy-int- buckets cannot be emptied, so none were reaped.");
|
|
398
|
+
return { reaped: [], failed: [] };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const provisioner = new CloudflareClients({ accountId: creds.accountId, apiToken: creds.apiToken }).r2Provisioner();
|
|
402
|
+
return reapStaleTestResources(
|
|
403
|
+
{
|
|
404
|
+
label: "R2 bucket",
|
|
405
|
+
list: async () => (await provisioner.listBuckets()).map((bucket) => bucket.name),
|
|
406
|
+
remove: async (name) => {
|
|
407
|
+
await emptyTestBucket(creds, name);
|
|
408
|
+
await provisioner.deleteBucket(name);
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
options,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { CloudflareInvalidResponseError, CloudflareRequestError } from "../client/errors";
|
|
6
|
+
import type { CloudflareWorkersManager } from "../workers/workersManager";
|
|
7
|
+
import type { IntegrationCreds } from "./harness";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A throwaway Worker that records what its `email()` handler was handed, and the reader that fetches
|
|
11
|
+
* the record back out.
|
|
12
|
+
*
|
|
13
|
+
* ## Why a Worker has to exist at all
|
|
14
|
+
*
|
|
15
|
+
* An Email Routing rule cannot point at a Worker that is not there. Cloudflare rejects the rule with
|
|
16
|
+
* `2016 Workers Script Info not found` at creation time, so a live test of `ensureWorkerRoute` has to
|
|
17
|
+
* deploy a real script before it can create a real rule. That fact alone forces this file; everything
|
|
18
|
+
* else here is what the script may as well do once it is running.
|
|
19
|
+
*
|
|
20
|
+
* ## Why the record goes through KV
|
|
21
|
+
*
|
|
22
|
+
* There is no inbox. The fixture zone's catch-all drops and it has zero verified destination
|
|
23
|
+
* addresses, so nothing this suite sends can be read from a mailbox — and a test that waits for one
|
|
24
|
+
* waits forever. The Worker is the destination, so the Worker has to be the witness: it writes what it
|
|
25
|
+
* received to a KV namespace the test created, and the test reads it back over the REST API.
|
|
26
|
+
*
|
|
27
|
+
* ## What it deliberately does not do
|
|
28
|
+
*
|
|
29
|
+
* It does not classify, suppress, or parse MIME. Everything `@pithy-sh/email` actually does with an
|
|
30
|
+
* inbound message is unit-tested against real D1 in the Workers pool, where a hostile message can be
|
|
31
|
+
* constructed exactly rather than mailed and hoped for. What only a live delivery can answer is what
|
|
32
|
+
* Cloudflare *hands* the handler — which headers survive, which Cloudflare adds, and whether a
|
|
33
|
+
* sender's own headers arrive intact. So the recorder records, and asserts nothing.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** The KV binding the recorder writes through. */
|
|
37
|
+
export const INBOUND_RECORDER_BINDING = "OBSERVED";
|
|
38
|
+
|
|
39
|
+
/** The header a sender stamps to address its record. One send, one key, so parallel runs cannot collide. */
|
|
40
|
+
export const INBOUND_NONCE_HEADER = "X-Pithy-Probe";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How much of the raw message is kept.
|
|
44
|
+
*
|
|
45
|
+
* A record is read back into a test's failure output, and a whole message is pages of base64. 16 KiB
|
|
46
|
+
* covers the header block several times over, which is the part every question in #47 is about.
|
|
47
|
+
*/
|
|
48
|
+
const RAW_CAP = 16_384;
|
|
49
|
+
|
|
50
|
+
/** How long a record lives in KV. An hour outlives any run and reclaims itself if a teardown is missed. */
|
|
51
|
+
const RECORD_TTL_SECONDS = 3600;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The recorder's source, as a module string.
|
|
55
|
+
*
|
|
56
|
+
* Kept verbatim here rather than built from a template because it is uploaded as-is and read by whoever
|
|
57
|
+
* is debugging a delivery that did not arrive. It writes **once**, under the nonce it finds in the raw
|
|
58
|
+
* message, and it must never throw: a throw inside `email()` is a delivery failure Cloudflare retries,
|
|
59
|
+
* which turns one test message into several.
|
|
60
|
+
*/
|
|
61
|
+
export const INBOUND_RECORDER_MODULE = `
|
|
62
|
+
const NONCE = /^${INBOUND_NONCE_HEADER}:[ \\t]*(\\S+)[ \\t]*$/im;
|
|
63
|
+
const AUTH_RESULTS = /^Authentication-Results:[ \\t]*([\\s\\S]*?)(?=\\r?\\n[^ \\t])/gim;
|
|
64
|
+
|
|
65
|
+
/** Header names in the raw block, in order, lowercased. Duplicates kept — two DKIM signatures are two facts. */
|
|
66
|
+
function rawHeaderNames(raw) {
|
|
67
|
+
const block = raw.split(/\\r?\\n\\r?\\n/)[0] ?? "";
|
|
68
|
+
return block
|
|
69
|
+
.split(/\\r?\\n/)
|
|
70
|
+
.filter((line) => /^[A-Za-z0-9-]+:/.test(line))
|
|
71
|
+
.map((line) => line.slice(0, line.indexOf(":")).toLowerCase());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export default {
|
|
75
|
+
async email(message, env) {
|
|
76
|
+
let record;
|
|
77
|
+
let nonce = "unkeyed";
|
|
78
|
+
try {
|
|
79
|
+
const raw = await new Response(message.raw).text();
|
|
80
|
+
nonce = NONCE.exec(raw)?.[1] ?? "unkeyed";
|
|
81
|
+
record = {
|
|
82
|
+
envelopeFrom: message.from,
|
|
83
|
+
envelopeTo: message.to,
|
|
84
|
+
rawSize: message.rawSize,
|
|
85
|
+
seen: [...message.headers.keys()].map((name) => name.toLowerCase()).sort(),
|
|
86
|
+
rawHeaderNames: rawHeaderNames(raw),
|
|
87
|
+
authenticationResults: [...raw.matchAll(AUTH_RESULTS)].map((match) => match[1].trim()),
|
|
88
|
+
raw: raw.slice(0, ${RAW_CAP}),
|
|
89
|
+
};
|
|
90
|
+
} catch (error) {
|
|
91
|
+
record = { failure: String(error) };
|
|
92
|
+
}
|
|
93
|
+
await env.${INBOUND_RECORDER_BINDING}.put(nonce, JSON.stringify(record), { expirationTtl: ${RECORD_TTL_SECONDS} });
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
`;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* What the recorder saw — validated on the way back in, because a KV read is a boundary like any other.
|
|
100
|
+
*
|
|
101
|
+
* The Worker that wrote it is ours, which is exactly the argument that has let every unvalidated
|
|
102
|
+
* internal read through. A truncated write or a stale record from an older recorder deserves a Zod
|
|
103
|
+
* failure naming the field, not a `TypeError` three assertions later.
|
|
104
|
+
*/
|
|
105
|
+
export const ObservedInbound = z
|
|
106
|
+
.object({
|
|
107
|
+
envelopeFrom: z.string().describe("The SMTP envelope sender, as `message.from` reported it."),
|
|
108
|
+
envelopeTo: z.string().describe("The SMTP envelope recipient, as `message.to` reported it."),
|
|
109
|
+
rawSize: z.number().describe("The message size in bytes, as `message.rawSize` reported it."),
|
|
110
|
+
seen: z
|
|
111
|
+
.array(z.string())
|
|
112
|
+
.describe("Header names on `message.headers`, lowercased and sorted — the surface workerd#6740 is about."),
|
|
113
|
+
rawHeaderNames: z
|
|
114
|
+
.array(z.string())
|
|
115
|
+
.describe("Header names in the raw MIME block, lowercased, in wire order, duplicates kept."),
|
|
116
|
+
authenticationResults: z
|
|
117
|
+
.array(z.string())
|
|
118
|
+
.describe("Every `Authentication-Results` value in the raw block, in wire order: the topmost is the receiver's."),
|
|
119
|
+
raw: z.string().describe("The delivered message, truncated to the first 16 KiB."),
|
|
120
|
+
})
|
|
121
|
+
.describe("One inbound message as the Worker's `email()` handler received it.");
|
|
122
|
+
|
|
123
|
+
/** What the recorder saw. */
|
|
124
|
+
export type ObservedInbound = z.output<typeof ObservedInbound>;
|
|
125
|
+
|
|
126
|
+
/** A trivial module Worker: enough for an Email Routing rule to be allowed to point at it. */
|
|
127
|
+
export const PLACEHOLDER_INBOUND_MODULE = "export default { async email() {} };\n";
|
|
128
|
+
|
|
129
|
+
/** The module filename every throwaway Worker here is uploaded under. */
|
|
130
|
+
export const INBOUND_MODULE_NAME = "worker.mjs";
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Deploy the recorder at `scriptName`, bound to the KV namespace records are written to.
|
|
134
|
+
*
|
|
135
|
+
* Uploaded through `CloudflareWorkersManager.createWorker` like anything else. It briefly was not:
|
|
136
|
+
* #373 was a manager that could not upload a module to the live API at all, and this file carried a
|
|
137
|
+
* hand-rolled `fetch` around it. The manager is the path again — a helper that exists because the
|
|
138
|
+
* product is broken is the thing people copy.
|
|
139
|
+
*/
|
|
140
|
+
export async function deployInboundRecorder(options: {
|
|
141
|
+
workers: CloudflareWorkersManager;
|
|
142
|
+
scriptName: string;
|
|
143
|
+
namespaceId: string;
|
|
144
|
+
/** The date this throwaway Worker runs on. Named by the suite, because #396 left no default to inherit. */
|
|
145
|
+
compatibilityDate: string;
|
|
146
|
+
}): Promise<void> {
|
|
147
|
+
const { workers, scriptName, namespaceId, compatibilityDate } = options;
|
|
148
|
+
await workers.createWorker(
|
|
149
|
+
scriptName,
|
|
150
|
+
compatibilityDate,
|
|
151
|
+
{ bindings: [{ type: "kv_namespace", name: INBOUND_RECORDER_BINDING, namespace_id: namespaceId }] },
|
|
152
|
+
{ name: INBOUND_MODULE_NAME, body: INBOUND_RECORDER_MODULE },
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The record for one nonce, or null while nothing has arrived.
|
|
158
|
+
*
|
|
159
|
+
* Null rather than a throw for the missing case, because "not yet" is the normal state of a mail
|
|
160
|
+
* delivery: the caller polls. A 404 from KV is that state; anything else is a real failure and throws.
|
|
161
|
+
*/
|
|
162
|
+
export async function readObservedInbound(options: {
|
|
163
|
+
creds: IntegrationCreds;
|
|
164
|
+
namespaceId: string;
|
|
165
|
+
nonce: string;
|
|
166
|
+
}): Promise<ObservedInbound | null> {
|
|
167
|
+
const { creds, namespaceId, nonce } = options;
|
|
168
|
+
const url = `https://api.cloudflare.com/client/v4/accounts/${creds.accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(nonce)}`;
|
|
169
|
+
const response = await fetch(url, { headers: { Authorization: `Bearer ${creds.apiToken}` } });
|
|
170
|
+
if (response.status === 404) return null;
|
|
171
|
+
if (!response.ok) {
|
|
172
|
+
throw new CloudflareRequestError({
|
|
173
|
+
message: "Could not read the recorded inbound message.",
|
|
174
|
+
action: "Check the token carries Workers KV Storage: Edit on this account.",
|
|
175
|
+
detail: `KV read for the inbound record returned ${response.status}.`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const body: unknown = JSON.parse(await response.text());
|
|
180
|
+
// The recorder writes `{ failure }` rather than throwing, so a handler that broke says so here
|
|
181
|
+
// instead of arriving as a Zod complaint about seven missing fields.
|
|
182
|
+
if (typeof body === "object" && body !== null && "failure" in body) {
|
|
183
|
+
throw new CloudflareInvalidResponseError({
|
|
184
|
+
message: "The inbound recorder could not read the message it was handed.",
|
|
185
|
+
detail: `The recorder Worker recorded a failure: ${String((body as { failure: unknown }).failure)}`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return ObservedInbound.parse(body);
|
|
189
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { fixtureValue, reportFixtureEstate, resolveFixture } from "./fixtures";
|
|
5
|
+
import { loadIntegrationCreds } from "./harness";
|
|
6
|
+
import { reapAllStaleTestResources } from "./reap";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The Vitest `globalSetup` every `vitest.integration.config.ts` points at — one fixture report and one
|
|
10
|
+
* debris sweep per integration run, before a single suite is collected.
|
|
11
|
+
*
|
|
12
|
+
* **A `globalSetup` is the only place either can correctly live.** Reaping used to happen in a suite's
|
|
13
|
+
* `beforeAll`, and Vitest runs no hooks inside a `describe.skipIf(true)` — so each reaper was gated on
|
|
14
|
+
* exactly the credential whose absence lets debris pile up, and a package with no live suite of its own
|
|
15
|
+
* reaped nothing however much it created. `globalSetup` runs before collection, so no suite's gate can
|
|
16
|
+
* switch it off, and it runs once per project rather than once per file.
|
|
17
|
+
*
|
|
18
|
+
* The fixture report is the same argument about a different thing. A suite that skips for want of a
|
|
19
|
+
* Turnstile widget prints "skipped" and nothing else, and the one place that can say *which* fixture and
|
|
20
|
+
* *where to make it* is a place no suite's gate reaches. So it runs first, and it runs **before the
|
|
21
|
+
* credentials check** — a contributor with no account is exactly who needs to be told why the run went
|
|
22
|
+
* quiet, and gating the explanation on the thing being explained is how it goes silent for them.
|
|
23
|
+
*
|
|
24
|
+
* Never throws. Housekeeping that fails the run it was meant to help is worse than the debris: a missing
|
|
25
|
+
* or unprivileged token must skip the sweep and let the suites make their own skip decision, exactly as
|
|
26
|
+
* they did before.
|
|
27
|
+
*/
|
|
28
|
+
export default async function setup(): Promise<void> {
|
|
29
|
+
reportFixtureEstate();
|
|
30
|
+
|
|
31
|
+
const creds = loadIntegrationCreds();
|
|
32
|
+
if (!creds.hasCreds) return;
|
|
33
|
+
|
|
34
|
+
// The one zone-scoped kind. The fixture is the only thing that may name a zone this run is allowed to
|
|
35
|
+
// edit routing on; without it, that kind reports itself skipped rather than guessing.
|
|
36
|
+
const routing = resolveFixture("email-routing");
|
|
37
|
+
const emailRoutingZoneId = routing.ready ? fixtureValue("email-routing", "EMAIL_ROUTING_ZONE_ID") : undefined;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await reapAllStaleTestResources(creds, { emailRoutingZoneId });
|
|
41
|
+
} catch (error) {
|
|
42
|
+
// Deliberately swallowed, and reported. The sweep is a courtesy to the next run; a run that cannot
|
|
43
|
+
// sweep is still a run worth having.
|
|
44
|
+
console.warn(`stale test resources could not be swept: ${error instanceof Error ? error.message : String(error)}`);
|
|
45
|
+
}
|
|
46
|
+
}
|