@pithy-sh/cloudflare 0.4.2 → 0.5.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 (52) 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/client/manager.d.ts +13 -2
  6. package/dist/client/manager.d.ts.map +1 -1
  7. package/dist/client/manager.js +18 -5
  8. package/dist/d1/d1Manager.d.ts.map +1 -1
  9. package/dist/d1/d1Manager.js +3 -3
  10. package/dist/d1/d1PreparedStatement.d.ts +12 -0
  11. package/dist/d1/d1PreparedStatement.d.ts.map +1 -1
  12. package/dist/d1/d1PreparedStatement.js +13 -3
  13. package/dist/oauth/callback.d.ts +32 -0
  14. package/dist/oauth/callback.d.ts.map +1 -0
  15. package/dist/oauth/callback.js +58 -0
  16. package/dist/oauth/catalog.d.ts +26 -0
  17. package/dist/oauth/catalog.d.ts.map +1 -0
  18. package/dist/oauth/catalog.js +30 -0
  19. package/dist/oauth/client.d.ts +126 -0
  20. package/dist/oauth/client.d.ts.map +1 -0
  21. package/dist/oauth/client.js +200 -0
  22. package/dist/oauth/scopes.d.ts +62 -0
  23. package/dist/oauth/scopes.d.ts.map +1 -0
  24. package/dist/oauth/scopes.js +79 -0
  25. package/dist/secrets/secretsStoreManager.d.ts.map +1 -1
  26. package/dist/secrets/secretsStoreManager.js +3 -2
  27. package/dist/test-utils/settle.d.ts +79 -0
  28. package/dist/test-utils/settle.d.ts.map +1 -0
  29. package/dist/test-utils/settle.js +105 -0
  30. package/dist/tokens/accountTokensManager.d.ts +7 -7
  31. package/dist/tokens/permissions.d.ts +39 -0
  32. package/dist/tokens/permissions.d.ts.map +1 -1
  33. package/dist/tokens/permissions.js +45 -1
  34. package/dist/tokens/profiles.d.ts +4 -0
  35. package/dist/tokens/profiles.d.ts.map +1 -1
  36. package/dist/tokens/profiles.js +4 -0
  37. package/dist/workers/buildsManager.js +1 -1
  38. package/dist/workflows/stepFailure.js +1 -1
  39. package/dist/workflows/workflowsClient.js +1 -1
  40. package/package.json +2 -2
  41. package/src/ai/aiManager.ts +44 -5
  42. package/src/client/manager.ts +24 -6
  43. package/src/d1/d1Manager.ts +7 -3
  44. package/src/d1/d1PreparedStatement.ts +21 -2
  45. package/src/oauth/callback.ts +70 -0
  46. package/src/oauth/catalog.ts +39 -0
  47. package/src/oauth/client.ts +265 -0
  48. package/src/oauth/scopes.ts +91 -0
  49. package/src/secrets/secretsStoreManager.ts +2 -1
  50. package/src/test-utils/settle.ts +139 -0
  51. package/src/tokens/permissions.ts +54 -0
  52. package/src/tokens/profiles.ts +4 -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 every worker-consumer token
14
+ * the project declares, 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
+ }
@@ -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
+ }
@@ -46,12 +46,66 @@ export const PERMISSION_GROUPS = {
46
46
  //
47
47
  // Still not a group that can alter a zone. "Workers Routes Write" writes routes on a zone; changing
48
48
  // the zone itself would be "Zone Write", which this catalog does not have and will not get.
49
+ //
50
+ // Only this group is zone-limited. "Workers Scripts Write" is account-scoped and attaches a custom
51
+ // domain — a proxied DNS record and a certificate — on any zone the account holds (measured, #709;
52
+ // docs/TOKENS.md). So a token carrying the base can reach every zone's hostnames whatever this scopes.
49
53
  "routes:write": ["Workers Routes Write"],
50
54
  } as const;
51
55
 
52
56
  /** A known permission key — one of {@link PERMISSION_GROUPS}'s keys. */
