@pithy-sh/cloudflare 0.4.2 → 0.6.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 (95) hide show
  1. package/README.md +3 -3
  2. package/dist/ai/aiManager.d.ts +15 -1
  3. package/dist/ai/aiManager.d.ts.map +1 -1
  4. package/dist/ai/aiManager.js +39 -6
  5. package/dist/analytics/analyticsManager.d.ts +78 -0
  6. package/dist/analytics/analyticsManager.d.ts.map +1 -0
  7. package/dist/analytics/analyticsManager.js +206 -0
  8. package/dist/analytics/datasets.d.ts +26 -0
  9. package/dist/analytics/datasets.d.ts.map +1 -0
  10. package/dist/analytics/datasets.js +26 -0
  11. package/dist/analytics/window.d.ts +50 -0
  12. package/dist/analytics/window.d.ts.map +1 -0
  13. package/dist/analytics/window.js +67 -0
  14. package/dist/artifacts/artifactsManager.d.ts +52 -0
  15. package/dist/artifacts/artifactsManager.d.ts.map +1 -0
  16. package/dist/artifacts/artifactsManager.js +150 -0
  17. package/dist/client/clients.d.ts +13 -1
  18. package/dist/client/clients.d.ts.map +1 -1
  19. package/dist/client/clients.js +22 -1
  20. package/dist/client/manager.d.ts +13 -2
  21. package/dist/client/manager.d.ts.map +1 -1
  22. package/dist/client/manager.js +18 -5
  23. package/dist/d1/d1Manager.d.ts.map +1 -1
  24. package/dist/d1/d1Manager.js +3 -3
  25. package/dist/d1/d1PreparedStatement.d.ts +12 -0
  26. package/dist/d1/d1PreparedStatement.d.ts.map +1 -1
  27. package/dist/d1/d1PreparedStatement.js +13 -3
  28. package/dist/media/imageManager.d.ts +25 -0
  29. package/dist/media/imageManager.d.ts.map +1 -1
  30. package/dist/media/imageManager.js +58 -2
  31. package/dist/media/streamManager.d.ts +15 -0
  32. package/dist/media/streamManager.d.ts.map +1 -1
  33. package/dist/media/streamManager.js +28 -1
  34. package/dist/oauth/callback.d.ts +32 -0
  35. package/dist/oauth/callback.d.ts.map +1 -0
  36. package/dist/oauth/callback.js +58 -0
  37. package/dist/oauth/catalog.d.ts +26 -0
  38. package/dist/oauth/catalog.d.ts.map +1 -0
  39. package/dist/oauth/catalog.js +30 -0
  40. package/dist/oauth/client.d.ts +126 -0
  41. package/dist/oauth/client.d.ts.map +1 -0
  42. package/dist/oauth/client.js +200 -0
  43. package/dist/oauth/scopes.d.ts +62 -0
  44. package/dist/oauth/scopes.d.ts.map +1 -0
  45. package/dist/oauth/scopes.js +79 -0
  46. package/dist/queue/queueProvisioner.d.ts +30 -0
  47. package/dist/queue/queueProvisioner.d.ts.map +1 -0
  48. package/dist/queue/queueProvisioner.js +72 -0
  49. package/dist/r2/r2Manager.d.ts +11 -1
  50. package/dist/r2/r2Manager.d.ts.map +1 -1
  51. package/dist/r2/r2Manager.js +18 -1
  52. package/dist/secrets/secretsStoreManager.d.ts.map +1 -1
  53. package/dist/secrets/secretsStoreManager.js +3 -2
  54. package/dist/test-utils/settle.d.ts +79 -0
  55. package/dist/test-utils/settle.d.ts.map +1 -0
  56. package/dist/test-utils/settle.js +105 -0
  57. package/dist/tokens/accountTokensManager.d.ts +8 -7
  58. package/dist/tokens/accountTokensManager.d.ts.map +1 -1
  59. package/dist/tokens/accountTokensManager.js +2 -1
  60. package/dist/tokens/permissions.d.ts +49 -0
  61. package/dist/tokens/permissions.d.ts.map +1 -1
  62. package/dist/tokens/permissions.js +56 -2
  63. package/dist/tokens/profiles.d.ts +10 -30
  64. package/dist/tokens/profiles.d.ts.map +1 -1
  65. package/dist/tokens/profiles.js +48 -36
  66. package/dist/tokens/runtime.d.ts +16 -0
  67. package/dist/tokens/runtime.d.ts.map +1 -0
  68. package/dist/tokens/runtime.js +18 -0
  69. package/dist/workers/buildsManager.js +1 -1
  70. package/dist/workflows/stepFailure.js +1 -1
  71. package/dist/workflows/workflowsClient.js +1 -1
  72. package/package.json +3 -3
  73. package/src/ai/aiManager.ts +44 -5
  74. package/src/analytics/analyticsManager.ts +311 -0
  75. package/src/analytics/datasets.ts +30 -0
  76. package/src/analytics/window.ts +101 -0
  77. package/src/artifacts/artifactsManager.ts +199 -0
  78. package/src/client/clients.ts +28 -1
  79. package/src/client/manager.ts +24 -6
  80. package/src/d1/d1Manager.ts +7 -3
  81. package/src/d1/d1PreparedStatement.ts +21 -2
  82. package/src/media/imageManager.ts +93 -1
  83. package/src/media/streamManager.ts +34 -0
  84. package/src/oauth/callback.ts +70 -0
  85. package/src/oauth/catalog.ts +39 -0
  86. package/src/oauth/client.ts +265 -0
  87. package/src/oauth/scopes.ts +91 -0
  88. package/src/queue/queueProvisioner.ts +79 -0
  89. package/src/r2/r2Manager.ts +29 -2
  90. package/src/secrets/secretsStoreManager.ts +2 -1
  91. package/src/test-utils/settle.ts +139 -0
  92. package/src/tokens/accountTokensManager.ts +12 -0
  93. package/src/tokens/permissions.ts +81 -0
  94. package/src/tokens/profiles.ts +58 -70
  95. package/src/tokens/runtime.ts +18 -0
