@pithy-sh/cloudflare 0.1.1 → 0.1.3
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/dist/ai/aiManager.d.ts +99 -0
- package/dist/ai/aiManager.d.ts.map +1 -0
- package/dist/ai/aiManager.js +148 -0
- package/dist/ai/vectorizeManager.d.ts +67 -0
- package/dist/ai/vectorizeManager.d.ts.map +1 -0
- package/dist/ai/vectorizeManager.js +101 -0
- package/dist/ai/vectorizeProvisioner.d.ts +123 -0
- package/dist/ai/vectorizeProvisioner.d.ts.map +1 -0
- package/dist/ai/vectorizeProvisioner.js +195 -0
- package/dist/client/accounts.d.ts +48 -0
- package/dist/client/accounts.d.ts.map +1 -0
- package/dist/client/accounts.js +55 -0
- package/dist/client/clients.d.ts +122 -0
- package/dist/client/clients.d.ts.map +1 -0
- package/dist/client/clients.js +216 -0
- package/dist/client/errors.d.ts +73 -0
- package/dist/client/errors.d.ts.map +1 -0
- package/dist/client/errors.js +98 -0
- package/dist/client/manager.d.ts +45 -0
- package/dist/client/manager.d.ts.map +1 -0
- package/dist/client/manager.js +51 -0
- package/dist/d1/d1Manager.d.ts +74 -0
- package/dist/d1/d1Manager.d.ts.map +1 -0
- package/dist/d1/d1Manager.js +128 -0
- package/dist/d1/d1PreparedStatement.d.ts +24 -0
- package/dist/d1/d1PreparedStatement.d.ts.map +1 -0
- package/dist/d1/d1PreparedStatement.js +91 -0
- package/dist/d1/d1Provisioner.d.ts +28 -0
- package/dist/d1/d1Provisioner.d.ts.map +1 -0
- package/dist/d1/d1Provisioner.js +67 -0
- package/dist/email/emailRoutingManager.d.ts +36 -0
- package/dist/email/emailRoutingManager.d.ts.map +1 -0
- package/dist/email/emailRoutingManager.js +124 -0
- package/dist/email/emailSendManager.d.ts +45 -0
- package/dist/email/emailSendManager.d.ts.map +1 -0
- package/dist/email/emailSendManager.js +52 -0
- package/dist/env/devVars.d.ts +65 -0
- package/dist/env/devVars.d.ts.map +1 -0
- package/dist/env/devVars.js +84 -0
- package/dist/hostnames/customHostnamesManager.d.ts +51 -0
- package/dist/hostnames/customHostnamesManager.d.ts.map +1 -0
- package/dist/hostnames/customHostnamesManager.js +84 -0
- package/dist/kv/kvManager.d.ts +76 -0
- package/dist/kv/kvManager.d.ts.map +1 -0
- package/dist/kv/kvManager.js +135 -0
- package/dist/kv/kvProvisioner.d.ts +28 -0
- package/dist/kv/kvProvisioner.d.ts.map +1 -0
- package/dist/kv/kvProvisioner.js +71 -0
- package/dist/media/assetSeeder.d.ts +31 -0
- package/dist/media/assetSeeder.d.ts.map +1 -0
- package/dist/media/assetSeeder.js +70 -0
- package/dist/media/imageManager.d.ts +34 -0
- package/dist/media/imageManager.d.ts.map +1 -0
- package/dist/media/imageManager.js +103 -0
- package/dist/media/ownership.d.ts +40 -0
- package/dist/media/ownership.d.ts.map +1 -0
- package/dist/media/ownership.js +46 -0
- package/dist/media/streamManager.d.ts +83 -0
- package/dist/media/streamManager.d.ts.map +1 -0
- package/dist/media/streamManager.js +145 -0
- package/dist/queue/queueManager.d.ts +84 -0
- package/dist/queue/queueManager.d.ts.map +1 -0
- package/dist/queue/queueManager.js +114 -0
- package/dist/r2/r2Credentials.d.ts +12 -0
- package/dist/r2/r2Credentials.d.ts.map +1 -0
- package/dist/r2/r2Credentials.js +13 -0
- package/dist/r2/r2Manager.d.ts +226 -0
- package/dist/r2/r2Manager.d.ts.map +1 -0
- package/dist/r2/r2Manager.js +405 -0
- package/dist/r2/r2Provisioner.d.ts +39 -0
- package/dist/r2/r2Provisioner.d.ts.map +1 -0
- package/dist/r2/r2Provisioner.js +86 -0
- package/dist/secrets/secretsStoreManager.d.ts +82 -0
- package/dist/secrets/secretsStoreManager.d.ts.map +1 -0
- package/dist/secrets/secretsStoreManager.js +147 -0
- package/dist/secrets/secretsStores.d.ts +47 -0
- package/dist/secrets/secretsStores.d.ts.map +1 -0
- package/dist/secrets/secretsStores.js +65 -0
- package/dist/test-utils/emailRoutingRules.d.ts +39 -0
- package/dist/test-utils/emailRoutingRules.d.ts.map +1 -0
- package/dist/test-utils/emailRoutingRules.js +89 -0
- package/dist/test-utils/fixtureReportSetup.d.ts +25 -0
- package/dist/test-utils/fixtureReportSetup.d.ts.map +1 -0
- package/dist/test-utils/fixtureReportSetup.js +30 -0
- package/dist/test-utils/fixtures.d.ts +224 -0
- package/dist/test-utils/fixtures.d.ts.map +1 -0
- package/dist/test-utils/fixtures.js +277 -0
- package/dist/test-utils/harness.d.ts +192 -0
- package/dist/test-utils/harness.d.ts.map +1 -0
- package/dist/test-utils/harness.js +351 -0
- package/dist/test-utils/inboundRecorder.d.ts +91 -0
- package/dist/test-utils/inboundRecorder.d.ts.map +1 -0
- package/dist/test-utils/inboundRecorder.js +141 -0
- package/dist/test-utils/integrationSetup.d.ts +22 -0
- package/dist/test-utils/integrationSetup.d.ts.map +1 -0
- package/dist/test-utils/integrationSetup.js +37 -0
- package/dist/test-utils/reap.d.ts +112 -0
- package/dist/test-utils/reap.d.ts.map +1 -0
- package/dist/test-utils/reap.js +185 -0
- package/dist/tokens/accountTokensManager.d.ts +157 -0
- package/dist/tokens/accountTokensManager.d.ts.map +1 -0
- package/dist/tokens/accountTokensManager.js +280 -0
- package/dist/tokens/permissions.d.ts +36 -0
- package/dist/tokens/permissions.d.ts.map +1 -0
- package/dist/tokens/permissions.js +50 -0
- package/dist/tokens/profiles.d.ts +102 -0
- package/dist/tokens/profiles.d.ts.map +1 -0
- package/dist/tokens/profiles.js +137 -0
- package/dist/turnstile/turnstileManager.d.ts +75 -0
- package/dist/turnstile/turnstileManager.d.ts.map +1 -0
- package/dist/turnstile/turnstileManager.js +138 -0
- package/dist/user/userManager.d.ts +36 -0
- package/dist/user/userManager.d.ts.map +1 -0
- package/dist/user/userManager.js +58 -0
- package/dist/workers/buildsManager.d.ts +116 -0
- package/dist/workers/buildsManager.d.ts.map +1 -0
- package/dist/workers/buildsManager.js +237 -0
- package/dist/workers/buildsTypes.d.ts +102 -0
- package/dist/workers/buildsTypes.d.ts.map +1 -0
- package/dist/workers/buildsTypes.js +87 -0
- package/dist/workers/workersBuildEvent.d.ts +64 -0
- package/dist/workers/workersBuildEvent.d.ts.map +1 -0
- package/dist/workers/workersBuildEvent.js +47 -0
- package/dist/workers/workersManager.d.ts +136 -0
- package/dist/workers/workersManager.d.ts.map +1 -0
- package/dist/workers/workersManager.js +312 -0
- package/dist/workers/workersProvisioner.d.ts +80 -0
- package/dist/workers/workersProvisioner.d.ts.map +1 -0
- package/dist/workers/workersProvisioner.js +106 -0
- package/dist/workflows/stepFailure.d.ts +180 -0
- package/dist/workflows/stepFailure.d.ts.map +1 -0
- package/dist/workflows/stepFailure.js +227 -0
- package/dist/workflows/workflowsClient.d.ts +87 -0
- package/dist/workflows/workflowsClient.d.ts.map +1 -0
- package/dist/workflows/workflowsClient.js +156 -0
- package/dist/zones/zonesManager.d.ts +50 -0
- package/dist/zones/zonesManager.d.ts.map +1 -0
- package/dist/zones/zonesManager.js +74 -0
- package/package.json +18 -9
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { CloudflareInvalidResponseError, CloudflareRequestError } from "../client/errors.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
//#region src/test-utils/inboundRecorder.ts
|
|
4
|
+
/**
|
|
5
|
+
* A throwaway Worker that records what its `email()` handler was handed, and the reader that fetches
|
|
6
|
+
* the record back out.
|
|
7
|
+
*
|
|
8
|
+
* ## Why a Worker has to exist at all
|
|
9
|
+
*
|
|
10
|
+
* An Email Routing rule cannot point at a Worker that is not there. Cloudflare rejects the rule with
|
|
11
|
+
* `2016 Workers Script Info not found` at creation time, so a live test of `ensureWorkerRoute` has to
|
|
12
|
+
* deploy a real script before it can create a real rule. That fact alone forces this file; everything
|
|
13
|
+
* else here is what the script may as well do once it is running.
|
|
14
|
+
*
|
|
15
|
+
* ## Why the record goes through KV
|
|
16
|
+
*
|
|
17
|
+
* There is no inbox. The fixture zone's catch-all drops and it has zero verified destination
|
|
18
|
+
* addresses, so nothing this suite sends can be read from a mailbox — and a test that waits for one
|
|
19
|
+
* waits forever. The Worker is the destination, so the Worker has to be the witness: it writes what it
|
|
20
|
+
* received to a KV namespace the test created, and the test reads it back over the REST API.
|
|
21
|
+
*
|
|
22
|
+
* ## What it deliberately does not do
|
|
23
|
+
*
|
|
24
|
+
* It does not classify, suppress, or parse MIME. Everything `@pithy-sh/email` actually does with an
|
|
25
|
+
* inbound message is unit-tested against real D1 in the Workers pool, where a hostile message can be
|
|
26
|
+
* constructed exactly rather than mailed and hoped for. What only a live delivery can answer is what
|
|
27
|
+
* Cloudflare *hands* the handler — which headers survive, which Cloudflare adds, and whether a
|
|
28
|
+
* sender's own headers arrive intact. So the recorder records, and asserts nothing.
|
|
29
|
+
*/
|
|
30
|
+
/** The KV binding the recorder writes through. */
|
|
31
|
+
const INBOUND_RECORDER_BINDING = "OBSERVED";
|
|
32
|
+
/** The header a sender stamps to address its record. One send, one key, so parallel runs cannot collide. */
|
|
33
|
+
const INBOUND_NONCE_HEADER = "X-Pithy-Probe";
|
|
34
|
+
/**
|
|
35
|
+
* The recorder's source, as a module string.
|
|
36
|
+
*
|
|
37
|
+
* Kept verbatim here rather than built from a template because it is uploaded as-is and read by whoever
|
|
38
|
+
* is debugging a delivery that did not arrive. It writes **once**, under the nonce it finds in the raw
|
|
39
|
+
* message, and it must never throw: a throw inside `email()` is a delivery failure Cloudflare retries,
|
|
40
|
+
* which turns one test message into several.
|
|
41
|
+
*/
|
|
42
|
+
const INBOUND_RECORDER_MODULE = `
|
|
43
|
+
const NONCE = /^${INBOUND_NONCE_HEADER}:[ \\t]*(\\S+)[ \\t]*$/im;
|
|
44
|
+
const AUTH_RESULTS = /^Authentication-Results:[ \\t]*([\\s\\S]*?)(?=\\r?\\n[^ \\t])/gim;
|
|
45
|
+
|
|
46
|
+
/** Header names in the raw block, in order, lowercased. Duplicates kept — two DKIM signatures are two facts. */
|
|
47
|
+
function rawHeaderNames(raw) {
|
|
48
|
+
const block = raw.split(/\\r?\\n\\r?\\n/)[0] ?? "";
|
|
49
|
+
return block
|
|
50
|
+
.split(/\\r?\\n/)
|
|
51
|
+
.filter((line) => /^[A-Za-z0-9-]+:/.test(line))
|
|
52
|
+
.map((line) => line.slice(0, line.indexOf(":")).toLowerCase());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default {
|
|
56
|
+
async email(message, env) {
|
|
57
|
+
let record;
|
|
58
|
+
let nonce = "unkeyed";
|
|
59
|
+
try {
|
|
60
|
+
const raw = await new Response(message.raw).text();
|
|
61
|
+
nonce = NONCE.exec(raw)?.[1] ?? "unkeyed";
|
|
62
|
+
record = {
|
|
63
|
+
envelopeFrom: message.from,
|
|
64
|
+
envelopeTo: message.to,
|
|
65
|
+
rawSize: message.rawSize,
|
|
66
|
+
seen: [...message.headers.keys()].map((name) => name.toLowerCase()).sort(),
|
|
67
|
+
rawHeaderNames: rawHeaderNames(raw),
|
|
68
|
+
authenticationResults: [...raw.matchAll(AUTH_RESULTS)].map((match) => match[1].trim()),
|
|
69
|
+
raw: raw.slice(0, 16384),
|
|
70
|
+
};
|
|
71
|
+
} catch (error) {
|
|
72
|
+
record = { failure: String(error) };
|
|
73
|
+
}
|
|
74
|
+
await env.${INBOUND_RECORDER_BINDING}.put(nonce, JSON.stringify(record), { expirationTtl: 3600 });
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
`;
|
|
78
|
+
/**
|
|
79
|
+
* What the recorder saw — validated on the way back in, because a KV read is a boundary like any other.
|
|
80
|
+
*
|
|
81
|
+
* The Worker that wrote it is ours, which is exactly the argument that has let every unvalidated
|
|
82
|
+
* internal read through. A truncated write or a stale record from an older recorder deserves a Zod
|
|
83
|
+
* failure naming the field, not a `TypeError` three assertions later.
|
|
84
|
+
*/
|
|
85
|
+
const ObservedInbound = z.object({
|
|
86
|
+
envelopeFrom: z.string().describe("The SMTP envelope sender, as `message.from` reported it."),
|
|
87
|
+
envelopeTo: z.string().describe("The SMTP envelope recipient, as `message.to` reported it."),
|
|
88
|
+
rawSize: z.number().describe("The message size in bytes, as `message.rawSize` reported it."),
|
|
89
|
+
seen: z.array(z.string()).describe("Header names on `message.headers`, lowercased and sorted — the surface workerd#6740 is about."),
|
|
90
|
+
rawHeaderNames: z.array(z.string()).describe("Header names in the raw MIME block, lowercased, in wire order, duplicates kept."),
|
|
91
|
+
authenticationResults: z.array(z.string()).describe("Every `Authentication-Results` value in the raw block, in wire order: the topmost is the receiver's."),
|
|
92
|
+
raw: z.string().describe("The delivered message, truncated to the first 16 KiB.")
|
|
93
|
+
}).describe("One inbound message as the Worker's `email()` handler received it.");
|
|
94
|
+
/** A trivial module Worker: enough for an Email Routing rule to be allowed to point at it. */
|
|
95
|
+
const PLACEHOLDER_INBOUND_MODULE = "export default { async email() {} };\n";
|
|
96
|
+
/** The module filename every throwaway Worker here is uploaded under. */
|
|
97
|
+
const INBOUND_MODULE_NAME = "worker.mjs";
|
|
98
|
+
/**
|
|
99
|
+
* Deploy the recorder at `scriptName`, bound to the KV namespace records are written to.
|
|
100
|
+
*
|
|
101
|
+
* Uploaded through `CloudflareWorkersManager.createWorker` like anything else. It briefly was not:
|
|
102
|
+
* #373 was a manager that could not upload a module to the live API at all, and this file carried a
|
|
103
|
+
* hand-rolled `fetch` around it. The manager is the path again — a helper that exists because the
|
|
104
|
+
* product is broken is the thing people copy.
|
|
105
|
+
*/
|
|
106
|
+
async function deployInboundRecorder(options) {
|
|
107
|
+
const { workers, scriptName, namespaceId, compatibilityDate } = options;
|
|
108
|
+
await workers.createWorker(scriptName, compatibilityDate, { bindings: [{
|
|
109
|
+
type: "kv_namespace",
|
|
110
|
+
name: INBOUND_RECORDER_BINDING,
|
|
111
|
+
namespace_id: namespaceId
|
|
112
|
+
}] }, {
|
|
113
|
+
name: INBOUND_MODULE_NAME,
|
|
114
|
+
body: INBOUND_RECORDER_MODULE
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The record for one nonce, or null while nothing has arrived.
|
|
119
|
+
*
|
|
120
|
+
* Null rather than a throw for the missing case, because "not yet" is the normal state of a mail
|
|
121
|
+
* delivery: the caller polls. A 404 from KV is that state; anything else is a real failure and throws.
|
|
122
|
+
*/
|
|
123
|
+
async function readObservedInbound(options) {
|
|
124
|
+
const { creds, namespaceId, nonce } = options;
|
|
125
|
+
const url = `https://api.cloudflare.com/client/v4/accounts/${creds.accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(nonce)}`;
|
|
126
|
+
const response = await fetch(url, { headers: { Authorization: `Bearer ${creds.apiToken}` } });
|
|
127
|
+
if (response.status === 404) return null;
|
|
128
|
+
if (!response.ok) throw new CloudflareRequestError({
|
|
129
|
+
message: "Could not read the recorded inbound message.",
|
|
130
|
+
action: "Check the token carries Workers KV Storage: Edit on this account.",
|
|
131
|
+
detail: `KV read for the inbound record returned ${response.status}.`
|
|
132
|
+
});
|
|
133
|
+
const body = JSON.parse(await response.text());
|
|
134
|
+
if (typeof body === "object" && body !== null && "failure" in body) throw new CloudflareInvalidResponseError({
|
|
135
|
+
message: "The inbound recorder could not read the message it was handed.",
|
|
136
|
+
detail: `The recorder Worker recorded a failure: ${String(body.failure)}`
|
|
137
|
+
});
|
|
138
|
+
return ObservedInbound.parse(body);
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
141
|
+
export { INBOUND_MODULE_NAME, INBOUND_NONCE_HEADER, INBOUND_RECORDER_BINDING, INBOUND_RECORDER_MODULE, ObservedInbound, PLACEHOLDER_INBOUND_MODULE, deployInboundRecorder, readObservedInbound };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Vitest `globalSetup` every `vitest.integration.config.ts` points at — one fixture report and one
|
|
3
|
+
* debris sweep per integration run, before a single suite is collected.
|
|
4
|
+
*
|
|
5
|
+
* **A `globalSetup` is the only place either can correctly live.** Reaping used to happen in a suite's
|
|
6
|
+
* `beforeAll`, and Vitest runs no hooks inside a `describe.skipIf(true)` — so each reaper was gated on
|
|
7
|
+
* exactly the credential whose absence lets debris pile up, and a package with no live suite of its own
|
|
8
|
+
* reaped nothing however much it created. `globalSetup` runs before collection, so no suite's gate can
|
|
9
|
+
* switch it off, and it runs once per project rather than once per file.
|
|
10
|
+
*
|
|
11
|
+
* The fixture report is the same argument about a different thing. A suite that skips for want of a
|
|
12
|
+
* Turnstile widget prints "skipped" and nothing else, and the one place that can say *which* fixture and
|
|
13
|
+
* *where to make it* is a place no suite's gate reaches. So it runs first, and it runs **before the
|
|
14
|
+
* credentials check** — a contributor with no account is exactly who needs to be told why the run went
|
|
15
|
+
* quiet, and gating the explanation on the thing being explained is how it goes silent for them.
|
|
16
|
+
*
|
|
17
|
+
* Never throws. Housekeeping that fails the run it was meant to help is worse than the debris: a missing
|
|
18
|
+
* or unprivileged token must skip the sweep and let the suites make their own skip decision, exactly as
|
|
19
|
+
* they did before.
|
|
20
|
+
*/
|
|
21
|
+
export default function setup(): Promise<void>;
|
|
22
|
+
//# sourceMappingURL=integrationSetup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"integrationSetup.d.ts","sourceRoot":"","sources":["../../src/test-utils/integrationSetup.ts"],"names":[],"mappings":"AAOA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAA8B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAkBnD"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { loadIntegrationCreds } from "./harness.js";
|
|
2
|
+
import { fixtureValue, reportFixtureEstate, resolveFixture } from "./fixtures.js";
|
|
3
|
+
import { reapAllStaleTestResources } from "./reap.js";
|
|
4
|
+
//#region src/test-utils/integrationSetup.ts
|
|
5
|
+
/**
|
|
6
|
+
* The Vitest `globalSetup` every `vitest.integration.config.ts` points at — one fixture report and one
|
|
7
|
+
* debris sweep per integration run, before a single suite is collected.
|
|
8
|
+
*
|
|
9
|
+
* **A `globalSetup` is the only place either can correctly live.** Reaping used to happen in a suite's
|
|
10
|
+
* `beforeAll`, and Vitest runs no hooks inside a `describe.skipIf(true)` — so each reaper was gated on
|
|
11
|
+
* exactly the credential whose absence lets debris pile up, and a package with no live suite of its own
|
|
12
|
+
* reaped nothing however much it created. `globalSetup` runs before collection, so no suite's gate can
|
|
13
|
+
* switch it off, and it runs once per project rather than once per file.
|
|
14
|
+
*
|
|
15
|
+
* The fixture report is the same argument about a different thing. A suite that skips for want of a
|
|
16
|
+
* Turnstile widget prints "skipped" and nothing else, and the one place that can say *which* fixture and
|
|
17
|
+
* *where to make it* is a place no suite's gate reaches. So it runs first, and it runs **before the
|
|
18
|
+
* credentials check** — a contributor with no account is exactly who needs to be told why the run went
|
|
19
|
+
* quiet, and gating the explanation on the thing being explained is how it goes silent for them.
|
|
20
|
+
*
|
|
21
|
+
* Never throws. Housekeeping that fails the run it was meant to help is worse than the debris: a missing
|
|
22
|
+
* or unprivileged token must skip the sweep and let the suites make their own skip decision, exactly as
|
|
23
|
+
* they did before.
|
|
24
|
+
*/
|
|
25
|
+
async function setup() {
|
|
26
|
+
reportFixtureEstate();
|
|
27
|
+
const creds = loadIntegrationCreds();
|
|
28
|
+
if (!creds.hasCreds) return;
|
|
29
|
+
const emailRoutingZoneId = resolveFixture("email-routing").ready ? fixtureValue("email-routing", "EMAIL_ROUTING_ZONE_ID") : void 0;
|
|
30
|
+
try {
|
|
31
|
+
await reapAllStaleTestResources(creds, { emailRoutingZoneId });
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.warn(`stale test resources could not be swept: ${error instanceof Error ? error.message : String(error)}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
//#endregion
|
|
37
|
+
export { setup as default };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { type IntegrationCreds, type ReapableKind, testResourceAge } from "./harness";
|
|
2
|
+
/**
|
|
3
|
+
* The reap registry: every throwaway resource kind a live suite can mint, in one place, swept once per
|
|
4
|
+
* integration run.
|
|
5
|
+
*
|
|
6
|
+
* ## Why this exists, and why per-suite reaping was never enough
|
|
7
|
+
*
|
|
8
|
+
* {@link ReapableKind} and `reapStaleTestResources` are generic — they know how to reap *a* kind, and
|
|
9
|
+
* nothing about which kinds exist. So a kind was reaped only where some suite happened to hand the
|
|
10
|
+
* reaper a `list`/`remove` pair, and five such call sites covered the whole repo. Secrets Store
|
|
11
|
+
* entries, Queues, and API tokens had none at all, which is why eight `pithy-int-secret-…` entries sat
|
|
12
|
+
* on a real account with nothing in the repo able to reclaim them, ever.
|
|
13
|
+
*
|
|
14
|
+
* Two failures compound, and the second is the one that makes per-suite reaping structurally wrong.
|
|
15
|
+
*
|
|
16
|
+
* **A reaper registered in a suite's `beforeAll` does not run when that suite skips.** Every live suite
|
|
17
|
+
* is a `describe.skipIf(...)`, and Vitest runs no hooks inside a skipped suite. So the reaper is gated
|
|
18
|
+
* on exactly the credential whose absence lets debris accumulate: without `R2_CREDENTIALS` the R2
|
|
19
|
+
* suite skips, and because D1 reaping lived inside `@pithy-sh/storage`'s bundle, the **D1** reaper went
|
|
20
|
+
* offline with it. A reaper must never be gated on the same condition as the suite that dirties the
|
|
21
|
+
* account.
|
|
22
|
+
*
|
|
23
|
+
* **And reaping was per-suite, not per-run.** `test:integration --filter @pithy-sh/vector` mints
|
|
24
|
+
* Vectorize indexes and reaps nothing, because the only index reaper lived in `@pithy-sh/cloudflare`.
|
|
25
|
+
* Cross-package coverage was accidental.
|
|
26
|
+
*
|
|
27
|
+
* So the sweep moved to a Vitest `globalSetup` (see `integrationSetup.ts`), which runs once per project
|
|
28
|
+
* before any suite is collected and cannot be skipped by a suite's gate. A new live suite inherits
|
|
29
|
+
* cleanup instead of remembering to arrange it.
|
|
30
|
+
*
|
|
31
|
+
* ## What is deliberately still not reaped
|
|
32
|
+
*
|
|
33
|
+
* Resources composed under {@link RESERVED_TEST_PROJECT} — `pithy provision --feature`'s ephemeral D1 and
|
|
34
|
+
* KV, which a live test provisions through the *product's* namer because the names are the thing under
|
|
35
|
+
* test. `testResourceAge` cannot read an age out of `pithy-int-test-dev-73-slug-db`, and returns null
|
|
36
|
+
* rather than guessing. That conservatism is correct and stays: failing to clean up costs pennies, and
|
|
37
|
+
* deleting a resource a running suite still owns turns a green run red for reasons nobody can see. Those
|
|
38
|
+
* suites tear down in an unconditional `afterAll`; an interrupted run leaves debris that must be removed
|
|
39
|
+
* by hand. `CONTRIBUTING.md` says so out loud rather than implying the sweep covers everything.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* What this run is allowed to sweep beyond the account itself.
|
|
43
|
+
*
|
|
44
|
+
* Every other kind is account-scoped: the credentials name the account, and the account holds the
|
|
45
|
+
* debris. An Email Routing rule is not — it lives on a **zone**, and a sweep that guessed which zone
|
|
46
|
+
* would be editing mail delivery on a domain nobody pointed it at. So the zone is passed in, from the
|
|
47
|
+
* fixture that declared it, and a run that was told nothing sweeps nothing.
|
|
48
|
+
*/
|
|
49
|
+
export interface ReapScope {
|
|
50
|
+
/** The zone whose `pithy-int-` routing rules may be reclaimed. Absent means the kind reports itself skipped. */
|
|
51
|
+
emailRoutingZoneId?: string;
|
|
52
|
+
}
|
|
53
|
+
/** A kind that cannot be reaped this run, and the credential it is waiting on. */
|
|
54
|
+
export interface SkippedReapKind {
|
|
55
|
+
/** The human label, matching the kind it stands in for. */
|
|
56
|
+
label: string;
|
|
57
|
+
/** Why it was skipped — names the missing variable, so the report is actionable. */
|
|
58
|
+
skipped: string;
|
|
59
|
+
}
|
|
60
|
+
/** One entry in a reap plan: a kind that can be swept, or a named reason it cannot. */
|
|
61
|
+
export type ReapPlanEntry = (ReapableKind & {
|
|
62
|
+
label: string;
|
|
63
|
+
}) | SkippedReapKind;
|
|
64
|
+
/** What one kind's sweep did. `skipped` is non-null only when the kind never ran. */
|
|
65
|
+
export interface ReapKindResult {
|
|
66
|
+
/** The kind's label, as it appears in the plan. */
|
|
67
|
+
label: string;
|
|
68
|
+
/** Names successfully removed. */
|
|
69
|
+
reaped: string[];
|
|
70
|
+
/** Names that matched but could not be removed — reported, never swallowed. */
|
|
71
|
+
failed: string[];
|
|
72
|
+
/** The reason this kind did not run, or null when it did. */
|
|
73
|
+
skipped: string | null;
|
|
74
|
+
}
|
|
75
|
+
/** Every label in a plan, in plan order — what a test asserts against so a dropped kind is visible. */
|
|
76
|
+
export declare function reapPlanLabels(plan: readonly ReapPlanEntry[]): string[];
|
|
77
|
+
/**
|
|
78
|
+
* Sweep every kind in a plan and report what went.
|
|
79
|
+
*
|
|
80
|
+
* Never throws, and never lets one kind's failure end the sweep: a reaper exists to help a suite, and a
|
|
81
|
+
* housekeeping error that fails the run is worse than the debris it was cleaning. A listing that throws
|
|
82
|
+
* yields an empty result for that kind; a removal that throws lands in `failed`, where it is visible.
|
|
83
|
+
*/
|
|
84
|
+
export declare function reapKinds(plan: readonly ReapPlanEntry[], options?: {
|
|
85
|
+
now?: number;
|
|
86
|
+
staleAfterMs?: number;
|
|
87
|
+
}): Promise<ReapKindResult[]>;
|
|
88
|
+
/**
|
|
89
|
+
* Build the sweep plan for an account.
|
|
90
|
+
*
|
|
91
|
+
* Alphabetical by label, so the set is readable as a list rather than as an accident of construction
|
|
92
|
+
* order, and so a test can pin it. Every kind appears whether or not it can run — a kind that is missing
|
|
93
|
+
* a credential reports *why*, because "nothing to reap" and "unable to reap" look identical in a log and
|
|
94
|
+
* only one of them is fine.
|
|
95
|
+
*
|
|
96
|
+
* R2 is the one kind an API token cannot reclaim: Cloudflare refuses to delete a non-empty bucket, and
|
|
97
|
+
* emptying one is an S3-protocol operation. So it needs the key pair, and says so when it lacks it.
|
|
98
|
+
*/
|
|
99
|
+
export declare function testResourceReapPlan(creds: IntegrationCreds, options?: ReapScope): ReapPlanEntry[];
|
|
100
|
+
/**
|
|
101
|
+
* Reclaim every stale throwaway resource on the account, across every kind.
|
|
102
|
+
*
|
|
103
|
+
* The entry point a `globalSetup` calls. Reports one line per kind that did something or could not run;
|
|
104
|
+
* a kind with nothing to do stays quiet, because a clean account should produce a clean log.
|
|
105
|
+
*/
|
|
106
|
+
export declare function reapAllStaleTestResources(creds: IntegrationCreds, options?: ReapScope & {
|
|
107
|
+
now?: number;
|
|
108
|
+
staleAfterMs?: number;
|
|
109
|
+
}): Promise<ReapKindResult[]>;
|
|
110
|
+
/** Re-exported so a caller reasoning about one name does not need two imports. */
|
|
111
|
+
export { testResourceAge };
|
|
112
|
+
//# sourceMappingURL=reap.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reap.d.ts","sourceRoot":"","sources":["../../src/test-utils/reap.ts"],"names":[],"mappings":"AAMA,OAAO,EAEL,KAAK,gBAAgB,EACrB,KAAK,YAAY,EAEjB,eAAe,EAChB,MAAM,WAAW,CAAC;AAEnB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH;;;;;;;GAOG;AACH,MAAM,WAAW,SAAS;IACxB,gHAAgH;IAChH,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,kFAAkF;AAClF,MAAM,WAAW,eAAe;IAC9B,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAC;IACd,oFAAoF;IACpF,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,uFAAuF;AACvF,MAAM,MAAM,aAAa,GAAG,CAAC,YAAY,GAAG;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,eAAe,CAAC;AAEjF,qFAAqF;AACrF,MAAM,WAAW,cAAc;IAC7B,mDAAmD;IACnD,KAAK,EAAE,MAAM,CAAC;IACd,kCAAkC;IAClC,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,+EAA+E;IAC/E,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,6DAA6D;IAC7D,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAOD,uGAAuG;AACvG,wBAAgB,cAAc,CAAC,IAAI,EAAE,SAAS,aAAa,EAAE,GAAG,MAAM,EAAE,CAEvE;AAED;;;;;;GAMG;AACH,wBAAsB,SAAS,CAC7B,IAAI,EAAE,SAAS,aAAa,EAAE,EAC9B,OAAO,GAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAO,GACpD,OAAO,CAAC,cAAc,EAAE,CAAC,CA+B3B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,gBAAgB,EAAE,OAAO,GAAE,SAAc,GAAG,aAAa,EAAE,CAkHtG;AAED;;;;;GAKG;AACH,wBAAsB,yBAAyB,CAC7C,KAAK,EAAE,gBAAgB,EACvB,OAAO,GAAE,SAAS,GAAG;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAO,GAChE,OAAO,CAAC,cAAc,EAAE,CAAC,CAc3B;AAED,kFAAkF;AAClF,OAAO,EAAE,eAAe,EAAE,CAAC"}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { CloudflareClients } from "../client/clients.js";
|
|
2
|
+
import { listEmailRoutingRules, namedRules } from "./emailRoutingRules.js";
|
|
3
|
+
import { emptyTestBucket, staleTestResourceNames, testResourceAge } from "./harness.js";
|
|
4
|
+
import { Cloudflare } from "cloudflare";
|
|
5
|
+
//#region src/test-utils/reap.ts
|
|
6
|
+
/** Whether a plan entry is a stand-in for a kind that cannot run. */
|
|
7
|
+
function isSkipped(entry) {
|
|
8
|
+
return "skipped" in entry;
|
|
9
|
+
}
|
|
10
|
+
/** Every label in a plan, in plan order — what a test asserts against so a dropped kind is visible. */
|
|
11
|
+
function reapPlanLabels(plan) {
|
|
12
|
+
return plan.map((entry) => entry.label);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Sweep every kind in a plan and report what went.
|
|
16
|
+
*
|
|
17
|
+
* Never throws, and never lets one kind's failure end the sweep: a reaper exists to help a suite, and a
|
|
18
|
+
* housekeeping error that fails the run is worse than the debris it was cleaning. A listing that throws
|
|
19
|
+
* yields an empty result for that kind; a removal that throws lands in `failed`, where it is visible.
|
|
20
|
+
*/
|
|
21
|
+
async function reapKinds(plan, options = {}) {
|
|
22
|
+
const results = [];
|
|
23
|
+
for (const entry of plan) {
|
|
24
|
+
if (isSkipped(entry)) {
|
|
25
|
+
results.push({
|
|
26
|
+
label: entry.label,
|
|
27
|
+
reaped: [],
|
|
28
|
+
failed: [],
|
|
29
|
+
skipped: entry.skipped
|
|
30
|
+
});
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const reaped = [];
|
|
34
|
+
const failed = [];
|
|
35
|
+
let names;
|
|
36
|
+
try {
|
|
37
|
+
names = await entry.list();
|
|
38
|
+
} catch {
|
|
39
|
+
results.push({
|
|
40
|
+
label: entry.label,
|
|
41
|
+
reaped,
|
|
42
|
+
failed,
|
|
43
|
+
skipped: null
|
|
44
|
+
});
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
for (const name of staleTestResourceNames(names, options)) try {
|
|
48
|
+
await entry.remove(name);
|
|
49
|
+
reaped.push(name);
|
|
50
|
+
} catch {
|
|
51
|
+
failed.push(name);
|
|
52
|
+
}
|
|
53
|
+
results.push({
|
|
54
|
+
label: entry.label,
|
|
55
|
+
reaped,
|
|
56
|
+
failed,
|
|
57
|
+
skipped: null
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return results;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Build the sweep plan for an account.
|
|
64
|
+
*
|
|
65
|
+
* Alphabetical by label, so the set is readable as a list rather than as an accident of construction
|
|
66
|
+
* order, and so a test can pin it. Every kind appears whether or not it can run — a kind that is missing
|
|
67
|
+
* a credential reports *why*, because "nothing to reap" and "unable to reap" look identical in a log and
|
|
68
|
+
* only one of them is fine.
|
|
69
|
+
*
|
|
70
|
+
* R2 is the one kind an API token cannot reclaim: Cloudflare refuses to delete a non-empty bucket, and
|
|
71
|
+
* emptying one is an S3-protocol operation. So it needs the key pair, and says so when it lacks it.
|
|
72
|
+
*/
|
|
73
|
+
function testResourceReapPlan(creds, options = {}) {
|
|
74
|
+
const clients = new CloudflareClients({
|
|
75
|
+
accountId: creds.accountId,
|
|
76
|
+
apiToken: creds.apiToken
|
|
77
|
+
});
|
|
78
|
+
const sdk = new Cloudflare({ apiToken: creds.apiToken });
|
|
79
|
+
const workers = clients.workers();
|
|
80
|
+
const tokens = clients.accountTokens();
|
|
81
|
+
const d1 = clients.d1Provisioner();
|
|
82
|
+
const kv = clients.kvProvisioner();
|
|
83
|
+
const vectorize = clients.vectorizeProvisioner();
|
|
84
|
+
const r2 = clients.r2Provisioner();
|
|
85
|
+
return [
|
|
86
|
+
{
|
|
87
|
+
label: "API token",
|
|
88
|
+
list: async () => (await tokens.listTokens()).map((token) => token.name),
|
|
89
|
+
remove: async (name) => {
|
|
90
|
+
await tokens.deleteTokensByName(name);
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
label: "D1 database",
|
|
95
|
+
list: async () => (await d1.listDatabases()).map((database) => database.name),
|
|
96
|
+
remove: async (name) => {
|
|
97
|
+
const found = (await d1.listDatabases()).find((database) => database.name === name);
|
|
98
|
+
if (found) await d1.deleteDatabase(found.uuid);
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
options.emailRoutingZoneId ? {
|
|
102
|
+
label: "Email Routing rule",
|
|
103
|
+
list: async () => namedRules(await listEmailRoutingRules(creds, options.emailRoutingZoneId ?? "")).map((rule) => rule.name),
|
|
104
|
+
remove: async (name) => {
|
|
105
|
+
await clients.emailRouting().removeWorkerRoute({
|
|
106
|
+
zoneId: options.emailRoutingZoneId ?? "",
|
|
107
|
+
ruleName: name
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
} : {
|
|
111
|
+
label: "Email Routing rule",
|
|
112
|
+
skipped: "no EMAIL_ROUTING_ZONE_ID: the zone to sweep is unknown."
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
label: "KV namespace",
|
|
116
|
+
list: async () => (await kv.listNamespaces()).map((namespace) => namespace.title),
|
|
117
|
+
remove: async (title) => {
|
|
118
|
+
const found = (await kv.listNamespaces()).find((namespace) => namespace.title === title);
|
|
119
|
+
if (found) await kv.deleteNamespace(found.id);
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
label: "Queue",
|
|
124
|
+
list: async () => {
|
|
125
|
+
const names = [];
|
|
126
|
+
for await (const queue of sdk.queues.list({ account_id: creds.accountId })) if (queue.queue_name) names.push(queue.queue_name);
|
|
127
|
+
return names;
|
|
128
|
+
},
|
|
129
|
+
remove: async (name) => {
|
|
130
|
+
for await (const queue of sdk.queues.list({ account_id: creds.accountId })) if (queue.queue_name === name && queue.queue_id) {
|
|
131
|
+
await sdk.queues.delete(queue.queue_id, { account_id: creds.accountId });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
creds.r2 ? {
|
|
137
|
+
label: "R2 bucket",
|
|
138
|
+
list: async () => (await r2.listBuckets()).map((bucket) => bucket.name),
|
|
139
|
+
remove: async (name) => {
|
|
140
|
+
await emptyTestBucket(creds, name);
|
|
141
|
+
await r2.deleteBucket(name);
|
|
142
|
+
}
|
|
143
|
+
} : {
|
|
144
|
+
label: "R2 bucket",
|
|
145
|
+
skipped: "no R2_CREDENTIALS: a bucket cannot be emptied, and R2 refuses to delete a non-empty one."
|
|
146
|
+
},
|
|
147
|
+
creds.secretsStoreId ? {
|
|
148
|
+
label: "Secrets Store entry",
|
|
149
|
+
list: async () => (await clients.secrets(creds.secretsStoreId).listSecrets()).map((entry) => entry.name),
|
|
150
|
+
remove: async (name) => {
|
|
151
|
+
await clients.secrets(creds.secretsStoreId).deleteSecretIfPresent(name);
|
|
152
|
+
}
|
|
153
|
+
} : {
|
|
154
|
+
label: "Secrets Store entry",
|
|
155
|
+
skipped: "no SECRETS_STORE_ID: the store to sweep is unknown."
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
label: "Vectorize index",
|
|
159
|
+
list: async () => (await vectorize.listIndexes()).map((index) => index.name),
|
|
160
|
+
remove: (name) => vectorize.deleteIndex(name)
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
label: "Worker script",
|
|
164
|
+
list: async () => (await workers.listWorkers()).map((script) => script.id ?? "").filter(Boolean),
|
|
165
|
+
remove: (name) => workers.deleteWorker(name)
|
|
166
|
+
}
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Reclaim every stale throwaway resource on the account, across every kind.
|
|
171
|
+
*
|
|
172
|
+
* The entry point a `globalSetup` calls. Reports one line per kind that did something or could not run;
|
|
173
|
+
* a kind with nothing to do stays quiet, because a clean account should produce a clean log.
|
|
174
|
+
*/
|
|
175
|
+
async function reapAllStaleTestResources(creds, options = {}) {
|
|
176
|
+
const results = await reapKinds(testResourceReapPlan(creds, options), options);
|
|
177
|
+
for (const result of results) {
|
|
178
|
+
if (result.skipped) console.warn(`stale ${result.label}(s) were not swept: ${result.skipped}`);
|
|
179
|
+
if (result.reaped.length > 0) console.warn(`reaped ${result.reaped.length} stale ${result.label}(s): ${result.reaped.join(", ")}`);
|
|
180
|
+
if (result.failed.length > 0) console.warn(`could not reap ${result.failed.length} stale ${result.label}(s): ${result.failed.join(", ")}`);
|
|
181
|
+
}
|
|
182
|
+
return results;
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
185
|
+
export { reapAllStaleTestResources, reapKinds, reapPlanLabels, testResourceAge, testResourceReapPlan };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CloudflareManager } from "../client/manager";
|
|
3
|
+
import { CfTokenVerification } from "../user/userManager";
|
|
4
|
+
/**
|
|
5
|
+
* One access policy to attach to a minted token: a set of permission groups (named, resolved to ids
|
|
6
|
+
* at mint time) and the resources they apply to. This is the reusable unit — every use case that
|
|
7
|
+
* needs a scoped CF token (the secrets manager is the first) describes itself as a list of these,
|
|
8
|
+
* and the manager turns names into the CF policy shape. `effect` defaults to `allow`.
|
|
9
|
+
*/
|
|
10
|
+
export interface TokenPermission {
|
|
11
|
+
/** Permission-group names to grant, resolved to ids against the live account list (e.g. "Secrets Store Read"). */
|
|
12
|
+
permissionGroupNames: string[];
|
|
13
|
+
/** The resource scope the groups apply to (e.g. `{ "com.cloudflare.api.account.<id>": "*" }`). */
|
|
14
|
+
resources: Record<string, string>;
|
|
15
|
+
/** Allow or deny the groups against the resources. Defaults to `allow`. */
|
|
16
|
+
effect?: "allow" | "deny";
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The whole-account resource scope for a token policy: `{ "com.cloudflare.api.account.<id>": "*" }`.
|
|
20
|
+
* Account-level permission groups (Secrets Store, D1, Workers, …) scope to this. One helper so the
|
|
21
|
+
* exact resource key format lives in a single place instead of being hand-spelled per use case.
|
|
22
|
+
*/
|
|
23
|
+
export declare function accountResource(accountId: string): Record<string, string>;
|
|
24
|
+
/**
|
|
25
|
+
* A permission group available to account-owned tokens, as returned by the permission-groups list.
|
|
26
|
+
* Only the id and name matter for resolution — the manager maps a requested name to its id.
|
|
27
|
+
*/
|
|
28
|
+
export declare const CfPermissionGroup: z.ZodObject<{
|
|
29
|
+
id: z.ZodString;
|
|
30
|
+
name: z.ZodString;
|
|
31
|
+
}, z.core.$strip>;
|
|
32
|
+
export type CfPermissionGroup = z.output<typeof CfPermissionGroup>;
|
|
33
|
+
/**
|
|
34
|
+
* A freshly minted account-owned token. The `value` is the secret bearer credential — present **only**
|
|
35
|
+
* in the create response, never re-readable — so it is captured here and must be stored at once
|
|
36
|
+
* (e.g. into the Secrets Store). Never log it.
|
|
37
|
+
*/
|
|
38
|
+
export declare const MintedAccountToken: z.ZodObject<{
|
|
39
|
+
id: z.ZodString;
|
|
40
|
+
value: z.ZodString;
|
|
41
|
+
name: z.ZodOptional<z.ZodString>;
|
|
42
|
+
status: z.ZodOptional<z.ZodEnum<{
|
|
43
|
+
active: "active";
|
|
44
|
+
disabled: "disabled";
|
|
45
|
+
expired: "expired";
|
|
46
|
+
}>>;
|
|
47
|
+
}, z.core.$strip>;
|
|
48
|
+
export type MintedAccountToken = z.output<typeof MintedAccountToken>;
|
|
49
|
+
/**
|
|
50
|
+
* An existing account-owned token's metadata, as returned by list/get. No `value` — Cloudflare never
|
|
51
|
+
* returns a token's secret after creation — so this is only enough to find a token by name and
|
|
52
|
+
* address it for deletion.
|
|
53
|
+
*/
|
|
54
|
+
export declare const AccountTokenSummary: z.ZodObject<{
|
|
55
|
+
id: z.ZodString;
|
|
56
|
+
name: z.ZodString;
|
|
57
|
+
status: z.ZodOptional<z.ZodEnum<{
|
|
58
|
+
active: "active";
|
|
59
|
+
disabled: "disabled";
|
|
60
|
+
expired: "expired";
|
|
61
|
+
}>>;
|
|
62
|
+
}, z.core.$strip>;
|
|
63
|
+
export type AccountTokenSummary = z.output<typeof AccountTokenSummary>;
|
|
64
|
+
/**
|
|
65
|
+
* Out-of-Worker control plane for **account-owned** Cloudflare API tokens: mint scoped, least-privilege
|
|
66
|
+
* tokens, find them by name, and delete them. Account-owned (not user-bound) by design — CLAUDE.md
|
|
67
|
+
* prefers org-level tokens that outlive any one person.
|
|
68
|
+
*
|
|
69
|
+
* This is the reusable seam behind every "pithy mints its own scoped token" case. A caller hands it a
|
|
70
|
+
* token name and a set of {@link TokenPermission}s (permission-group names + resource scope); the
|
|
71
|
+
* manager resolves the names to ids against the live account list and creates the token. The secrets
|
|
72
|
+
* manager's runtime credential is the first concrete use; more follow without touching this class.
|
|
73
|
+
*/
|
|
74
|
+
export declare class CloudflareAccountTokensManager extends CloudflareManager {
|
|
75
|
+
getServiceType(): string;
|
|
76
|
+
/** Prove access by listing permission groups — a read, never a token create/delete. Never throws. */
|
|
77
|
+
validateServiceAccess(): Promise<boolean>;
|
|
78
|
+
/**
|
|
79
|
+
* Verify the **calling** token against this account — `GET /accounts/{id}/tokens/verify`.
|
|
80
|
+
*
|
|
81
|
+
* Distinct from {@link CloudflareUserManager.verifyToken}, which hits the *user*-scoped
|
|
82
|
+
* `/user/tokens/verify` and returns `Invalid API Token` for an account-owned (`cfat_*`) credential.
|
|
83
|
+
* Since `CLAUDE.md` prefers account-owned tokens over user-bound ones, the user endpoint is the wrong
|
|
84
|
+
* one for the common case, and using it reports a working token as invalid.
|
|
85
|
+
*/
|
|
86
|
+
verifyToken(): Promise<CfTokenVerification>;
|
|
87
|
+
/**
|
|
88
|
+
* The name of an account-owned token by id, or `null` — `GET /accounts/{id}/tokens/{token_id}`.
|
|
89
|
+
*
|
|
90
|
+
* Distinct from {@link CloudflareUserManager.getTokenName}, which hits `GET /user/tokens/:id` and
|
|
91
|
+
* answers `9109 — Valid user-level authentication not found` for a `cfat_*` caller. Both halves of
|
|
92
|
+
* the account-token identity read — verify and name — are account-scoped, or neither is.
|
|
93
|
+
*
|
|
94
|
+
* **Best effort, by contract.** Reading a token record needs `API Tokens Read`, which the
|
|
95
|
+
* least-privilege tokens {@link mintToken} produces deliberately do not carry: a 403 is the normal
|
|
96
|
+
* answer for a CI credential asking its own name, not a fault. So a denied read returns `null` and
|
|
97
|
+
* the caller falls back to the token id from {@link verifyToken} — which any account token can call
|
|
98
|
+
* with no permission at all. Every other failure still throws.
|
|
99
|
+
*/
|
|
100
|
+
getTokenName(tokenId: string): Promise<string | null>;
|
|
101
|
+
/** Every permission group available to account-owned tokens in this account (SDK auto-paginates). */
|
|
102
|
+
listPermissionGroups(): Promise<CfPermissionGroup[]>;
|
|
103
|
+
/**
|
|
104
|
+
* Resolve permission-group names to their `{ id }` references against the account's live group list.
|
|
105
|
+
* Throws a clear, actionable error naming any name that does not exist, or any name that is
|
|
106
|
+
* **ambiguous** — Cloudflare reuses display names across resource scopes (e.g. account vs zone), so a
|
|
107
|
+
* name that maps to more than one id can't be resolved without picking the wrong scope. Either way a
|
|
108
|
+
* typo'd or ambiguous group fails loudly at mint time, never silently widening or narrowing scope.
|
|
109
|
+
*/
|
|
110
|
+
resolvePermissionGroups(names: string[]): Promise<Array<{
|
|
111
|
+
id: string;
|
|
112
|
+
}>>;
|
|
113
|
+
/**
|
|
114
|
+
* Mint a new account-owned token named `name` carrying `permissions`. Resolves every permission
|
|
115
|
+
* group name to its id (one group-list fetch for all policies), builds the CF policy set, and
|
|
116
|
+
* creates the token. Returns the token's one-time secret `value` — store it immediately. A 403 (the
|
|
117
|
+
* calling token cannot create tokens) is reraised as an actionable "grant 'Account API Tokens
|
|
118
|
+
* Write'" error so the cause is obvious; this doubles as the fail-fast preflight when provisioning
|
|
119
|
+
* runs the mint first.
|
|
120
|
+
*/
|
|
121
|
+
mintToken(name: string, permissions: TokenPermission[]): Promise<MintedAccountToken>;
|
|
122
|
+
/**
|
|
123
|
+
* Resolve names against a prefetched name→ids index, throwing on any unknown or ambiguous name.
|
|
124
|
+
* The shared core of {@link resolvePermissionGroups} and {@link mintToken}: take the group list once,
|
|
125
|
+
* resolve every name against it. Ambiguity (a name with more than one id) is a hard error, not a
|
|
126
|
+
* silent pick — see {@link resolvePermissionGroups}.
|
|
127
|
+
*/
|
|
128
|
+
private resolveAgainstIndex;
|
|
129
|
+
/** Every account-owned token's metadata (SDK auto-paginates). No secret values — list is metadata only. */
|
|
130
|
+
listTokens(): Promise<AccountTokenSummary[]>;
|
|
131
|
+
/** Find an account token by exact name, or `null` — for idempotent re-mint (SDK auto-paginates). */
|
|
132
|
+
findTokenByName(name: string): Promise<AccountTokenSummary | null>;
|
|
133
|
+
/** Delete an account token by id. */
|
|
134
|
+
deleteToken(tokenId: string): Promise<void>;
|
|
135
|
+
/**
|
|
136
|
+
* Delete every account token with the given name; returns how many were removed. Names are not
|
|
137
|
+
* unique in Cloudflare, so any duplicates (e.g. a prior interrupted run) are swept here. A no-op
|
|
138
|
+
* when none match. Used by teardown.
|
|
139
|
+
*/
|
|
140
|
+
deleteTokensByName(name: string): Promise<number>;
|
|
141
|
+
/**
|
|
142
|
+
* Roll an existing token's secret: regenerate its value **in place**, keeping the same token id,
|
|
143
|
+
* name, and policies (the dashboard's "Roll" action). Returns the new secret value — the only time
|
|
144
|
+
* Cloudflare hands it back — so it must be stored at once. The seam the deferred value-rotation
|
|
145
|
+
* builds on: a credential self-rolls without ever changing identity.
|
|
146
|
+
*/
|
|
147
|
+
rollTokenValue(tokenId: string): Promise<string>;
|
|
148
|
+
/**
|
|
149
|
+
* Ensure a named token exists and return a **fresh** secret for it. If a token of this name already
|
|
150
|
+
* exists, roll its value in place (same id and policies, new secret); otherwise mint a new one.
|
|
151
|
+
* Either way the caller gets a usable secret to store — the idempotent path for a credential that
|
|
152
|
+
* lives in the Secrets Store, since Cloudflare never returns a token's existing secret. Mirrors the
|
|
153
|
+
* dashboard's roll-or-create.
|
|
154
|
+
*/
|
|
155
|
+
rollToken(name: string, permissions: TokenPermission[]): Promise<MintedAccountToken>;
|
|
156
|
+
}
|
|
157
|
+
//# sourceMappingURL=accountTokensManager.d.ts.map
|