@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,67 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { CloudflareNotConfiguredError } from "../client/errors";
5
+
6
+ /**
7
+ * The permission catalog: short, stable permission **keys** (`d1:read`) mapped to the Cloudflare
8
+ * account **permission-group display names** they grant (`"D1 Read"`). Token profiles are declared in
9
+ * these keys, so the profiles read cleanly and the CF-specific names live in one place. The names are
10
+ * resolved to permission-group **ids** against the live account at mint time
11
+ * (`CloudflareAccountTokensManager.resolvePermissionGroups`), which fails loudly on an unknown or
12
+ * ambiguous name — so a name that drifts from Cloudflare's catalog is caught at mint, never silently
13
+ * mis-scoped. Verify the exact names against the account when adding a key; an adopter can override a
14
+ * profile's keys if their account differs.
15
+ */
16
+ export const PERMISSION_GROUPS = {
17
+ "d1:read": ["D1 Read"],
18
+ // "D1 Write", not "D1 Edit" — Cloudflare names the D1 group Write while several other services use
19
+ // Edit, and the account catalog has no group called "D1 Edit" at all. `accountTokensManager.integration.test.ts`
20
+ // now checks every name here against the live account, which is the only place that can tell.
21
+ "d1:write": ["D1 Write"],
22
+ "workers:write": ["Workers Scripts Write"],
23
+ "secrets:read": ["Secrets Store Read"],
24
+ "secrets:write": ["Secrets Store Write"],
25
+ "email:routing": ["Email Routing Rules Write"],
26
+ "kv:write": ["Workers KV Storage Write"],
27
+ "r2:read": ["Workers R2 Storage Read"],
28
+ "r2:write": ["Workers R2 Storage Write"],
29
+ "vectorize:read": ["Vectorize Read"],
30
+ "vectorize:write": ["Vectorize Write"],
31
+ "ai:read": ["Workers AI Read"],
32
+ // Read-only, and read-only is the whole point: Pithy attaches routes to zones and never creates,
33
+ // transfers, or deletes one — a zone is the adopter's relationship with their registrar. This exists
34
+ // so `pithy init` and `pithy worker add` can offer the account's real zones instead of asking someone
35
+ // to paste an id off a dashboard page.
36
+ "zone:read": ["Zone Read"],
37
+ } as const;
38
+
39
+ /** A known permission key — one of {@link PERMISSION_GROUPS}'s keys. */
40
+ export type PermissionKey = keyof typeof PERMISSION_GROUPS;
41
+
42
+ /** Narrow an arbitrary string to a {@link PermissionKey}. */
43
+ export function isPermissionKey(key: string): key is PermissionKey {
44
+ return key in PERMISSION_GROUPS;
45
+ }
46
+
47
+ /**
48
+ * Resolve permission keys to the CF permission-group display names they grant, de-duped in first-seen
49
+ * order. An unknown key fails with an actionable error naming the valid keys — caught at the CLI/config
50
+ * boundary before any CF call.
51
+ */
52
+ export function resolvePermissionKeys(keys: string[]): string[] {
53
+ const names: string[] = [];
54
+ for (const key of keys) {
55
+ if (!isPermissionKey(key)) {
56
+ throw new CloudflareNotConfiguredError({
57
+ message: `Unknown token permission key: ${key}.`,
58
+ action: `Use one of: ${Object.keys(PERMISSION_GROUPS).join(", ")}.`,
59
+ detail: `resolve permission keys: ${key} is not in the permission catalog`,
60
+ });
61
+ }
62
+ for (const name of PERMISSION_GROUPS[key]) {
63
+ if (!names.includes(name)) names.push(name);
64
+ }
65
+ }
66
+ return names;
67
+ }
@@ -0,0 +1,238 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Capability, TokenProfileSeam } from "@pithy-sh/core/src/capability/capability";
5
+ import { CloudflareNotConfiguredError } from "../client/errors";
6
+ import { accountResource, type TokenPermission } from "./accountTokensManager";
7
+ import { isPermissionKey, type PermissionKey, resolvePermissionKeys } from "./permissions";
8
+
9
+ /**
10
+ * Where a minted token's value is written.
11
+ *
12
+ * - `secrets-store` — the CF Secrets Store; a Worker reads it via its binding. The destination for a
13
+ * worker-consumer token; resolved from the token's declared secret (its registry backend) unless a
14
+ * profile or `--store` names it directly.
15
+ * - `dev-vars` — `<config>/<project>/tokens.json`, keyed by environment and outside every checkout.
16
+ * **Read by an operator, never by a command** — nothing resolves a credential from it. The store for
17
+ * the `ci-system` token: mint it here, read the value
18
+ * out, and set it as CI's `CLOUDFLARE_API_TOKEN`. It wrote `.dev.vars.<env>` *inside* the project until
19
+ * #182; the name stays because it is a public `--store` flag value.
20
+ * - `ephemeral` — nothing is written; the value is used in-process and discarded. The one-step CI
21
+ * path — CI can't read the CF Secrets Store, so it mints and uses the token in the same job.
22
+ */
23
+ export type TokenStore = "secrets-store" | "dev-vars" | "ephemeral";
24
+
25
+ /** The valid stores, for validating a `--store` flag or a profile's `defaultStore`. */
26
+ export const TOKEN_STORES: readonly TokenStore[] = ["secrets-store", "dev-vars", "ephemeral"];
27
+
28
+ /** The name of the one CI system token — the single least-privilege credential a CI pipeline runs under. */
29
+ export const CI_SYSTEM_PROFILE = "ci-system";
30
+
31
+ /**
32
+ * The base permissions the `ci-system` token always carries: deploy Workers, run migrations against
33
+ * remote D1, and read/write the Secrets Store (a deployed Worker binding CFSS secrets needs Secrets
34
+ * Store access at deploy). Every composed capability's {@link Capability.ciPermissions} unions on top.
35
+ */
36
+ const CI_SYSTEM_BASE: readonly PermissionKey[] = [
37
+ "workers:write",
38
+ "d1:read",
39
+ "d1:write",
40
+ "secrets:read",
41
+ "secrets:write",
42
+ ];
43
+
44
+ /**
45
+ * A named token profile: the CF permissions a job needs (short {@link PermissionKey}s), the resource
46
+ * scope, the **secret** its minted value is stored under (whose registry backend is the destination),
47
+ * and an optional built-in store override. The consuming code is the source of truth for its access;
48
+ * adopters override any field per profile.
49
+ */
50
+ export interface TokenProfile {
51
+ /** The profile's stable name — the key it is minted, listed, and revoked under. */
52
+ name: string;
53
+ /** The CF permissions the token grants, as short catalog keys (resolved to group names at mint). */
54
+ permissions: PermissionKey[];
55
+ /** The resource scope: `"account"` (the default) or an explicit CF resource map. */
56
+ resources?: "account" | Record<string, string>;
57
+ /** The secret-registry name the minted value is stored under; its declared backend is the destination. */
58
+ secret: string;
59
+ /**
60
+ * Whether the value behind {@link secret} differs per environment (the default) or is one value every
61
+ * environment shares. It decides the environment segment of the **CF Secrets Store entry name** the
62
+ * minted value is written to, and nothing else — never the variable key, which is a variable name
63
+ * and stays verbatim. A `global` profile writes one entry with the literal `global` in that slot,
64
+ * matching what provisioning wrote; an `environment` profile writes one entry per environment.
65
+ */
66
+ secretScope?: TokenSecretScope;
67
+ /** A built-in destination override (`dev-vars`/`ephemeral`/`secrets-store`); else the secret's backend. */
68
+ defaultStore?: TokenStore;
69
+ /** Why this profile exists / what job consumes it. */
70
+ description: string;
71
+ }
72
+
73
+ /** Whether a profile's secret is one value per environment or one value shared by all of them. */
74
+ export type TokenSecretScope = "environment" | "global";
75
+
76
+ /** The valid secret scopes, for validating a capability's declaration. */
77
+ const TOKEN_SECRET_SCOPES: readonly TokenSecretScope[] = ["environment", "global"];
78
+
79
+ /** An adopter's override of a profile's defaults (from `pithy.config.ts` or CLI flags). Every field optional. */
80
+ export interface ProfileOverride {
81
+ /** Replace the profile's permission keys. */
82
+ permissions?: PermissionKey[];
83
+ /** Replace the profile's resource scope with an explicit CF resource map. */
84
+ resources?: Record<string, string>;
85
+ /** Replace where the value is written (`--store`). */
86
+ store?: TokenStore;
87
+ }
88
+
89
+ /** The env-var / secret name a profile's token is stored under: `CF_TOKEN_<PROFILE>`. */
90
+ export function tokenSecretName(profile: string): string {
91
+ return `CF_TOKEN_${profile.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
92
+ }
93
+
94
+ /**
95
+ * Author a token profile: validates its permission keys against the catalog at author time, so a typo
96
+ * fails where the profile is declared, not deep in a mint call. Returns the profile unchanged, typed.
97
+ */
98
+ export function defineTokenProfile(profile: TokenProfile): TokenProfile {
99
+ resolvePermissionKeys(profile.permissions); // throws on an unknown key
100
+ return profile;
101
+ }
102
+
103
+ /** Validate a capability's declared permission keys, throwing an actionable error on any unknown one. */
104
+ function assertPermissionKeys(keys: readonly string[], where: string): PermissionKey[] {
105
+ for (const key of keys) {
106
+ if (!isPermissionKey(key)) {
107
+ throw new CloudflareNotConfiguredError({
108
+ message: `${where} declares an unknown permission key: ${key}.`,
109
+ action:
110
+ "Use a known permission key (d1:read, d1:write, workers:write, secrets:read, secrets:write, email:routing, kv:write).",
111
+ });
112
+ }
113
+ }
114
+ return keys as PermissionKey[];
115
+ }
116
+
117
+ /** Build the aggregating `ci-system` profile: the base permissions ∪ every capability's `ciPermissions`. */
118
+ function ciSystemProfile(capabilities: readonly Capability[]): TokenProfile {
119
+ const permissions: PermissionKey[] = [...CI_SYSTEM_BASE];
120
+ for (const capability of capabilities) {
121
+ for (const key of assertPermissionKeys(capability.ciPermissions ?? [], `Capability "${capability.name}"`)) {
122
+ if (!permissions.includes(key)) permissions.push(key);
123
+ }
124
+ }
125
+ return {
126
+ name: CI_SYSTEM_PROFILE,
127
+ permissions,
128
+ secret: tokenSecretName(CI_SYSTEM_PROFILE),
129
+ defaultStore: "dev-vars",
130
+ description:
131
+ "The one CI pipeline credential: deploy Workers, migrate, and every composed capability's CI needs. Set it as CI's CLOUDFLARE_API_TOKEN.",
132
+ };
133
+ }
134
+
135
+ /**
136
+ * The seam fields this package reads. Core's {@link TokenProfileSeam} is the structural contract every
137
+ * capability declares against; `secretScope` is additive on top of it, so it is named here rather than
138
+ * in core — a seam value that omits it is still assignable, and one that carries it is read as declared.
139
+ *
140
+ * Exported so a capability can type its `tokenProfiles` entry against it. Declaring `secretScope` as an
141
+ * inline object literal directly in `tokenProfiles` still trips TypeScript's excess-property check
142
+ * against core's narrower `TokenProfileSeam`; declare the entry as its own `const` (with `as const` or
143
+ * `satisfies TokenProfileSeamInput`) and it flows through.
144
+ */
145
+ export interface TokenProfileSeamInput extends TokenProfileSeam {
146
+ readonly secretScope?: string;
147
+ }
148
+
149
+ /** Build a concrete, validated worker-consumer {@link TokenProfile} from a capability's structural seam entry. */
150
+ function profileFromSeam(name: string, seam: TokenProfileSeamInput, capability: string): TokenProfile {
151
+ const store = seam.defaultStore;
152
+ if (store !== undefined && !(TOKEN_STORES as readonly string[]).includes(store)) {
153
+ throw new CloudflareNotConfiguredError({
154
+ message: `Token profile "${name}" (capability "${capability}") declares an unknown store: ${store}.`,
155
+ action: `Use one of: ${TOKEN_STORES.join(", ")}.`,
156
+ });
157
+ }
158
+ const scope = seam.secretScope;
159
+ if (scope !== undefined && !(TOKEN_SECRET_SCOPES as readonly string[]).includes(scope)) {
160
+ throw new CloudflareNotConfiguredError({
161
+ message: `Token profile "${name}" (capability "${capability}") declares an unknown secret scope: ${scope}.`,
162
+ action: `Use one of: ${TOKEN_SECRET_SCOPES.join(", ")}.`,
163
+ });
164
+ }
165
+ return {
166
+ name,
167
+ permissions: assertPermissionKeys(seam.permissions, `Token profile "${name}"`),
168
+ resources: seam.resources,
169
+ secret: seam.secret ?? tokenSecretName(name),
170
+ secretScope: scope as TokenSecretScope | undefined,
171
+ defaultStore: store as TokenStore | undefined,
172
+ description: seam.description ?? name,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * The project's token-profile registry: the aggregating `ci-system` profile plus every composed
178
+ * capability's worker-consumer {@link Capability.tokenProfiles} slice. A capability profile that
179
+ * clashes with `ci-system` (or another capability's name) fails loudly at assembly. This is the one
180
+ * registry `pithy token` reads.
181
+ */
182
+ export function resolveTokenProfiles(capabilities: readonly Capability[]): Record<string, TokenProfile> {
183
+ const profiles: Record<string, TokenProfile> = { [CI_SYSTEM_PROFILE]: ciSystemProfile(capabilities) };
184
+ for (const capability of capabilities) {
185
+ for (const [name, seam] of Object.entries(capability.tokenProfiles ?? {})) {
186
+ if (profiles[name]) {
187
+ throw new CloudflareNotConfiguredError({
188
+ message: `Token profile "${name}" is declared more than once (capability "${capability.name}").`,
189
+ action: "Give each token profile a unique name.",
190
+ });
191
+ }
192
+ profiles[name] = profileFromSeam(name, seam, capability.name);
193
+ }
194
+ }
195
+ return profiles;
196
+ }
197
+
198
+ /**
199
+ * Resolve a profile by name from the aggregated registry, applying an adopter override. An unknown
200
+ * name fails with an actionable error listing the known profiles.
201
+ */
202
+ export function resolveProfile(
203
+ profiles: Record<string, TokenProfile>,
204
+ name: string,
205
+ override?: ProfileOverride,
206
+ ): TokenProfile {
207
+ const base = profiles[name];
208
+ if (!base) {
209
+ throw new CloudflareNotConfiguredError({
210
+ message: `Unknown token profile: ${name}.`,
211
+ action: `Use one of: ${Object.keys(profiles).join(", ")}.`,
212
+ detail: `resolve token profile: ${name} is not in the registry`,
213
+ });
214
+ }
215
+ if (!override) return base;
216
+ return {
217
+ ...base,
218
+ permissions: override.permissions ?? base.permissions,
219
+ resources: override.resources ?? base.resources,
220
+ defaultStore: override.store ?? base.defaultStore,
221
+ };
222
+ }
223
+
224
+ /** Build the account-scoped {@link TokenPermission}s for a set of permission keys — the mint input. */
225
+ export function permissionsForKeys(keys: PermissionKey[], accountId: string): TokenPermission[] {
226
+ return [{ permissionGroupNames: resolvePermissionKeys(keys), resources: accountResource(accountId) }];
227
+ }
228
+
229
+ /**
230
+ * Turn a resolved profile into the {@link TokenPermission}s the account-tokens manager mints from: the
231
+ * profile's permission keys resolved to CF group names, scoped to the account (or the profile's
232
+ * explicit resources). One policy per profile — a token carries exactly the access its profile declares.
233
+ */
234
+ export function profilePermissions(profile: TokenProfile, accountId: string): TokenPermission[] {
235
+ const resources =
236
+ profile.resources && profile.resources !== "account" ? profile.resources : accountResource(accountId);
237
+ return [{ permissionGroupNames: resolvePermissionKeys(profile.permissions), resources }];
238
+ }
@@ -0,0 +1,177 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { JsonDate } from "@pithy-sh/core/src/data/codecs";
5
+ import type { Cloudflare } from "cloudflare";
6
+ import type { Widget, WidgetListResponse } from "cloudflare/resources/turnstile/widgets";
7
+ import { z } from "zod";
8
+ import { CloudflareInvalidResponseError, cloudflareRequest } from "../client/errors";
9
+ import { CloudflareManager } from "../client/manager";
10
+
11
+ /** The siteverify endpoint Turnstile tokens are validated against (server-side, not the SDK). */
12
+ const SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
13
+
14
+ /** Per-call SDK timeout + retry budget for widget management operations. */
15
+ const requestOptions: Cloudflare.RequestOptions = { timeout: 10000, maxRetries: 3 };
16
+
17
+ /**
18
+ * The two widget modes Pithy provisions. `managed` is Cloudflare's *visible* managed widget (CF decides
19
+ * whether to show interaction) — used where a challenge should be seen (a login page). `invisible` runs
20
+ * silently — used where a form should not interrupt (a lead capture). One of each per domain, max.
21
+ */
22
+ export type TurnstileWidgetMode = "managed" | "invisible";
23
+
24
+ /**
25
+ * The server-side response from Turnstile's `/siteverify` endpoint. Pithy validates the raw JSON
26
+ * through this object before trusting it — a humanity check is a security boundary (CLAUDE.md §HTTP:
27
+ * Turnstile is composable middleware). `challenge_ts` is an ISO-8601 string on the wire, decoded to
28
+ * a `Date` via the `JsonDate` codec.
29
+ */
30
+ export const TurnstileVerification = z
31
+ .object({
32
+ success: z.boolean().describe("Whether the token passed the humanity challenge."),
33
+ "error-codes": z.array(z.string()).default([]).describe("Machine-readable failure reasons; empty on success."),
34
+ messages: z.array(z.string()).optional().describe("Optional human-readable notices from Cloudflare."),
35
+ challenge_ts: JsonDate.optional().describe("When the challenge was solved (ISO-8601 on the wire, Date in JS)."),
36
+ hostname: z.string().optional().describe("The hostname the challenge was solved on."),
37
+ action: z.string().optional().describe("The customer-supplied action label bound to the token."),
38
+ cdata: z.string().optional().describe("The customer-supplied context data bound to the token."),
39
+ })
40
+ .describe("Server-side Turnstile siteverify response from challenges.cloudflare.com.");
41
+ export type TurnstileVerification = z.output<typeof TurnstileVerification>;
42
+
43
+ /** Options for a server-side `verify` call. */
44
+ export interface TurnstileVerifyOptions {
45
+ /** The end-user IP the token was issued to, for an extra integrity check. */
46
+ remoteIp?: string;
47
+ /** The expected action label, asserted by Cloudflare against the token. */
48
+ action?: string;
49
+ }
50
+
51
+ /**
52
+ * Out-of-Worker Turnstile access over the REST API: server-side token verification against
53
+ * `/siteverify`, plus widget management (create/list/update/rotate/delete) via the SDK. Inside a
54
+ * Worker the same siteverify call works over `fetch`; this manager is the CLI/CI/provisioning
55
+ * counterpart, addressed by account.
56
+ */
57
+ export class CloudflareTurnstileManager extends CloudflareManager {
58
+ /**
59
+ * Verify a Turnstile token server-side against `/siteverify`. The secret is the widget's secret
60
+ * key (never the sitekey). The raw response is validated through `TurnstileVerification`; a
61
+ * malformed body throws `cloudflare/invalid_response`, a network failure is wrapped as
62
+ * `cloudflare/request_failed`.
63
+ */
64
+ async verify(token: string, secret: string, options?: TurnstileVerifyOptions): Promise<TurnstileVerification> {
65
+ const body = new URLSearchParams({ secret, response: token });
66
+ if (options?.remoteIp) body.set("remoteip", options.remoteIp);
67
+ if (options?.action) body.set("action", options.action);
68
+
69
+ const raw = await cloudflareRequest("Turnstile siteverify", async () => {
70
+ const response = await fetch(SITEVERIFY_URL, {
71
+ method: "POST",
72
+ headers: { "content-type": "application/x-www-form-urlencoded" },
73
+ body,
74
+ });
75
+ if (!response.ok) {
76
+ throw new CloudflareInvalidResponseError({
77
+ message: "Turnstile siteverify returned a non-OK status.",
78
+ detail: `siteverify responded ${response.status} ${response.statusText}.`,
79
+ });
80
+ }
81
+ return (await response.json()) as unknown;
82
+ });
83
+
84
+ const parsed = TurnstileVerification.safeParse(raw);
85
+ if (!parsed.success) {
86
+ throw new CloudflareInvalidResponseError({
87
+ message: "Turnstile siteverify response had an unexpected shape.",
88
+ detail: parsed.error.message,
89
+ });
90
+ }
91
+ return parsed.data;
92
+ }
93
+
94
+ /** Find a widget by name. Returns null when no widget matches; never throws on a missing match. */
95
+ async getTurnstile(name: string): Promise<WidgetListResponse | null> {
96
+ return cloudflareRequest(`Turnstile get widget '${name}'`, async () => {
97
+ for await (const widget of this.getClient().turnstile.widgets.list({ account_id: this.accountId })) {
98
+ if (widget.name === name) return widget;
99
+ }
100
+ return null;
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Every widget claiming `domain`, matched case-insensitively (hostnames are). The sibling of
106
+ * {@link getTurnstile}, keyed on the domain instead of the name — and **plural**, deliberately:
107
+ * Cloudflare's widget names are not unique and a domain may legitimately appear on several widgets,
108
+ * so returning one would hide the rest from a caller deciding whether the domain is free.
109
+ *
110
+ * Exact hostname equality only. Cloudflare treats a widget's domain as covering its subdomains, but
111
+ * this is the input to a *refusal*, and a refusal inferred from a subdomain relationship would block
112
+ * `app.example.com` because someone once made a widget for `example.com`.
113
+ */
114
+ async listTurnstilesByDomain(domain: string): Promise<WidgetListResponse[]> {
115
+ const wanted = domain.toLowerCase();
116
+ return cloudflareRequest(`Turnstile list widgets for '${domain}'`, async () => {
117
+ const matches: WidgetListResponse[] = [];
118
+ for await (const widget of this.getClient().turnstile.widgets.list({ account_id: this.accountId })) {
119
+ if (widget.domains?.some((each) => each.toLowerCase() === wanted)) matches.push(widget);
120
+ }
121
+ return matches;
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Create a widget for the given domains in the requested mode — `managed` (visible) or `invisible`
127
+ * (silent). Defaults to `invisible` so existing callers keep their behavior.
128
+ */
129
+ async addTurnstile(name: string, domains: string[], mode: TurnstileWidgetMode = "invisible"): Promise<Widget> {
130
+ return cloudflareRequest(`Turnstile create widget '${name}'`, () =>
131
+ this.getClient().turnstile.widgets.create({ account_id: this.accountId, domains, mode, name }, requestOptions),
132
+ );
133
+ }
134
+
135
+ /** Delete a widget by its sitekey. */
136
+ async deleteTurnstile(siteKey: string): Promise<void> {
137
+ await cloudflareRequest(`Turnstile delete widget '${siteKey}'`, () =>
138
+ this.getClient().turnstile.widgets.delete(siteKey, { account_id: this.accountId }, requestOptions),
139
+ );
140
+ }
141
+
142
+ /** Replace the allowed domains on an existing widget (kept in invisible mode). */
143
+ async updateTurnstileDomains(siteKey: string, domains: string[], name: string): Promise<Widget> {
144
+ return cloudflareRequest(`Turnstile update domains for '${siteKey}'`, () =>
145
+ this.getClient().turnstile.widgets.update(
146
+ siteKey,
147
+ { account_id: this.accountId, domains, mode: "invisible", name },
148
+ requestOptions,
149
+ ),
150
+ );
151
+ }
152
+
153
+ /** Rotate a widget's secret key, invalidating the old secret immediately. */
154
+ async rotateTurnstile(siteKey: string): Promise<Widget> {
155
+ return cloudflareRequest(`Turnstile rotate secret for '${siteKey}'`, () =>
156
+ this.getClient().turnstile.widgets.rotateSecret(
157
+ siteKey,
158
+ { account_id: this.accountId, invalidate_immediately: true },
159
+ requestOptions,
160
+ ),
161
+ );
162
+ }
163
+
164
+ getServiceType(): string {
165
+ return "Cloudflare Turnstile";
166
+ }
167
+
168
+ /** Prove access by listing widgets. Never throws. */
169
+ async validateServiceAccess(): Promise<boolean> {
170
+ try {
171
+ await this.getClient().turnstile.widgets.list({ account_id: this.accountId });
172
+ return true;
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
177
+ }
@@ -0,0 +1,73 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { cloudflareRequest, decodeResponse } from "../client/errors";
6
+ import { CloudflareManager } from "../client/manager";
7
+
8
+ /**
9
+ * Out-of-Worker read access to the **user/token** identity behind a CF API token — `GET /user` and
10
+ * `GET /user/tokens/verify`/`/user/tokens/:id`. The audit CLI emitter uses it to attribute a
11
+ * control-plane action (`pithy migrate`, `pithy deploy`, …) to the right actor: a user token
12
+ * (`cfut_*`) resolves to the developer's email, an account token (`cfat_*`) to the token's name.
13
+ *
14
+ * These are *user-scoped* endpoints, so they need a user-bound token — CLAUDE.md prefers
15
+ * account-owned tokens for provisioning, but actor attribution is exactly the case where the token's
16
+ * own identity is the point. Reads only; never mints, never logs a token value.
17
+ */
18
+
19
+ /** The CF user behind a user token. The SDK omits `email` from its type, but the API returns it. */
20
+ export const CfUserIdentity = z
21
+ .object({
22
+ id: z.string().optional().describe("The Cloudflare user id — stable cross-reference for the actor."),
23
+ email: z.string().optional().describe("The Cloudflare user's email — the human actor's identifier."),
24
+ })
25
+ .describe("The identity behind a Cloudflare user (`cfut_*`) API token, from `GET /user`.");
26
+ export type CfUserIdentity = z.output<typeof CfUserIdentity>;
27
+
28
+ /** The result of verifying the calling token: its id and lifecycle status. */
29
+ export const CfTokenVerification = z
30
+ .object({
31
+ id: z.string().describe("The token's id — addresses it for a follow-up name lookup."),
32
+ status: z.string().describe("The token's lifecycle status (`active` | `disabled` | `expired`)."),
33
+ })
34
+ .describe("The result of `GET /user/tokens/verify` for the calling Cloudflare API token.");
35
+ export type CfTokenVerification = z.output<typeof CfTokenVerification>;
36
+
37
+ /** Just the token's name, the piece the account-token actor path needs. */
38
+ const TokenName = z.object({ name: z.string() });
39
+
40
+ export class CloudflareUserManager extends CloudflareManager {
41
+ getServiceType(): string {
42
+ return "Cloudflare User";
43
+ }
44
+
45
+ /** Prove access by reading the user record. Never throws. */
46
+ async validateServiceAccess(): Promise<boolean> {
47
+ try {
48
+ await this.getUser();
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ /** The Cloudflare user behind a user (`cfut_*`) token — `GET /user`. */
56
+ async getUser(): Promise<CfUserIdentity> {
57
+ const raw = await cloudflareRequest("get user", () => this.getClient().user.get());
58
+ return decodeResponse(CfUserIdentity, raw, "user get");
59
+ }
60
+
61
+ /** Verify the calling token and read its id + status — `GET /user/tokens/verify`. */
62
+ async verifyToken(): Promise<CfTokenVerification> {
63
+ const raw = await cloudflareRequest("verify token", () => this.getClient().user.tokens.verify());
64
+ return decodeResponse(CfTokenVerification, raw, "token verify");
65
+ }
66
+
67
+ /** The name of a token by id, or `null` if it can't be read — `GET /user/tokens/:id`. */
68
+ async getTokenName(tokenId: string): Promise<string | null> {
69
+ const raw = await cloudflareRequest(`get token ${tokenId}`, () => this.getClient().user.tokens.get(tokenId));
70
+ const parsed = TokenName.safeParse(raw);
71
+ return parsed.success ? parsed.data.name : null;
72
+ }
73
+ }