@pithy-sh/cloudflare 0.4.1 → 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,200 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { cloudflareRequest, decodeResponse } from "../client/errors.js";
4
+ import { UpstreamError } from "@pithy-sh/core/src/error/pithyError";
5
+ import { z } from "zod";
6
+ import { commandName } from "@pithy-sh/core/src/cli/command";
7
+ import { createHash, randomBytes } from "node:crypto";
8
+ //#region src/oauth/client.ts
9
+ /**
10
+ * **The Pithy OAuth client: one, owned by us, authorized by each adopter against their own account (#759).**
11
+ *
12
+ * Registered 2026-10-15 on the `pithy.` account as `Pithy CLI` — a public client, so no secret ships in
13
+ * this package: `token_endpoint_auth_method: none`, `authorization_code` with PKCE `S256`, and
14
+ * `refresh_token`. Verified by a DNS TXT record on `pithy.sh`, its `client_uri`, and public, which is
15
+ * what lets a user on any account sign in to it. An identifier, not a credential.
16
+ */
17
+ const PITHY_OAUTH_CLIENT_ID = "5ae99cf157ae3e57d354e9c25ad3f7e2";
18
+ /** The endpoints, from `https://dash.cloudflare.com/.well-known/openid-configuration`. */
19
+ const CLOUDFLARE_OAUTH = {
20
+ authorize: "https://dash.cloudflare.com/oauth2/auth",
21
+ token: "https://dash.cloudflare.com/oauth2/token",
22
+ revoke: "https://dash.cloudflare.com/oauth2/revoke"
23
+ };
24
+ /** How long a token-endpoint call may take before it is a timeout rather than a wait. */
25
+ const OAUTH_TIMEOUT_MS = 3e4;
26
+ /**
27
+ * A fresh PKCE pair.
28
+ *
29
+ * **This is what makes a pasted address safe to accept.** The code in the address a browser lands on is
30
+ * redeemable only with the verifier, and the verifier exists only in the CLI that opened the flow — so a
31
+ * code read off a screen, typed across an SSH session, or seen by anyone else is worth nothing elsewhere.
32
+ */
33
+ function pkcePair() {
34
+ const verifier = randomBytes(32).toString("base64url");
35
+ return {
36
+ verifier,
37
+ challenge: createHash("sha256").update(verifier).digest("base64url")
38
+ };
39
+ }
40
+ /** A fresh `state`. Cloudflare refuses one under eight characters; this is twenty-two. */
41
+ function oauthState() {
42
+ return randomBytes(16).toString("base64url");
43
+ }
44
+ /** The URL a browser opens to sign in and consent. */
45
+ function authorizeUrl(request) {
46
+ const url = new URL(CLOUDFLARE_OAUTH.authorize);
47
+ url.search = new URLSearchParams({
48
+ response_type: "code",
49
+ client_id: PITHY_OAUTH_CLIENT_ID,
50
+ redirect_uri: request.redirectUri,
51
+ scope: request.scopes.join(" "),
52
+ state: request.state,
53
+ code_challenge: request.challenge,
54
+ code_challenge_method: "S256"
55
+ }).toString();
56
+ return url.toString();
57
+ }
58
+ /** The token endpoint's answer to a code exchange or a refresh. */
59
+ const OAuthTokenResponse = z.object({
60
+ access_token: z.string().min(1).describe("The bearer token API calls carry. One hour, measured."),
61
+ refresh_token: z.string().min(1).optional().describe("Present only when `offline_access` was granted. Rotates on every use."),
62
+ token_type: z.string().describe("Always `bearer`."),
63
+ expires_in: z.number().int().positive().describe("Seconds until the access token expires."),
64
+ scope: z.string().describe("The scopes granted, space-separated. Compare it with what was requested.")
65
+ }).describe("Cloudflare's token endpoint response, `POST /oauth2/token`.");
66
+ /**
67
+ * The token endpoint's refusal body, per RFC 6749 §5.2.
68
+ *
69
+ * **Not the REST API's `errors[]`, so not `cloudflareRefusal`'s to word.** That composer reads the
70
+ * envelope `api.cloudflare.com` answers with and writes a permission-grant action for it; the OAuth
71
+ * endpoints on `dash.cloudflare.com` answer `{ error, error_description }`, and the remedy is a sign-in,
72
+ * not a grant. They are a service we do not control, so a refusal is `core/upstream_failed`.
73
+ */
74
+ const OAuthErrorResponse = z.object({
75
+ error: z.string(),
76
+ error_description: z.string().optional()
77
+ });
78
+ /** One form POST to an OAuth endpoint: the status, and the body parsed as JSON where it is JSON. */
79
+ async function post(operation, url, form, options) {
80
+ const send = options.fetch ?? globalThis.fetch;
81
+ return cloudflareRequest(operation, async () => {
82
+ const response = await send(url, {
83
+ method: "POST",
84
+ headers: {
85
+ "content-type": "application/x-www-form-urlencoded",
86
+ accept: "application/json"
87
+ },
88
+ body: new URLSearchParams(form),
89
+ signal: AbortSignal.timeout(OAUTH_TIMEOUT_MS)
90
+ });
91
+ const text = await response.text();
92
+ let body;
93
+ try {
94
+ body = text === "" ? {} : JSON.parse(text);
95
+ } catch {
96
+ body = {
97
+ error: "invalid_response",
98
+ error_description: text.slice(0, 200)
99
+ };
100
+ }
101
+ return {
102
+ status: response.status,
103
+ body
104
+ };
105
+ });
106
+ }
107
+ /** The refusal's own words, or `null` when the body is not an RFC 6749 error. */
108
+ function refusalOf(body) {
109
+ const parsed = OAuthErrorResponse.safeParse(body);
110
+ if (!parsed.success) return null;
111
+ return {
112
+ error: parsed.data.error,
113
+ description: parsed.data.error_description ?? parsed.data.error
114
+ };
115
+ }
116
+ function grantOf(body, context, now, keepRefresh) {
117
+ const token = decodeResponse(OAuthTokenResponse, body, context);
118
+ return {
119
+ accessToken: token.access_token,
120
+ refreshToken: token.refresh_token ?? keepRefresh,
121
+ expiresAt: new Date(now.getTime() + token.expires_in * 1e3),
122
+ scope: token.scope
123
+ };
124
+ }
125
+ /**
126
+ * Redeem an authorization code for a session.
127
+ *
128
+ * A refusal is a `core/upstream_failed` carrying Cloudflare's own description — an expired code, a
129
+ * `redirect_uri` that does not match, a verifier that does not fit the challenge. Each is a sign-in that
130
+ * has to start over, and the description is what says which.
131
+ */
132
+ async function exchangeCode(request) {
133
+ const now = request.now ?? (() => /* @__PURE__ */ new Date());
134
+ const answer = await post("exchange the sign-in code", CLOUDFLARE_OAUTH.token, {
135
+ grant_type: "authorization_code",
136
+ code: request.code,
137
+ redirect_uri: request.redirectUri,
138
+ client_id: PITHY_OAUTH_CLIENT_ID,
139
+ code_verifier: request.verifier
140
+ }, request);
141
+ if (answer.status !== 200) {
142
+ const refusal = refusalOf(answer.body);
143
+ throw new UpstreamError({
144
+ message: `Cloudflare refused the sign-in: ${refusal?.description ?? `HTTP ${answer.status}`}`,
145
+ action: `Run ${commandName("cloudflare connect")} again. A sign-in code works once, and only for a few minutes.`,
146
+ detail: `token endpoint ${answer.status}: ${refusal?.error ?? "no error body"}`
147
+ });
148
+ }
149
+ return grantOf(answer.body, "token exchange", now(), null);
150
+ }
151
+ /**
152
+ * Renew a session without a browser.
153
+ *
154
+ * **A refusal is an answer, not a throw.** A spent, revoked or expired refresh token means *sign in
155
+ * again*, and the caller decides how to say so — at dispatch it is deferred to the first command that
156
+ * actually needs Cloudflare. Only a transport failure throws, because that one is worth retrying.
157
+ *
158
+ * The refresh token **rotates** on every use (measured). The one handed back must replace the one sent,
159
+ * on disk, before anything else reads it; the caller holds the profile's lock across that write.
160
+ */
161
+ async function refreshGrant(request) {
162
+ const now = request.now ?? (() => /* @__PURE__ */ new Date());
163
+ const answer = await post("renew the Cloudflare session", CLOUDFLARE_OAUTH.token, {
164
+ grant_type: "refresh_token",
165
+ refresh_token: request.refreshToken,
166
+ client_id: PITHY_OAUTH_CLIENT_ID
167
+ }, request);
168
+ if (answer.status !== 200) return {
169
+ kind: "refused",
170
+ ...refusalOf(answer.body) ?? {
171
+ error: `http_${answer.status}`,
172
+ description: `HTTP ${answer.status}`
173
+ }
174
+ };
175
+ return {
176
+ kind: "refreshed",
177
+ grant: grantOf(answer.body, "token refresh", now(), request.refreshToken)
178
+ };
179
+ }
180
+ /**
181
+ * Revoke one token at Cloudflare. Throws on a refusal, so `disconnect` never reports a revocation that did
182
+ * not happen — the local file is deleted either way, and the operator is told which half held.
183
+ */
184
+ async function revokeToken(request) {
185
+ const answer = await post("revoke the Cloudflare session", CLOUDFLARE_OAUTH.revoke, {
186
+ token: request.token,
187
+ token_type_hint: request.hint,
188
+ client_id: PITHY_OAUTH_CLIENT_ID
189
+ }, request);
190
+ if (answer.status !== 200) {
191
+ const refusal = refusalOf(answer.body);
192
+ throw new UpstreamError({
193
+ message: `Cloudflare did not revoke the session: ${refusal?.description ?? `HTTP ${answer.status}`}`,
194
+ action: "Sign in to the Cloudflare dashboard and revoke the Pithy CLI's access to your account there.",
195
+ detail: `revoke endpoint ${answer.status}: ${refusal?.error ?? "no error body"}`
196
+ });
197
+ }
198
+ }
199
+ //#endregion
200
+ export { CLOUDFLARE_OAUTH, OAuthTokenResponse, PITHY_OAUTH_CLIENT_ID, authorizeUrl, exchangeCode, oauthState, pkcePair, refreshGrant, revokeToken };
@@ -0,0 +1,62 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * The scopes every session asks for, whatever the project composes (#759).
5
+ *
6
+ * - `user-details.read` — `GET /user`, which is what turns an audit row's actor from a token's name into
7
+ * the person who signed in.
8
+ * - `offline_access` — the only way Cloudflare issues a refresh token. Without it a session dies at the
9
+ * hour and every command after it opens a browser (measured 2026-10-15).
10
+ * - `account-api-tokens.write` — the mint. A session mints `ci-system` and every worker-consumer token
11
+ * the project declares, so the hand-made bootstrap token stops being a setup step.
12
+ * - `account-settings.read` — `GET /accounts`, so the account is discovered rather than stored. On its own
13
+ * it lists the account; `d1.write` alone listed nothing (both measured 2026-10-15), so discovery must not
14
+ * lean on whichever other scope a project happens to request.
15
+ * - `zone.read` — `pithy init` and `pithy worker add` offer the account's real zones, and `init` asks
16
+ * before there is a project whose capabilities could say so. Read-only, as {@link OAUTH_SCOPES} says of it.
17
+ *
18
+ * Everything else comes from the project's own token permissions, through {@link sessionScopes}.
19
+ */
20
+ export declare const SESSION_BASE_SCOPES: readonly ["user-details.read", "offline_access", "account-api-tokens.write", "account-settings.read", "zone.read"];
21
+ /**
22
+ * The scopes Cloudflare's consent screen lists under **"Sensitive scopes"**, switched off, by the name it
23
+ * shows them under.
24
+ *
25
+ * **Approving with one switched off does not grant the rest.** The dashboard answers *"We could not
26
+ * complete the authorization. Please check your network connection and try again."* and no callback
27
+ * ever arrives (measured 2026-10-15). So `connect` names these before it opens the browser, and its
28
+ * timeout names them as the likeliest cause.
29
+ *
30
+ * **The list is ours to keep, because Cloudflare's is not published.** `GET /oauth/scopes` carries an
31
+ * id, a name, a category and nothing that marks a scope sensitive; only the consent screen says. Add to
32
+ * it when a consent screen shows another.
33
+ */
34
+ export declare const SENSITIVE_SCOPES: Readonly<Record<string, string>>;
35
+ /**
36
+ * The scopes a session needs to act for a project, and to mint every token it declares: the base, then
37
+ * the scope of each permission key, de-duped in first-seen order.
38
+ *
39
+ * **Derived, never listed.** Cloudflare's delegation rule binds the session as it binds a token — a mint
40
+ * whose groups the session does not hold is refused — so the request is exactly the union of what the
41
+ * project's tokens carry. A capability the project does not compose contributes nothing to it, and so
42
+ * nothing to the consent screen the adopter reads.
43
+ */
44
+ export declare function sessionScopes(permissionKeys: readonly string[]): string[];
45
+ /**
46
+ * The requested scopes a grant does not carry, in request order.
47
+ *
48
+ * `granted` is the token endpoint's `scope`: one space-separated string. Today Cloudflare fails rather
49
+ * than grant a subset, so this should always be empty; it is checked because that is Cloudflare's
50
+ * behavior to change, and a session missing `account-api-tokens.write` would fail at the first mint.
51
+ */
52
+ export declare function missingScopes(requested: readonly string[], granted: string): string[];
53
+ /** One sensitive scope a request carries, by its id and the name the consent screen shows. */
54
+ export interface SensitiveScope {
55
+ /** The catalog id: `account-api-tokens.write`. */
56
+ id: string;
57
+ /** What the consent screen calls it: "Account API Tokens Write". */
58
+ name: string;
59
+ }
60
+ /** The sensitive scopes in a request, in request order. See {@link SENSITIVE_SCOPES}. */
61
+ export declare function sensitiveScopesIn(scopes: readonly string[]): SensitiveScope[];
62
+ //# sourceMappingURL=scopes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scopes.d.ts","sourceRoot":"","sources":["../../src/oauth/scopes.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,mBAAmB,YAC9B,mBAAmB,EACnB,gBAAgB,EAChB,0BAA0B,EAC1B,uBAAuB,EACvB,WAAW,CACH,CAAC;AAEX;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gBAAgB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAE7D,CAAC;AAEF;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAMzE;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAGrF;AAED,8FAA8F;AAC9F,MAAM,WAAW,cAAc;IAC7B,kDAAkD;IAClD,EAAE,EAAE,MAAM,CAAC;IACX,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAC;CACd;AAED,yFAAyF;AACzF,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,cAAc,EAAE,CAK7E"}
@@ -0,0 +1,79 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { oauthScopesFor } from "../tokens/permissions.js";
4
+ //#region src/oauth/scopes.ts
5
+ /**
6
+ * The scopes every session asks for, whatever the project composes (#759).
7
+ *
8
+ * - `user-details.read` — `GET /user`, which is what turns an audit row's actor from a token's name into
9
+ * the person who signed in.
10
+ * - `offline_access` — the only way Cloudflare issues a refresh token. Without it a session dies at the
11
+ * hour and every command after it opens a browser (measured 2026-10-15).
12
+ * - `account-api-tokens.write` — the mint. A session mints `ci-system` and every worker-consumer token
13
+ * the project declares, so the hand-made bootstrap token stops being a setup step.
14
+ * - `account-settings.read` — `GET /accounts`, so the account is discovered rather than stored. On its own
15
+ * it lists the account; `d1.write` alone listed nothing (both measured 2026-10-15), so discovery must not
16
+ * lean on whichever other scope a project happens to request.
17
+ * - `zone.read` — `pithy init` and `pithy worker add` offer the account's real zones, and `init` asks
18
+ * before there is a project whose capabilities could say so. Read-only, as {@link OAUTH_SCOPES} says of it.
19
+ *
20
+ * Everything else comes from the project's own token permissions, through {@link sessionScopes}.
21
+ */
22
+ const SESSION_BASE_SCOPES = [
23
+ "user-details.read",
24
+ "offline_access",
25
+ "account-api-tokens.write",
26
+ "account-settings.read",
27
+ "zone.read"
28
+ ];
29
+ /**
30
+ * The scopes Cloudflare's consent screen lists under **"Sensitive scopes"**, switched off, by the name it
31
+ * shows them under.
32
+ *
33
+ * **Approving with one switched off does not grant the rest.** The dashboard answers *"We could not
34
+ * complete the authorization. Please check your network connection and try again."* and no callback
35
+ * ever arrives (measured 2026-10-15). So `connect` names these before it opens the browser, and its
36
+ * timeout names them as the likeliest cause.
37
+ *
38
+ * **The list is ours to keep, because Cloudflare's is not published.** `GET /oauth/scopes` carries an
39
+ * id, a name, a category and nothing that marks a scope sensitive; only the consent screen says. Add to
40
+ * it when a consent screen shows another.
41
+ */
42
+ const SENSITIVE_SCOPES = { "account-api-tokens.write": "Account API Tokens Write" };
43
+ /**
44
+ * The scopes a session needs to act for a project, and to mint every token it declares: the base, then
45
+ * the scope of each permission key, de-duped in first-seen order.
46
+ *
47
+ * **Derived, never listed.** Cloudflare's delegation rule binds the session as it binds a token — a mint
48
+ * whose groups the session does not hold is refused — so the request is exactly the union of what the
49
+ * project's tokens carry. A capability the project does not compose contributes nothing to it, and so
50
+ * nothing to the consent screen the adopter reads.
51
+ */
52
+ function sessionScopes(permissionKeys) {
53
+ const scopes = [...SESSION_BASE_SCOPES];
54
+ for (const scope of oauthScopesFor(permissionKeys)) if (!scopes.includes(scope)) scopes.push(scope);
55
+ return scopes;
56
+ }
57
+ /**
58
+ * The requested scopes a grant does not carry, in request order.
59
+ *
60
+ * `granted` is the token endpoint's `scope`: one space-separated string. Today Cloudflare fails rather
61
+ * than grant a subset, so this should always be empty; it is checked because that is Cloudflare's
62
+ * behavior to change, and a session missing `account-api-tokens.write` would fail at the first mint.
63
+ */
64
+ function missingScopes(requested, granted) {
65
+ const held = new Set(granted.split(/\s+/).filter(Boolean));
66
+ return requested.filter((scope) => !held.has(scope));
67
+ }
68
+ /** The sensitive scopes in a request, in request order. See {@link SENSITIVE_SCOPES}. */
69
+ function sensitiveScopesIn(scopes) {
70
+ return scopes.flatMap((id) => {
71
+ const name = SENSITIVE_SCOPES[id];
72
+ return name === void 0 ? [] : [{
73
+ id,
74
+ name
75
+ }];
76
+ });
77
+ }
78
+ //#endregion
79
+ export { SENSITIVE_SCOPES, SESSION_BASE_SCOPES, missingScopes, sensitiveScopesIn, sessionScopes };
@@ -1 +1 @@
1
- {"version":3,"file":"secretsStoreManager.d.ts","sourceRoot":"","sources":["../../src/secrets/secretsStoreManager.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,KAAK,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAKpF;;;;GAIG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;;iBAeqE,CAAC;AAChG,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,aAAa,CAAC,CAAC;AAE3D,mGAAmG;AACnG,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,SAAS,GAAG,aAAa,CAAC;AA4BxE,gGAAgG;AAChG,MAAM,WAAW,yBAA0B,SAAQ,uBAAuB;IACxE,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,qBAAa,6BAA8B,SAAQ,iBAAiB;IAClE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IAEjC,YAAY,MAAM,EAAE,yBAAyB,EAM5C;IAED,cAAc,IAAI,MAAM,CAEvB;IAED,iEAAiE;IAC3D,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED;;;;;;;;;;;;;;;;;OAiBG;IACG,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAgB5E;IAED;;;;;;;;;;;;;;;OAeG;IACG,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAmBtG;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACG,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA2BzF;IAED,4FAA4F;IACtF,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAc9C;IAED;;;;;;;;;;;;;OAaG;IACG,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAU1D;IAED;;;OAGG;IACG,WAAW,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC,CAU5C;IAED;;;;;;;;;;;;OAYG;IACG,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAEzD;IAED,0EAA0E;IACpE,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE3C;IAED,kEAAkE;IAClE,mBAAmB,IAAI;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAE5D;IAED,6GAA6G;YAC/F,YAAY;IAiB1B;;;;OAIG;YACW,UAAU;CAGzB"}
1
+ {"version":3,"file":"secretsStoreManager.d.ts","sourceRoot":"","sources":["../../src/secrets/secretsStoreManager.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,KAAK,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAKpF;;;;GAIG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;;iBAeqE,CAAC;AAChG,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,aAAa,CAAC,CAAC;AAE3D,mGAAmG;AACnG,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,SAAS,GAAG,aAAa,CAAC;AA4BxE,gGAAgG;AAChG,MAAM,WAAW,yBAA0B,SAAQ,uBAAuB;IACxE,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,qBAAa,6BAA8B,SAAQ,iBAAiB;IAClE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IAEjC,YAAY,MAAM,EAAE,yBAAyB,EAM5C;IAED,cAAc,IAAI,MAAM,CAEvB;IAED,iEAAiE;IAC3D,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED;;;;;;;;;;;;;;;;;OAiBG;IACG,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAgB5E;IAED;;;;;;;;;;;;;;;OAeG;IACG,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAmBtG;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACG,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA2BzF;IAED,4FAA4F;IACtF,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAc9C;IAED;;;;;;;;;;;;;OAaG;IACG,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAU1D;IAED;;;OAGG;IACG,WAAW,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC,CAU5C;IAED;;;;;;;;;;;;OAYG;IACG,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAEzD;IAED,0EAA0E;IACpE,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE3C;IAED,kEAAkE;IAClE,mBAAmB,IAAI;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAE5D;IAED,6GAA6G;YAC/F,YAAY;IAiB1B;;;;OAIG;YACW,UAAU;CAGzB"}
@@ -2,8 +2,9 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { CloudflareNotConfiguredError, cloudflareRequest, decodeResponse } from "../client/errors.js";
4
4
  import { CloudflareManager } from "../client/manager.js";
5
- import { z } from "zod";
6
5
  import { ConflictError, NotFoundError } from "@pithy-sh/core/src/error/pithyError";
6
+ import { z } from "zod";
7
+ import { remedy } from "@pithy-sh/core/src/cli/command";
7
8
  import { JsonDate } from "@pithy-sh/core/src/data/codecs";
8
9
  //#region src/secrets/secretsStoreManager.ts
9
10
  /** Scopes attached to every secret we create. CF requires at least one; "workers" is the bind target. */
@@ -172,7 +173,7 @@ var CloudflareSecretsStoreManager = class extends CloudflareManager {
172
173
  const target = named[0];
173
174
  if (!target) throw new NotFoundError({
174
175
  message: `Secret '${name}' is not in the store, so there is nothing to update.`,
175
- action: "Run `pithy secrets provision` to establish the entry, then run this again.",
176
+ action: `${remedy("secrets provision").say((command) => `Run \`${command}\`.`)} It establishes the entry; then run this again.`,
176
177
  detail: `update secret ${name}: no live entry of that name, and this verb never creates one`
177
178
  });
178
179
  if (named.length > 1) throw new ConflictError({
@@ -0,0 +1,79 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * **Waiting on an eventually consistent control plane, for the live suites.** One budget and one rule, shared
5
+ * by `@pithy-sh/cloudflare`'s Vectorize suite and `@pithy-sh/vector`'s, because the cloudflare suite kept a
6
+ * 60s budget and a single confirming read of its own and failed on exactly that (#709).
7
+ *
8
+ * **The budget is 120s, and 300s for a metadata index.** A Vectorize index became visible 25-50s after the
9
+ * call that made it, measured live, and an upsert became queryable in about 30s. A metadata index is slower:
10
+ * about 97s on one run of #709 and over 120s on the next, so its waits take {@link METADATA_INDEX_SETTLE_MS}.
11
+ *
12
+ * **A metadata-index wait compares names and types, never a count.** {@link metadataIndexesSettled} is the
13
+ * predicate; a list of the right length can still be the wrong list.
14
+ *
15
+ * **One confirming read proves nothing.** The metadata-index list is eventually consistent *and not
16
+ * monotonic*: a 3-minute probe watched it report `[]` for 26s, then both properties, then one of the two 18s
17
+ * later with nothing else running, then both again. So {@link waitForStable} waits for {@link STABLE_READS}
18
+ * agreeing reads in a row and hands back that snapshot, and a caller asserts against the snapshot rather
19
+ * than a fresh read, which can disagree with the one before it.
20
+ *
21
+ * **A test that waits several times shares one deadline across them.** Each wait's own budget bounds that
22
+ * wait and nothing else, so a test making eight of them in a row could outrun its vitest timeout with every
23
+ * step inside budget. Vitest then abandoned the body mid-poll: the failure said only "Test timed out", and the
24
+ * teardown that deletes the index never ran (#709). Pass the same {@link SettleOptions.deadline} to every
25
+ * wait and the step in progress when it passes fails by name, inside the body, where the teardown still runs.
26
+ */
27
+ /** Ceiling for any one wait. Measured at 25-50s for a control-plane change, and about 30s for an upsert. */
28
+ export declare const SETTLE_TIMEOUT_MS = 120000;
29
+ /**
30
+ * Ceiling for a metadata-index wait, and only for that. A metadata index is the slowest change Vectorize
31
+ * applies: on #709 one settled in about 97s and the next was still unsettled at 120s, so the shared budget
32
+ * undercut exactly this wait. `pithy vector provision` waits on the same measurement.
33
+ */
34
+ export declare const METADATA_INDEX_SETTLE_MS = 300000;
35
+ /**
36
+ * Consecutive agreeing reads that count as settled. Three, because one confirming read can be a stale replica,
37
+ * and two in a row six seconds apart is a far weaker coincidence.
38
+ */
39
+ export declare const STABLE_READS = 3;
40
+ /** How often a poll re-checks. Vectorize applies changes in tens of seconds, so a tighter loop only burns quota. */
41
+ export declare const POLL_INTERVAL_MS = 2000;
42
+ /** How long to wait and how often to look. Every field defaults to the shared budget above. */
43
+ export interface SettleOptions {
44
+ /** Give up after this long. Defaults to {@link SETTLE_TIMEOUT_MS}. */
45
+ timeoutMs?: number;
46
+ /** Re-check this often. Defaults to {@link POLL_INTERVAL_MS}. */
47
+ intervalMs?: number;
48
+ /** Agreeing reads in a row that count as settled, for {@link waitForStable}. Defaults to {@link STABLE_READS}. */
49
+ stableReads?: number;
50
+ /**
51
+ * An absolute deadline (ms since the epoch) shared by every wait in a test. A wait gives up at whichever
52
+ * comes first, its own `timeoutMs` or this, and says which.
53
+ */
54
+ deadline?: number;
55
+ }
56
+ /**
57
+ * Poll until `ready`, or fail with a message naming what was being waited for. A bare timeout tells the next
58
+ * reader nothing; naming the step tells them whether Cloudflare was slow or something is actually broken.
59
+ */
60
+ export declare function waitFor(what: string, ready: () => Promise<boolean>, options?: SettleOptions): Promise<void>;
61
+ /**
62
+ * Poll until `settled` holds on {@link STABLE_READS} consecutive reads, and return the last value read. The
63
+ * caller asserts against the returned snapshot, never against a fresh read.
64
+ */
65
+ export declare function waitForStable<T>(what: string, read: () => Promise<T>, settled: (value: T) => boolean, options?: SettleOptions): Promise<T>;
66
+ /**
67
+ * The settle predicate for a metadata-index list: every declared index is live, under its own name and its own
68
+ * type. The rule `compareMetadataIndexes` applies with nothing missing and nothing mismatched, restated here
69
+ * because this package cannot import `@pithy-sh/vector`. A count is not enough: a list of the right length
70
+ * with the wrong names passed the predicate this replaced. An undeclared extra does not block it.
71
+ */
72
+ export declare function metadataIndexesSettled(declared: readonly {
73
+ propertyName: string;
74
+ indexType: string;
75
+ }[]): (live: readonly {
76
+ propertyName: string;
77
+ indexType: string;
78
+ }[]) => boolean;
79
+ //# sourceMappingURL=settle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"settle.d.ts","sourceRoot":"","sources":["../../src/test-utils/settle.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,4GAA4G;AAC5G,eAAO,MAAM,iBAAiB,SAAU,CAAC;AAEzC;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,SAAU,CAAC;AAEhD;;;GAGG;AACH,eAAO,MAAM,YAAY,IAAI,CAAC;AAE9B,oHAAoH;AACpH,eAAO,MAAM,gBAAgB,OAAQ,CAAC;AAEtC,+FAA+F;AAC/F,MAAM,WAAW,aAAa;IAC5B,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iEAAiE;IACjE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kHAAkH;IAClH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAmBD;;;GAGG;AACH,wBAAsB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,EAAE,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAYrH;AAED;;;GAGG;AACH,wBAAsB,aAAa,CAAC,CAAC,EACnC,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACtB,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,OAAO,EAC9B,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,CAAC,CAAC,CAiBZ;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,SAAS;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,EAAE,GAC/D,CAAC,IAAI,EAAE,SAAS;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,EAAE,KAAK,OAAO,CAK3E"}
@@ -0,0 +1,105 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/test-utils/settle.ts
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
+ /** Ceiling for any one wait. Measured at 25-50s for a control-plane change, and about 30s for an upsert. */
29
+ const SETTLE_TIMEOUT_MS = 12e4;
30
+ /**
31
+ * Ceiling for a metadata-index wait, and only for that. A metadata index is the slowest change Vectorize
32
+ * applies: on #709 one settled in about 97s and the next was still unsettled at 120s, so the shared budget
33
+ * undercut exactly this wait. `pithy vector provision` waits on the same measurement.
34
+ */
35
+ const METADATA_INDEX_SETTLE_MS = 3e5;
36
+ /**
37
+ * Consecutive agreeing reads that count as settled. Three, because one confirming read can be a stale replica,
38
+ * and two in a row six seconds apart is a far weaker coincidence.
39
+ */
40
+ const STABLE_READS = 3;
41
+ /** How often a poll re-checks. Vectorize applies changes in tens of seconds, so a tighter loop only burns quota. */
42
+ const POLL_INTERVAL_MS = 2e3;
43
+ /** When a wait gives up, and the clause its timeout message ends the step name with. */
44
+ function limitOf(options, started) {
45
+ const own = started + (options.timeoutMs ?? 12e4);
46
+ if (options.deadline !== void 0 && options.deadline < own) return {
47
+ deadline: options.deadline,
48
+ why: " (the shared deadline for this test ran out)"
49
+ };
50
+ return {
51
+ deadline: own,
52
+ why: ""
53
+ };
54
+ }
55
+ function seconds(ms) {
56
+ return Math.round(ms / 1e3);
57
+ }
58
+ function pause(ms) {
59
+ return new Promise((resolve) => setTimeout(resolve, ms));
60
+ }
61
+ /**
62
+ * Poll until `ready`, or fail with a message naming what was being waited for. A bare timeout tells the next
63
+ * reader nothing; naming the step tells them whether Cloudflare was slow or something is actually broken.
64
+ */
65
+ async function waitFor(what, ready, options = {}) {
66
+ const started = Date.now();
67
+ const { deadline, why } = limitOf(options, started);
68
+ for (;;) {
69
+ if (await ready()) return;
70
+ if (Date.now() >= deadline) throw new Error(`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.`);
71
+ await pause(options.intervalMs ?? 2e3);
72
+ }
73
+ }
74
+ /**
75
+ * Poll until `settled` holds on {@link STABLE_READS} consecutive reads, and return the last value read. The
76
+ * caller asserts against the returned snapshot, never against a fresh read.
77
+ */
78
+ async function waitForStable(what, read, settled, options = {}) {
79
+ const started = Date.now();
80
+ const needed = options.stableReads ?? 3;
81
+ const { deadline, why } = limitOf(options, started);
82
+ let agreed = 0;
83
+ let last = await read();
84
+ for (;;) {
85
+ agreed = settled(last) ? agreed + 1 : 0;
86
+ if (agreed >= needed) return last;
87
+ if (Date.now() >= deadline) throw new Error(`Timed out after ${seconds(Date.now() - started)}s waiting for ${what} to settle across ${needed} reads${why}. Last read: ${JSON.stringify(last)}.`);
88
+ await pause(options.intervalMs ?? 2e3);
89
+ last = await read();
90
+ }
91
+ }
92
+ /**
93
+ * The settle predicate for a metadata-index list: every declared index is live, under its own name and its own
94
+ * type. The rule `compareMetadataIndexes` applies with nothing missing and nothing mismatched, restated here
95
+ * because this package cannot import `@pithy-sh/vector`. A count is not enough: a list of the right length
96
+ * with the wrong names passed the predicate this replaced. An undeclared extra does not block it.
97
+ */
98
+ function metadataIndexesSettled(declared) {
99
+ return (live) => {
100
+ const types = new Map(live.map((entry) => [entry.propertyName, entry.indexType]));
101
+ return declared.every((entry) => types.get(entry.propertyName) === entry.indexType);
102
+ };
103
+ }
104
+ //#endregion
105
+ export { METADATA_INDEX_SETTLE_MS, POLL_INTERVAL_MS, SETTLE_TIMEOUT_MS, STABLE_READS, metadataIndexesSettled, waitFor, waitForStable };
@@ -57,7 +57,7 @@ export declare const MintedAccountToken: z.ZodObject<{
57
57
  active: "active";
58
58
  disabled: "disabled";
59
59
  expired: "expired";
60
- }>>>;
60
+ }>>, unknown>;
61
61
  }, z.core.$strip>;
62
62
  export type MintedAccountToken = z.output<typeof MintedAccountToken>;
63
63
  /**
@@ -72,10 +72,10 @@ export declare const AccountTokenPolicy: z.ZodObject<{
72
72
  effect: z.ZodPreprocess<z.ZodOptional<z.ZodEnum<{
73
73
  allow: "allow";
74
74
  deny: "deny";
75
- }>>>;
75
+ }>>, unknown>;
76
76
  permission_groups: z.ZodPreprocess<z.ZodOptional<z.ZodArray<z.ZodObject<{
77
77
  id: z.ZodString;
78
- }, z.core.$strip>>>>;
78
+ }, z.core.$strip>>>, unknown>;
79
79
  resources: z.ZodRecord<z.ZodString, z.ZodUnknown>;
80
80
  }, z.core.$strip>;
81
81
  export type AccountTokenPolicy = z.output<typeof AccountTokenPolicy>;
@@ -96,17 +96,17 @@ export declare const AccountTokenSummary: z.ZodObject<{
96
96
  active: "active";
97
97
  disabled: "disabled";
98
98
  expired: "expired";
99
- }>>>;
99
+ }>>, unknown>;
100
100
  policies: z.ZodCatch<z.ZodPreprocess<z.ZodOptional<z.ZodArray<z.ZodObject<{
101
101
  effect: z.ZodPreprocess<z.ZodOptional<z.ZodEnum<{
102
102
  allow: "allow";
103
103
  deny: "deny";
104
- }>>>;
104
+ }>>, unknown>;
105
105
  permission_groups: z.ZodPreprocess<z.ZodOptional<z.ZodArray<z.ZodObject<{
106
106
  id: z.ZodString;
107
- }, z.core.$strip>>>>;
107
+ }, z.core.$strip>>>, unknown>;
108
108
  resources: z.ZodRecord<z.ZodString, z.ZodUnknown>;
109
- }, z.core.$strip>>>>>;
109
+ }, z.core.$strip>>>, unknown>>;
110
110
  }, z.core.$strip>;
111
111
  export type AccountTokenSummary = z.output<typeof AccountTokenSummary>;
112
112
  /**
@@ -28,6 +28,45 @@ export declare const PERMISSION_GROUPS: {
28
28
  };
29
29
  /** A known permission key — one of {@link PERMISSION_GROUPS}'s keys. */
30
30
  export type PermissionKey = keyof typeof PERMISSION_GROUPS;
31
+ /**
32
+ * **The third column: the OAuth scope each key needs a signed-in session to hold (#759).**
33
+ *
34
+ * A session mints only what it could do itself. Cloudflare refuses a mint whose permission groups the
35
+ * session's scopes do not cover — *"The OAuth session does not include the scopes required for the
36
+ * requested permission groups"*, measured 2026-10-15 — so the scopes `pithy cloudflare connect` asks for
37
+ * are derived from the permissions the project's tokens carry, through this table, and never listed by
38
+ * hand. `satisfies` over {@link PermissionKey} makes a key added above without a scope here a type error.
39
+ *
40
+ * **The ids pair with the group names one to one.** `GET /oauth/scopes` names each scope exactly as the
41
+ * permission group it grants — `d1.write` is "D1 Write", `workers-routes.write` is "Workers Routes Write" —
42
+ * which is what lets the integration test check this column against Cloudflare's own catalog the way
43
+ * the group names are checked against the account. A write scope also covers its read: a session holding
44
+ * `d1.write` minted a "D1 Read" token, so a key never needs both.
45
+ *
46
+ * Dot-delimited, always. The colon form (`account:read`) is what a first-party client like wrangler asks
47
+ * for, and a third-party client can neither register one nor request it.
48
+ */
49
+ export declare const OAUTH_SCOPES: {
50
+ readonly "d1:read": readonly ["d1.read"];
51
+ readonly "d1:write": readonly ["d1.write"];
52
+ readonly "workers:write": readonly ["workers-scripts.write"];
53
+ readonly "secrets:read": readonly ["secrets-store.read"];
54
+ readonly "secrets:write": readonly ["secrets-store.write"];
55
+ readonly "email:routing": readonly ["email-routing-rule.write"];
56
+ readonly "kv:write": readonly ["workers-kv-storage.write"];
57
+ readonly "r2:read": readonly ["workers-r2.read"];
58
+ readonly "r2:write": readonly ["workers-r2.write"];
59
+ readonly "vectorize:read": readonly ["vectorize.read"];
60
+ readonly "vectorize:write": readonly ["vectorize.write"];
61
+ readonly "ai:read": readonly ["ai.read"];
62
+ readonly "zone:read": readonly ["zone.read"];
63
+ readonly "routes:write": readonly ["workers-routes.write"];
64
+ };
65
+ /**
66
+ * Resolve permission keys to the OAuth scopes a session needs to act — or mint — with them, de-duped in
67
+ * first-seen order. An unknown key is refused exactly as {@link resolvePermissionKeys} refuses it.
68
+ */
69
+ export declare function oauthScopesFor(keys: readonly string[]): string[];
31
70
  /**
32
71
  * The keys whose Cloudflare permission group is **zone-scoped**: a policy carrying one must name zone
33
72
  * resources (`com.cloudflare.api.account.zone.<id>`), never the account resource.