@@ -0,0 +1,265 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { createHash, randomBytes } from "node:crypto";
5
+ import { commandName } from "@pithy-sh/core/src/cli/command";
6
+ import { UpstreamError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { z } from "zod";
8
+ import { cloudflareRequest, decodeResponse } from "../client/errors";
9
+
10
+ /**
11
+ * **The Pithy OAuth client: one, owned by us, authorized by each adopter against their own account (#759).**
12
+ *
13
+ * Registered 2026-10-15 on the `pithy.` account as `Pithy CLI` — a public client, so no secret ships in
14
+ * this package: `token_endpoint_auth_method: none`, `authorization_code` with PKCE `S256`, and
15
+ * `refresh_token`. Verified by a DNS TXT record on `pithy.sh`, its `client_uri`, and public, which is
16
+ * what lets a user on any account sign in to it. An identifier, not a credential.
17
+ */
18
+ export const PITHY_OAUTH_CLIENT_ID = "5ae99cf157ae3e57d354e9c25ad3f7e2";
19
+
20
+ /** The endpoints, from `https://dash.cloudflare.com/.well-known/openid-configuration`. */
21
+ export const CLOUDFLARE_OAUTH = {
22
+ authorize: "https://dash.cloudflare.com/oauth2/auth",
23
+ token: "https://dash.cloudflare.com/oauth2/token",
24
+ revoke: "https://dash.cloudflare.com/oauth2/revoke",
25
+ } as const;
26
+
27
+ /** How long a token-endpoint call may take before it is a timeout rather than a wait. */
28
+ const OAUTH_TIMEOUT_MS = 30_000;
29
+
30
+ /** The slice of `fetch` this module calls: a form POST. Injected, so no test reaches Cloudflare. */
31
+ export type OAuthFetch = (
32
+ url: string,
33
+ init: { method: "POST"; headers: Record<string, string>; body: URLSearchParams; signal: AbortSignal },
34
+ ) => Promise<Response>;
35
+
36
+ /** A PKCE verifier and the challenge derived from it. The verifier never leaves the process that made it. */
37
+ export interface PkcePair {
38
+ /** 43 base64url characters of randomness — sent only to the token endpoint, with the code. */
39
+ verifier: string;
40
+ /** `BASE64URL(SHA256(verifier))` — sent in the authorize URL. */
41
+ challenge: string;
42
+ }
43
+
44
+ /**
45
+ * A fresh PKCE pair.
46
+ *
47
+ * **This is what makes a pasted address safe to accept.** The code in the address a browser lands on is
48
+ * redeemable only with the verifier, and the verifier exists only in the CLI that opened the flow — so a
49
+ * code read off a screen, typed across an SSH session, or seen by anyone else is worth nothing elsewhere.
50
+ */
51
+ export function pkcePair(): PkcePair {
52
+ const verifier = randomBytes(32).toString("base64url");
53
+ return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
54
+ }
55
+
56
+ /** A fresh `state`. Cloudflare refuses one under eight characters; this is twenty-two. */
57
+ export function oauthState(): string {
58
+ return randomBytes(16).toString("base64url");
59
+ }
60
+
61
+ /** What an authorize URL is built from. */
62
+ export interface AuthorizeRequest {
63
+ /** The scopes to request — `sessionScopes`, never a hand-written list. */
64
+ scopes: readonly string[];
65
+ /** The loopback redirect: `http://127.0.0.1:<port>/callback`. */
66
+ redirectUri: string;
67
+ /** The `state` the callback must echo. */
68
+ state: string;
69
+ /** The PKCE challenge. */
70
+ challenge: string;
71
+ }
72
+
73
+ /** The URL a browser opens to sign in and consent. */
74
+ export function authorizeUrl(request: AuthorizeRequest): string {
75
+ const url = new URL(CLOUDFLARE_OAUTH.authorize);
76
+ url.search = new URLSearchParams({
77
+ response_type: "code",
78
+ client_id: PITHY_OAUTH_CLIENT_ID,
79
+ redirect_uri: request.redirectUri,
80
+ scope: request.scopes.join(" "),
81
+ state: request.state,
82
+ code_challenge: request.challenge,
83
+ code_challenge_method: "S256",
84
+ }).toString();
85
+ return url.toString();
86
+ }
87
+
88
+ /** The token endpoint's answer to a code exchange or a refresh. */
89
+ export const OAuthTokenResponse = z
90
+ .object({
91
+ access_token: z.string().min(1).describe("The bearer token API calls carry. One hour, measured."),
92
+ refresh_token: z
93
+ .string()
94
+ .min(1)
95
+ .optional()
96
+ .describe("Present only when `offline_access` was granted. Rotates on every use."),
97
+ token_type: z.string().describe("Always `bearer`."),
98
+ expires_in: z.number().int().positive().describe("Seconds until the access token expires."),
99
+ scope: z.string().describe("The scopes granted, space-separated. Compare it with what was requested."),
100
+ })
101
+ .describe("Cloudflare's token endpoint response, `POST /oauth2/token`.");
102
+ export type OAuthTokenResponse = z.output<typeof OAuthTokenResponse>;
103
+
104
+ /**
105
+ * The token endpoint's refusal body, per RFC 6749 §5.2.
106
+ *
107
+ * **Not the REST API's `errors[]`, so not `cloudflareRefusal`'s to word.** That composer reads the
108
+ * envelope `api.cloudflare.com` answers with and writes a permission-grant action for it; the OAuth
109
+ * endpoints on `dash.cloudflare.com` answer `{ error, error_description }`, and the remedy is a sign-in,
110
+ * not a grant. They are a service we do not control, so a refusal is `core/upstream_failed`.
111
+ */
112
+ const OAuthErrorResponse = z.object({
113
+ error: z.string(),
114
+ error_description: z.string().optional(),
115
+ });
116
+
117
+ /** A session's tokens, as this kit holds them. */
118
+ export interface OAuthGrant {
119
+ /** The bearer token. */
120
+ accessToken: string;
121
+ /** The refresh token, or `null` when `offline_access` was not granted. */
122
+ refreshToken: string | null;
123
+ /** When {@link accessToken} stops working, from `expires_in` and the clock at the answer. */
124
+ expiresAt: Date;
125
+ /** The granted scopes, space-separated, verbatim. */
126
+ scope: string;
127
+ }
128
+
129
+ /** What every token-endpoint call takes beyond its own fields. */
130
+ interface EndpointOptions {
131
+ /** Defaults to the global `fetch`. */
132
+ fetch?: OAuthFetch;
133
+ /** Defaults to `() => new Date()`. */
134
+ now?: () => Date;
135
+ }
136
+
137
+ /** One form POST to an OAuth endpoint: the status, and the body parsed as JSON where it is JSON. */
138
+ async function post(
139
+ operation: string,
140
+ url: string,
141
+ form: Record<string, string>,
142
+ options: EndpointOptions,
143
+ ): Promise<{ status: number; body: unknown }> {
144
+ const send = options.fetch ?? (globalThis.fetch as unknown as OAuthFetch);
145
+ return cloudflareRequest(operation, async () => {
146
+ const response = await send(url, {
147
+ method: "POST",
148
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
149
+ body: new URLSearchParams(form),
150
+ signal: AbortSignal.timeout(OAUTH_TIMEOUT_MS),
151
+ });
152
+ const text = await response.text();
153
+ let body: unknown;
154
+ try {
155
+ body = text === "" ? {} : JSON.parse(text);
156
+ } catch {
157
+ body = { error: "invalid_response", error_description: text.slice(0, 200) };
158
+ }
159
+ return { status: response.status, body };
160
+ });
161
+ }
162
+
163
+ /** The refusal's own words, or `null` when the body is not an RFC 6749 error. */
164
+ function refusalOf(body: unknown): { error: string; description: string } | null {
165
+ const parsed = OAuthErrorResponse.safeParse(body);
166
+ if (!parsed.success) return null;
167
+ return { error: parsed.data.error, description: parsed.data.error_description ?? parsed.data.error };
168
+ }
169
+
170
+ function grantOf(body: unknown, context: string, now: Date, keepRefresh: string | null): OAuthGrant {
171
+ const token = decodeResponse(OAuthTokenResponse, body, context);
172
+ return {
173
+ accessToken: token.access_token,
174
+ refreshToken: token.refresh_token ?? keepRefresh,
175
+ expiresAt: new Date(now.getTime() + token.expires_in * 1000),
176
+ scope: token.scope,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Redeem an authorization code for a session.
182
+ *
183
+ * A refusal is a `core/upstream_failed` carrying Cloudflare's own description — an expired code, a
184
+ * `redirect_uri` that does not match, a verifier that does not fit the challenge. Each is a sign-in that
185
+ * has to start over, and the description is what says which.
186
+ */
187
+ export async function exchangeCode(
188
+ request: { code: string; verifier: string; redirectUri: string } & EndpointOptions,
189
+ ): Promise<OAuthGrant> {
190
+ const now = request.now ?? (() => new Date());
191
+ const answer = await post(
192
+ "exchange the sign-in code",
193
+ CLOUDFLARE_OAUTH.token,
194
+ {
195
+ grant_type: "authorization_code",
196
+ code: request.code,
197
+ redirect_uri: request.redirectUri,
198
+ client_id: PITHY_OAUTH_CLIENT_ID,
199
+ code_verifier: request.verifier,
200
+ },
201
+ request,
202
+ );
203
+ if (answer.status !== 200) {
204
+ const refusal = refusalOf(answer.body);
205
+ throw new UpstreamError({
206
+ message: `Cloudflare refused the sign-in: ${refusal?.description ?? `HTTP ${answer.status}`}`,
207
+ action: `Run ${commandName("cloudflare connect")} again. A sign-in code works once, and only for a few minutes.`,
208
+ detail: `token endpoint ${answer.status}: ${refusal?.error ?? "no error body"}`,
209
+ });
210
+ }
211
+ return grantOf(answer.body, "token exchange", now(), null);
212
+ }
213
+
214
+ /** What a refresh comes back as: a new grant, or the server's refusal in its own words. */
215
+ export type RefreshResult =
216
+ | { kind: "refreshed"; grant: OAuthGrant }
217
+ | { kind: "refused"; error: string; description: string };
218
+
219
+ /**
220
+ * Renew a session without a browser.
221
+ *
222
+ * **A refusal is an answer, not a throw.** A spent, revoked or expired refresh token means *sign in
223
+ * again*, and the caller decides how to say so — at dispatch it is deferred to the first command that
224
+ * actually needs Cloudflare. Only a transport failure throws, because that one is worth retrying.
225
+ *
226
+ * The refresh token **rotates** on every use (measured). The one handed back must replace the one sent,
227
+ * on disk, before anything else reads it; the caller holds the profile's lock across that write.
228
+ */
229
+ export async function refreshGrant(request: { refreshToken: string } & EndpointOptions): Promise<RefreshResult> {
230
+ const now = request.now ?? (() => new Date());
231
+ const answer = await post(
232
+ "renew the Cloudflare session",
233
+ CLOUDFLARE_OAUTH.token,
234
+ { grant_type: "refresh_token", refresh_token: request.refreshToken, client_id: PITHY_OAUTH_CLIENT_ID },
235
+ request,
236
+ );
237
+ if (answer.status !== 200) {
238
+ const refusal = refusalOf(answer.body) ?? { error: `http_${answer.status}`, description: `HTTP ${answer.status}` };
239
+ return { kind: "refused", ...refusal };
240
+ }
241
+ return { kind: "refreshed", grant: grantOf(answer.body, "token refresh", now(), request.refreshToken) };
242
+ }
243
+
244
+ /**
245
+ * Revoke one token at Cloudflare. Throws on a refusal, so `disconnect` never reports a revocation that did
246
+ * not happen — the local file is deleted either way, and the operator is told which half held.
247
+ */
248
+ export async function revokeToken(
249
+ request: { token: string; hint: "access_token" | "refresh_token" } & EndpointOptions,
250
+ ): Promise<void> {
251
+ const answer = await post(
252
+ "revoke the Cloudflare session",
253
+ CLOUDFLARE_OAUTH.revoke,
254
+ { token: request.token, token_type_hint: request.hint, client_id: PITHY_OAUTH_CLIENT_ID },
255
+ request,
256
+ );
257
+ if (answer.status !== 200) {
258
+ const refusal = refusalOf(answer.body);
259
+ throw new UpstreamError({
260
+ message: `Cloudflare did not revoke the session: ${refusal?.description ?? `HTTP ${answer.status}`}`,
261
+ action: "Sign in to the Cloudflare dashboard and revoke the Pithy CLI's access to your account there.",
262
+ detail: `revoke endpoint ${answer.status}: ${refusal?.error ?? "no error body"}`,
263
+ });
264
+ }
265
+ }
@@ -0,0 +1,91 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { oauthScopesFor } from "../tokens/permissions";
5
+
6
+ /**
7
+ * The scopes every session asks for, whatever the project composes (#759).
8
+ *
9
+ * - `user-details.read` — `GET /user`, which is what turns an audit row's actor from a token's name into
10
+ * the person who signed in.
11
+ * - `offline_access` — the only way Cloudflare issues a refresh token. Without it a session dies at the
12
+ * hour and every command after it opens a browser (measured 2026-10-15).
13
+ * - `account-api-tokens.write` — the mint. A session mints `ci-system` and `runtime` as the project
14
+ * composes them, so the hand-made bootstrap token stops being a setup step.
15
+ * - `account-settings.read` — `GET /accounts`, so the account is discovered rather than stored. On its own
16
+ * it lists the account; `d1.write` alone listed nothing (both measured 2026-10-15), so discovery must not
17
+ * lean on whichever other scope a project happens to request.
18
+ * - `zone.read` — `pithy init` and `pithy worker add` offer the account's real zones, and `init` asks
19
+ * before there is a project whose capabilities could say so. Read-only, as {@link OAUTH_SCOPES} says of it.
20
+ *
21
+ * Everything else comes from the project's own token permissions, through {@link sessionScopes}.
22
+ */
23
+ export const SESSION_BASE_SCOPES = [
24
+ "user-details.read",
25
+ "offline_access",
26
+ "account-api-tokens.write",
27
+ "account-settings.read",
28
+ "zone.read",
29
+ ] as const;
30
+
31
+ /**
32
+ * The scopes Cloudflare's consent screen lists under **"Sensitive scopes"**, switched off, by the name it
33
+ * shows them under.
34
+ *
35
+ * **Approving with one switched off does not grant the rest.** The dashboard answers *"We could not
36
+ * complete the authorization. Please check your network connection and try again."* and no callback
37
+ * ever arrives (measured 2026-10-15). So `connect` names these before it opens the browser, and its
38
+ * timeout names them as the likeliest cause.
39
+ *
40
+ * **The list is ours to keep, because Cloudflare's is not published.** `GET /oauth/scopes` carries an
41
+ * id, a name, a category and nothing that marks a scope sensitive; only the consent screen says. Add to
42
+ * it when a consent screen shows another.
43
+ */
44
+ export const SENSITIVE_SCOPES: Readonly<Record<string, string>> = {
45
+ "account-api-tokens.write": "Account API Tokens Write",
46
+ };
47
+
48
+ /**
49
+ * The scopes a session needs to act for a project, and to mint every token it declares: the base, then
50
+ * the scope of each permission key, de-duped in first-seen order.
51
+ *
52
+ * **Derived, never listed.** Cloudflare's delegation rule binds the session as it binds a token — a mint
53
+ * whose groups the session does not hold is refused — so the request is exactly the union of what the
54
+ * project's tokens carry. A capability the project does not compose contributes nothing to it, and so
55
+ * nothing to the consent screen the adopter reads.
56
+ */
57
+ export function sessionScopes(permissionKeys: readonly string[]): string[] {
58
+ const scopes: string[] = [...SESSION_BASE_SCOPES];
59
+ for (const scope of oauthScopesFor(permissionKeys)) {
60
+ if (!scopes.includes(scope)) scopes.push(scope);
61
+ }
62
+ return scopes;
63
+ }
64
+
65
+ /**
66
+ * The requested scopes a grant does not carry, in request order.
67
+ *
68
+ * `granted` is the token endpoint's `scope`: one space-separated string. Today Cloudflare fails rather
69
+ * than grant a subset, so this should always be empty; it is checked because that is Cloudflare's
70
+ * behavior to change, and a session missing `account-api-tokens.write` would fail at the first mint.
71
+ */
72
+ export function missingScopes(requested: readonly string[], granted: string): string[] {
73
+ const held = new Set(granted.split(/\s+/).filter(Boolean));
74
+ return requested.filter((scope) => !held.has(scope));
75
+ }
76
+
77
+ /** One sensitive scope a request carries, by its id and the name the consent screen shows. */
78
+ export interface SensitiveScope {
79
+ /** The catalog id: `account-api-tokens.write`. */
80
+ id: string;
81
+ /** What the consent screen calls it: "Account API Tokens Write". */
82
+ name: string;
83
+ }
84
+
85
+ /** The sensitive scopes in a request, in request order. See {@link SENSITIVE_SCOPES}. */
86
+ export function sensitiveScopesIn(scopes: readonly string[]): SensitiveScope[] {
87
+ return scopes.flatMap((id) => {
88
+ const name = SENSITIVE_SCOPES[id];
89
+ return name === undefined ? [] : [{ id, name }];
90
+ });
91
+ }
@@ -0,0 +1,79 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { cloudflareRequest, decodeResponse, isNotFoundError } from "../client/errors";
6
+ import { CloudflareManager } from "../client/manager";
7
+
8
+ /** A queue's identity, decoded from the Queues API's `queue_id` and `queue_name`. */
9
+ export const QueueInfo = z
10
+ .object({
11
+ id: z.string().describe("The CF-assigned queue id, which the REST API addresses a queue by."),
12
+ name: z.string().describe("The queue name a producer binding names it by."),
13
+ })
14
+ .describe("A Cloudflare Queue's identity, as the Queues API returns it.");
15
+ export type QueueInfo = z.output<typeof QueueInfo>;
16
+
17
+ /** The Queues API's own spelling of a queue's identity, mapped onto {@link QueueInfo}. */
18
+ const QueueRecord = z
19
+ .object({
20
+ queue_id: z.string().describe("The CF-assigned queue id."),
21
+ queue_name: z.string().describe("The queue name."),
22
+ })
23
+ .transform(({ queue_id, queue_name }) => ({ id: queue_id, name: queue_name }))
24
+ .describe("A queue as the Queues API returns it.");
25
+
26
+ /**
27
+ * Account-level Queues control plane: **find, create and delete queues** (#662).
28
+ *
29
+ * `pithy provision` creates the queue behind every `queue` binding a composition declares, and `pithy feature
30
+ * destroy` deletes a feature's. A `queues.producers` entry names a queue, and `wrangler deploy` refuses one that
31
+ * does not exist, so the entry is only honest because this stands the queue up. Addressed by account — unlike
32
+ * {@link CloudflareQueueManager}, which targets one queue to send into.
33
+ */
34
+ export class CloudflareQueueProvisioner extends CloudflareManager {
35
+ getServiceType(): string {
36
+ return "Cloudflare Queues (control plane)";
37
+ }
38
+
39
+ /** Prove access by listing queues — a read, never a create or a delete. Never throws. */
40
+ async validateServiceAccess(): Promise<boolean> {
41
+ try {
42
+ await this.getClient().queues.list({ account_id: this.accountId });
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ /** Find a queue by exact name in the account, or `null` — for idempotent provisioning. */
50
+ async findQueueByName(name: string): Promise<QueueInfo | null> {
51
+ return cloudflareRequest(`find queue ${name}`, async () => {
52
+ for await (const queue of this.getClient().queues.list({ account_id: this.accountId })) {
53
+ const parsed = QueueRecord.safeParse(queue);
54
+ if (parsed.success && parsed.data.name === name) return parsed.data;
55
+ }
56
+ return null;
57
+ });
58
+ }
59
+
60
+ /** Create a queue by name; returns its id and name. */
61
+ async createQueue(name: string): Promise<QueueInfo> {
62
+ const response = await cloudflareRequest(`create queue ${name}`, () =>
63
+ this.getClient().queues.create({ account_id: this.accountId, queue_name: name }),
64
+ );
65
+ return decodeResponse(QueueRecord, response, "queue create");
66
+ }
67
+
68
+ /** Delete a queue by id. Idempotent — a missing queue is not an error, so teardown can re-run safely. */
69
+ async deleteQueue(queueId: string): Promise<void> {
70
+ await cloudflareRequest(`delete queue ${queueId}`, async () => {
71
+ try {
72
+ await this.getClient().queues.delete(queueId, { account_id: this.accountId });
73
+ } catch (error) {
74
+ if (isNotFoundError(error)) return;
75
+ throw error;
76
+ }
77
+ });
78
+ }
79
+ }
@@ -33,11 +33,29 @@ const LIST_PARTS_PAGE_SIZE = 1000;
33
33
  * the bucket it targets. R2 signs presigned URLs with the S3 keys, not the CF API token — both
34
34
  * come from config (no environment coupling).
35
35
  */
36
- export interface R2ManagerConfig extends CloudflareManagerConfig, R2Credentials {
36
+ export interface R2ManagerConfig extends Omit<CloudflareManagerConfig, "apiToken">, R2Credentials {
37
37
  /** The R2 bucket all object operations target. */
38
38
  bucketName: string;
39
+ /**
40
+ * A Cloudflare API token, for the one call here that is not S3: {@link CloudflareR2Manager.validateServiceAccess}.
41
+ *
42
+ * **Absent in a Worker (#728).** Every object operation is S3, signed with the key pair, so a Worker's
43
+ * object store holds no API token: the application's one is `runtime`, and R2 needs none of it. Without
44
+ * one, `validateServiceAccess` answers `false` and nothing calls the Cloudflare API. An empty string is
45
+ * still refused, because that is an unresolved value rather than a decision.
46
+ */
47
+ apiToken?: string;
39
48
  }
40
49
 
50
+ /**
51
+ * What the base client is built with when the config carries no API token. Never sent: {@link noApiToken}
52
+ * is the provider, and the base reads the token through it at every call and refuses an empty one.
53
+ */
54
+ const S3_ONLY = "r2-s3-only";
55
+
56
+ /** The token provider of an S3-only manager: no token, so any Cloudflare API call is refused before it is sent. */
57
+ const noApiToken = (): string => "";
58
+
41
59
  /** How long a presigned URL stays valid. Shared by every presign method so the option reads the same. */
42
60
  export interface PresignOptions {
43
61
  /** Lifetime in seconds. Defaults to one hour — long enough for a large upload, short enough to leak little. */
@@ -168,8 +186,15 @@ export class CloudflareR2Manager extends CloudflareManager {
168
186
 
169
187
  private s3Client: S3Client | null = null;
170
188
 
189
+ private readonly s3Only: boolean;
190
+
171
191
  constructor(config: R2ManagerConfig) {
172
- super(config);
192
+ super(
193
+ config.apiToken === undefined
194
+ ? { ...config, apiToken: S3_ONLY, tokenProvider: noApiToken }
195
+ : { ...config, apiToken: config.apiToken },
196
+ );
197
+ this.s3Only = config.apiToken === undefined;
173
198
  if (!config.bucketName) {
174
199
  throw new CloudflareNotConfiguredError({ detail: "Missing bucketName for R2 access." });
175
200
  }
@@ -538,6 +563,8 @@ export class CloudflareR2Manager extends CloudflareManager {
538
563
  * not merely that an S3 client could be built. Never throws.
539
564
  */
540
565
  async validateServiceAccess(): Promise<boolean> {
566
+ // An S3-only manager holds no API token to read the bucket record with.
567
+ if (this.s3Only) return false;
541
568
  try {
542
569
  await this.getClient().r2.buckets.get(this.bucketName, { account_id: this.accountId });
543
570
  return true;
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
+ import { remedy } from "@pithy-sh/core/src/cli/command";
4
5
  import { JsonDate } from "@pithy-sh/core/src/data/codecs";
5
6
  import { ConflictError, NotFoundError } from "@pithy-sh/core/src/error/pithyError";
6
7
  import { z } from "zod";
@@ -207,7 +208,7 @@ export class CloudflareSecretsStoreManager extends CloudflareManager {
207
208
  if (!target) {
208
209
  throw new NotFoundError({
209
210
  message: `Secret '${name}' is not in the store, so there is nothing to update.`,
210
- action: "Run `pithy secrets provision` to establish the entry, then run this again.",
211
+ action: `${remedy("secrets provision").say((command) => `Run \`${command}\`.`)} It establishes the entry; then run this again.`,
211
212
  detail: `update secret ${name}: no live entry of that name, and this verb never creates one`,
212
213
  });
213
214
  }
@@ -0,0 +1,139 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * **Waiting on an eventually consistent control plane, for the live suites.** One budget and one rule, shared
6
+ * by `@pithy-sh/cloudflare`'s Vectorize suite and `@pithy-sh/vector`'s, because the cloudflare suite kept a
7
+ * 60s budget and a single confirming read of its own and failed on exactly that (#709).
8
+ *
9
+ * **The budget is 120s, and 300s for a metadata index.** A Vectorize index became visible 25-50s after the
10
+ * call that made it, measured live, and an upsert became queryable in about 30s. A metadata index is slower:
11
+ * about 97s on one run of #709 and over 120s on the next, so its waits take {@link METADATA_INDEX_SETTLE_MS}.
12
+ *
13
+ * **A metadata-index wait compares names and types, never a count.** {@link metadataIndexesSettled} is the
14
+ * predicate; a list of the right length can still be the wrong list.
15
+ *
16
+ * **One confirming read proves nothing.** The metadata-index list is eventually consistent *and not
17
+ * monotonic*: a 3-minute probe watched it report `[]` for 26s, then both properties, then one of the two 18s
18
+ * later with nothing else running, then both again. So {@link waitForStable} waits for {@link STABLE_READS}
19
+ * agreeing reads in a row and hands back that snapshot, and a caller asserts against the snapshot rather
20
+ * than a fresh read, which can disagree with the one before it.
21
+ *
22
+ * **A test that waits several times shares one deadline across them.** Each wait's own budget bounds that
23
+ * wait and nothing else, so a test making eight of them in a row could outrun its vitest timeout with every
24
+ * step inside budget. Vitest then abandoned the body mid-poll: the failure said only "Test timed out", and the
25
+ * teardown that deletes the index never ran (#709). Pass the same {@link SettleOptions.deadline} to every
26
+ * wait and the step in progress when it passes fails by name, inside the body, where the teardown still runs.
27
+ */
28
+
29
+ /** Ceiling for any one wait. Measured at 25-50s for a control-plane change, and about 30s for an upsert. */
30
+ export const SETTLE_TIMEOUT_MS = 120_000;
31
+
32
+ /**
33
+ * Ceiling for a metadata-index wait, and only for that. A metadata index is the slowest change Vectorize
34
+ * applies: on #709 one settled in about 97s and the next was still unsettled at 120s, so the shared budget
35
+ * undercut exactly this wait. `pithy vector provision` waits on the same measurement.
36
+ */
37
+ export const METADATA_INDEX_SETTLE_MS = 300_000;
38
+
39
+ /**
40
+ * Consecutive agreeing reads that count as settled. Three, because one confirming read can be a stale replica,
41
+ * and two in a row six seconds apart is a far weaker coincidence.
42
+ */
43
+ export const STABLE_READS = 3;
44
+
45
+ /** How often a poll re-checks. Vectorize applies changes in tens of seconds, so a tighter loop only burns quota. */
46
+ export const POLL_INTERVAL_MS = 2_000;
47
+
48
+ /** How long to wait and how often to look. Every field defaults to the shared budget above. */
49
+ export interface SettleOptions {
50
+ /** Give up after this long. Defaults to {@link SETTLE_TIMEOUT_MS}. */
51
+ timeoutMs?: number;
52
+ /** Re-check this often. Defaults to {@link POLL_INTERVAL_MS}. */
53
+ intervalMs?: number;
54
+ /** Agreeing reads in a row that count as settled, for {@link waitForStable}. Defaults to {@link STABLE_READS}. */
55
+ stableReads?: number;
56
+ /**
57
+ * An absolute deadline (ms since the epoch) shared by every wait in a test. A wait gives up at whichever
58
+ * comes first, its own `timeoutMs` or this, and says which.
59
+ */
60
+ deadline?: number;
61
+ }
62
+
63
+ /** When a wait gives up, and the clause its timeout message ends the step name with. */
64
+ function limitOf(options: SettleOptions, started: number): { deadline: number; why: string } {
65
+ const own = started + (options.timeoutMs ?? SETTLE_TIMEOUT_MS);
66
+ if (options.deadline !== undefined && options.deadline < own) {
67
+ return { deadline: options.deadline, why: " (the shared deadline for this test ran out)" };
68
+ }
69
+ return { deadline: own, why: "" };
70
+ }
71
+
72
+ function seconds(ms: number): number {
73
+ return Math.round(ms / 1000);
74
+ }
75
+
76
+ function pause(ms: number): Promise<void> {
77
+ return new Promise((resolve) => setTimeout(resolve, ms));
78
+ }
79
+
80
+ /**
81
+ * Poll until `ready`, or fail with a message naming what was being waited for. A bare timeout tells the next
82
+ * reader nothing; naming the step tells them whether Cloudflare was slow or something is actually broken.
83
+ */
84
+ export async function waitFor(what: string, ready: () => Promise<boolean>, options: SettleOptions = {}): Promise<void> {
85
+ const started = Date.now();
86
+ const { deadline, why } = limitOf(options, started);
87
+ for (;;) {
88
+ if (await ready()) return;
89
+ if (Date.now() >= deadline) {
90
+ throw new Error(
91
+ `Timed out after ${seconds(Date.now() - started)}s waiting for ${what}${why}. Cloudflare applies this change asynchronously — this is either a slower-than-usual apply or a real failure.`,
92
+ );
93
+ }
94
+ await pause(options.intervalMs ?? POLL_INTERVAL_MS);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Poll until `settled` holds on {@link STABLE_READS} consecutive reads, and return the last value read. The
100
+ * caller asserts against the returned snapshot, never against a fresh read.
101
+ */
102
+ export async function waitForStable<T>(
103
+ what: string,
104
+ read: () => Promise<T>,
105
+ settled: (value: T) => boolean,
106
+ options: SettleOptions = {},
107
+ ): Promise<T> {
108
+ const started = Date.now();
109
+ const needed = options.stableReads ?? STABLE_READS;
110
+ const { deadline, why } = limitOf(options, started);
111
+ let agreed = 0;
112
+ let last = await read();
113
+ for (;;) {
114
+ agreed = settled(last) ? agreed + 1 : 0;
115
+ if (agreed >= needed) return last;
116
+ if (Date.now() >= deadline) {
117
+ throw new Error(
118
+ `Timed out after ${seconds(Date.now() - started)}s waiting for ${what} to settle across ${needed} reads${why}. Last read: ${JSON.stringify(last)}.`,
119
+ );
120
+ }
121
+ await pause(options.intervalMs ?? POLL_INTERVAL_MS);
122
+ last = await read();
123
+ }
124
+ }
125
+
126
+ /**
127
+ * The settle predicate for a metadata-index list: every declared index is live, under its own name and its own
128
+ * type. The rule `compareMetadataIndexes` applies with nothing missing and nothing mismatched, restated here
129
+ * because this package cannot import `@pithy-sh/vector`. A count is not enough: a list of the right length
130
+ * with the wrong names passed the predicate this replaced. An undeclared extra does not block it.
131
+ */
132
+ export function metadataIndexesSettled(
133
+ declared: readonly { propertyName: string; indexType: string }[],
134
+ ): (live: readonly { propertyName: string; indexType: string }[]) => boolean {
135
+ return (live) => {
136
+ const types = new Map(live.map((entry) => [entry.propertyName, entry.indexType]));
137
+ return declared.every((entry) => types.get(entry.propertyName) === entry.indexType);
138
+ };
139
+ }
@@ -185,6 +185,18 @@ export const AccountTokenSummary = z
185
185
  .describe(
186
186
  "The token's access policies, when the list response carries them. **A live token's own scope is the record of how it was minted** — it is how a `ci-system` token minted before zone-scoped routes (#651) can be told from one minted after, without reading the token record (which needs a grant these least-privilege tokens deliberately lack). Absent and empty are different facts: a response that says nothing means the scope could not be read, never that the token has none.",
187
187
  ),
188
+ // **Not `nullAsAbsent`, and that is the field.** Everywhere else a `null` means the response did not say;
189
+ // here it is Cloudflare's answer — nothing has ever authenticated with this token. Absent stays "did
190
+ // not say", and a shape this does not model degrades to absent rather than dropping the token, for the
191
+ // reason `policies` gives.
192
+ last_used_on: z
193
+ .string()
194
+ .nullable()
195
+ .optional()
196
+ .catch(undefined)
197
+ .describe(
198
+ "When something last authenticated with the token, as Cloudflare measures it against the live account. Read because `pithy token forget` acts on it (#762): `null` means never, so a local copy may be its only one. Absent means the response did not say, which is not a claim either way.",
199
+ ),
188
200
  })
189
201
  .describe("An existing account-owned API token's metadata (never its secret value).");
190
202
  export type AccountTokenSummary = z.output<typeof AccountTokenSummary>;