53
57
  export type PermissionKey = keyof typeof PERMISSION_GROUPS;
54
58
 
59
+ /**
60
+ * **The third column: the OAuth scope each key needs a signed-in session to hold (#759).**
61
+ *
62
+ * A session mints only what it could do itself. Cloudflare refuses a mint whose permission groups the
63
+ * session's scopes do not cover — *"The OAuth session does not include the scopes required for the
64
+ * requested permission groups"*, measured 2026-10-15 — so the scopes `pithy cloudflare connect` asks for
65
+ * are derived from the permissions the project's tokens carry, through this table, and never listed by
66
+ * hand. `satisfies` over {@link PermissionKey} makes a key added above without a scope here a type error.
67
+ *
68
+ * **The ids pair with the group names one to one.** `GET /oauth/scopes` names each scope exactly as the
69
+ * permission group it grants — `d1.write` is "D1 Write", `workers-routes.write` is "Workers Routes Write" —
70
+ * which is what lets the integration test check this column against Cloudflare's own catalog the way
71
+ * the group names are checked against the account. A write scope also covers its read: a session holding
72
+ * `d1.write` minted a "D1 Read" token, so a key never needs both.
73
+ *
74
+ * Dot-delimited, always. The colon form (`account:read`) is what a first-party client like wrangler asks
75
+ * for, and a third-party client can neither register one nor request it.
76
+ */
77
+ export const OAUTH_SCOPES = {
78
+ "d1:read": ["d1.read"],
79
+ "d1:write": ["d1.write"],
80
+ "workers:write": ["workers-scripts.write"],
81
+ "secrets:read": ["secrets-store.read"],
82
+ "secrets:write": ["secrets-store.write"],
83
+ "email:routing": ["email-routing-rule.write"],
84
+ "kv:write": ["workers-kv-storage.write"],
85
+ "r2:read": ["workers-r2.read"],
86
+ "r2:write": ["workers-r2.write"],
87
+ "vectorize:read": ["vectorize.read"],
88
+ "vectorize:write": ["vectorize.write"],
89
+ "ai:read": ["ai.read"],
90
+ "zone:read": ["zone.read"],
91
+ "routes:write": ["workers-routes.write"],
92
+ } as const satisfies Record<PermissionKey, readonly string[]>;
93
+
94
+ /**
95
+ * Resolve permission keys to the OAuth scopes a session needs to act — or mint — with them, de-duped in
96
+ * first-seen order. An unknown key is refused exactly as {@link resolvePermissionKeys} refuses it.
97
+ */
98
+ export function oauthScopesFor(keys: readonly string[]): string[] {
99
+ resolvePermissionKeys([...keys]);
100
+ const scopes: string[] = [];
101
+ for (const key of keys) {
102
+ for (const scope of OAUTH_SCOPES[key as PermissionKey]) {
103
+ if (!scopes.includes(scope)) scopes.push(scope);
104
+ }
105
+ }
106
+ return scopes;
107
+ }
108
+
55
109
  /**
56
110
  * The keys whose Cloudflare permission group is **zone-scoped**: a policy carrying one must name zone
57
111
  * resources (`com.cloudflare.api.account.zone.<id>`), never the account resource.
@@ -263,6 +263,10 @@ export function permissionsForKeys(keys: PermissionKey[], accountId: string): To
263
263
  * it is a policy of its own rather than a permission on the profile because its resources are zones,
264
264
  * which a profile cannot name: they are resolved from the domains at mint time.
265
265
  *
266
+ * **This limits the route, not the token.** The account policy beside it carries Workers Scripts Write,
267
+ * which attaches a custom domain on any zone the account holds (measured, #709; `docs/TOKENS.md`). Only
268
+ * the route calls are held to the declared zones.
269
+ *
266
270
  * **Empty for an empty zone list, deliberately.** A project that declares no domain attaches no route,
267
271
  * so it mints exactly the token it minted before this existed. No new permission for a project that
268
272
  * needs none.