@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
@@ -1 +1 @@
1
- {"version":3,"file":"permissions.d.ts","sourceRoot":"","sources":["../../src/tokens/permissions.ts"],"names":[],"mappings":"AAKA;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB;aAC5B,SAAS,YAAG,SAAS;aAIrB,UAAU,YAAG,UAAU;aACvB,eAAe,YAAG,uBAAuB;aACzC,cAAc,YAAG,oBAAoB;aACrC,eAAe,YAAG,qBAAqB;aACvC,eAAe,YAAG,2BAA2B;aAC7C,UAAU,YAAG,0BAA0B;aACvC,SAAS,YAAG,yBAAyB;aACrC,UAAU,YAAG,0BAA0B;aACvC,gBAAgB,YAAG,gBAAgB;aACnC,iBAAiB,YAAG,iBAAiB;aACrC,SAAS,YAAG,iBAAiB;aAK7B,WAAW,YAAG,WAAW;aAazB,cAAc,YAAG,sBAAsB;CAC/B,CAAC;AAEX,wEAAwE;AACxE,MAAM,MAAM,aAAa,GAAG,MAAM,OAAO,iBAAiB,CAAC;AAE3D;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,EAAE,SAAS,aAAa,EAAqB,CAAC;AAElF,6DAA6D;AAC7D,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,IAAI,aAAa,CAEjE;AAED,6FAA6F;AAC7F,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAE3D;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAe9D"}
1
+ {"version":3,"file":"permissions.d.ts","sourceRoot":"","sources":["../../src/tokens/permissions.ts"],"names":[],"mappings":"AAKA;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB;aAC5B,SAAS,YAAG,SAAS;aAIrB,UAAU,YAAG,UAAU;aACvB,eAAe,YAAG,uBAAuB;aACzC,cAAc,YAAG,oBAAoB;aACrC,eAAe,YAAG,qBAAqB;aACvC,eAAe,YAAG,2BAA2B;aAC7C,UAAU,YAAG,0BAA0B;aACvC,SAAS,YAAG,yBAAyB;aACrC,UAAU,YAAG,0BAA0B;aACvC,gBAAgB,YAAG,gBAAgB;aACnC,iBAAiB,YAAG,iBAAiB;aACrC,SAAS,YAAG,iBAAiB;aAK7B,WAAW,YAAG,WAAW;aAiBzB,cAAc,YAAG,sBAAsB;CAC/B,CAAC;AAEX,wEAAwE;AACxE,MAAM,MAAM,aAAa,GAAG,MAAM,OAAO,iBAAiB,CAAC;AAE3D;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;CAeoC,CAAC;AAE9D;;;GAGG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAShE;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,EAAE,SAAS,aAAa,EAAqB,CAAC;AAElF,6DAA6D;AAC7D,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,IAAI,aAAa,CAEjE;AAED,6FAA6F;AAC7F,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAE3D;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAe9D"}
@@ -29,6 +29,50 @@ const PERMISSION_GROUPS = {
29
29
  "routes:write": ["Workers Routes Write"]
30
30
  };
31
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
+ const OAUTH_SCOPES = {
50
+ "d1:read": ["d1.read"],
51
+ "d1:write": ["d1.write"],
52
+ "workers:write": ["workers-scripts.write"],
53
+ "secrets:read": ["secrets-store.read"],
54
+ "secrets:write": ["secrets-store.write"],
55
+ "email:routing": ["email-routing-rule.write"],
56
+ "kv:write": ["workers-kv-storage.write"],
57
+ "r2:read": ["workers-r2.read"],
58
+ "r2:write": ["workers-r2.write"],
59
+ "vectorize:read": ["vectorize.read"],
60
+ "vectorize:write": ["vectorize.write"],
61
+ "ai:read": ["ai.read"],
62
+ "zone:read": ["zone.read"],
63
+ "routes:write": ["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
+ function oauthScopesFor(keys) {
70
+ resolvePermissionKeys([...keys]);
71
+ const scopes = [];
72
+ for (const key of keys) for (const scope of OAUTH_SCOPES[key]) if (!scopes.includes(scope)) scopes.push(scope);
73
+ return scopes;
74
+ }
75
+ /**
32
76
  * The keys whose Cloudflare permission group is **zone-scoped**: a policy carrying one must name zone
33
77
  * resources (`com.cloudflare.api.account.zone.<id>`), never the account resource.
34
78
  *
@@ -64,4 +108,4 @@ function resolvePermissionKeys(keys) {
64
108
  return names;
65
109
  }
66
110
  //#endregion
67
- export { PERMISSION_GROUPS, ZONE_SCOPED_PERMISSIONS, isPermissionKey, isZoneScopedPermission, resolvePermissionKeys };
111
+ export { OAUTH_SCOPES, PERMISSION_GROUPS, ZONE_SCOPED_PERMISSIONS, isPermissionKey, isZoneScopedPermission, oauthScopesFor, resolvePermissionKeys };
@@ -105,6 +105,10 @@ export declare function permissionsForKeys(keys: PermissionKey[], accountId: str
105
105
  * it is a policy of its own rather than a permission on the profile because its resources are zones,
106
106
  * which a profile cannot name: they are resolved from the domains at mint time.
107
107
  *
108
+ * **This limits the route, not the token.** The account policy beside it carries Workers Scripts Write,
109
+ * which attaches a custom domain on any zone the account holds (measured, #709; `docs/TOKENS.md`). Only
110
+ * the route calls are held to the declared zones.
111
+ *
108
112
  * **Empty for an empty zone list, deliberately.** A project that declares no domain attaches no route,
109
113
  * so it mints exactly the token it minted before this existed. No new permission for a project that
110
114
  * needs none.
@@ -1 +1 @@
1
- {"version":3,"file":"profiles.d.ts","sourceRoot":"","sources":["../../src/tokens/profiles.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,0CAA0C,CAAC;AAE7F,OAAO,EAAmB,KAAK,eAAe,EAAiB,MAAM,wBAAwB,CAAC;AAC9F,OAAO,EAIL,KAAK,aAAa,EAEnB,MAAM,eAAe,CAAC;AAEvB;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,UAAU,GAAG,eAAe,GAAG,UAAU,GAAG,WAAW,CAAC;AAEpE,uFAAuF;AACvF,eAAO,MAAM,YAAY,EAAE,SAAS,UAAU,EAA+C,CAAC;AAE9F,4GAA4G;AAC5G,eAAO,MAAM,iBAAiB,cAAc,CAAC;AAe7C;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,mFAAmF;IACnF,IAAI,EAAE,MAAM,CAAC;IACb,oGAAoG;IACpG,WAAW,EAAE,aAAa,EAAE,CAAC;IAC7B,oFAAoF;IACpF,SAAS,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/C,0GAA0G;IAC1G,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,gBAAgB,CAAC;IAC/B,2GAA2G;IAC3G,YAAY,CAAC,EAAE,UAAU,CAAC;IAC1B,sDAAsD;IACtD,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,kGAAkG;AAClG,MAAM,MAAM,gBAAgB,GAAG,aAAa,GAAG,QAAQ,CAAC;AAKxD,iHAAiH;AACjH,MAAM,WAAW,eAAe;IAC9B,6CAA6C;IAC7C,WAAW,CAAC,EAAE,aAAa,EAAE,CAAC;IAC9B,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,sDAAsD;IACtD,KAAK,CAAC,EAAE,UAAU,CAAC;CACpB;AAED,yFAAyF;AACzF,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,GAAG,YAAY,CAGtE;AAkCD;;;;;;;;;GASG;AACH,MAAM,WAAW,qBAAsB,SAAQ,gBAAgB;IAC7D,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AA6BD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,SAAS,UAAU,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CActG;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,EACtC,IAAI,EAAE,MAAM,EACZ,QAAQ,CAAC,EAAE,eAAe,GACzB,YAAY,CAgBd;AAsBD,uGAAuG;AACvG,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,aAAa,EAAE,EAAE,SAAS,EAAE,MAAM,GAAG,eAAe,EAAE,CAG9F;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,eAAe,EAAE,CAG9E;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,GAAG,eAAe,EAAE,CAK9F"}
1
+ {"version":3,"file":"profiles.d.ts","sourceRoot":"","sources":["../../src/tokens/profiles.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,0CAA0C,CAAC;AAE7F,OAAO,EAAmB,KAAK,eAAe,EAAiB,MAAM,wBAAwB,CAAC;AAC9F,OAAO,EAIL,KAAK,aAAa,EAEnB,MAAM,eAAe,CAAC;AAEvB;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,UAAU,GAAG,eAAe,GAAG,UAAU,GAAG,WAAW,CAAC;AAEpE,uFAAuF;AACvF,eAAO,MAAM,YAAY,EAAE,SAAS,UAAU,EAA+C,CAAC;AAE9F,4GAA4G;AAC5G,eAAO,MAAM,iBAAiB,cAAc,CAAC;AAe7C;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,mFAAmF;IACnF,IAAI,EAAE,MAAM,CAAC;IACb,oGAAoG;IACpG,WAAW,EAAE,aAAa,EAAE,CAAC;IAC7B,oFAAoF;IACpF,SAAS,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/C,0GAA0G;IAC1G,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,gBAAgB,CAAC;IAC/B,2GAA2G;IAC3G,YAAY,CAAC,EAAE,UAAU,CAAC;IAC1B,sDAAsD;IACtD,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,kGAAkG;AAClG,MAAM,MAAM,gBAAgB,GAAG,aAAa,GAAG,QAAQ,CAAC;AAKxD,iHAAiH;AACjH,MAAM,WAAW,eAAe;IAC9B,6CAA6C;IAC7C,WAAW,CAAC,EAAE,aAAa,EAAE,CAAC;IAC9B,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,sDAAsD;IACtD,KAAK,CAAC,EAAE,UAAU,CAAC;CACpB;AAED,yFAAyF;AACzF,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,GAAG,YAAY,CAGtE;AAkCD;;;;;;;;;GASG;AACH,MAAM,WAAW,qBAAsB,SAAQ,gBAAgB;IAC7D,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AA6BD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,SAAS,UAAU,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CActG;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,EACtC,IAAI,EAAE,MAAM,EACZ,QAAQ,CAAC,EAAE,eAAe,GACzB,YAAY,CAgBd;AAsBD,uGAAuG;AACvG,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,aAAa,EAAE,EAAE,SAAS,EAAE,MAAM,GAAG,eAAe,EAAE,CAG9F;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,eAAe,EAAE,CAG9E;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,GAAG,eAAe,EAAE,CAK9F"}
@@ -152,6 +152,10 @@ function permissionsForKeys(keys, accountId) {
152
152
  * it is a policy of its own rather than a permission on the profile because its resources are zones,
153
153
  * which a profile cannot name: they are resolved from the domains at mint time.
154
154
  *
155
+ * **This limits the route, not the token.** The account policy beside it carries Workers Scripts Write,
156
+ * which attaches a custom domain on any zone the account holds (measured, #709; `docs/TOKENS.md`). Only
157
+ * the route calls are held to the declared zones.
158
+ *
155
159
  * **Empty for an empty zone list, deliberately.** A project that declares no domain attaches no route,
156
160
  * so it mints exactly the token it minted before this existed. No new permission for a project that
157
161
  * needs none.
@@ -3,8 +3,8 @@
3
3
  import { CloudflareInvalidResponseError, cloudflareApiErrors, cloudflareRefusal, cloudflareRequest, decodeResponse, messageOf } from "../client/errors.js";
4
4
  import { CloudflareManager } from "../client/manager.js";
5
5
  import { CfBuild, CfBuildLog, CfBuildToken, CfBuildTrigger, CfRepoConnection } from "./buildsTypes.js";
6
- import { z } from "zod";
7
6
  import { PithyError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { z } from "zod";
8
8
  //#region src/workers/buildsManager.ts
9
9
  /** The Cloudflare REST API v4 base URL. The Builds endpoints live under `/accounts/{id}/builds`. */
10
10
  const CF_API_BASE = "https://api.cloudflare.com/client/v4";
@@ -1,7 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
- import { z } from "zod";
4
3
  import { PithyError } from "@pithy-sh/core/src/error/pithyError";
4
+ import { z } from "zod";
5
5
  import { MAX_WORKFLOW_STEP_TEXT, decodeWorkflowStepMessage, splitWorkflowStepCode } from "@pithy-sh/core/src/workflow/stepMessage";
6
6
  import { guaranteedErrorParams } from "@pithy-sh/core/src/error/messageParams";
7
7
  import { ErrorPayload, kitErrorStatus } from "@pithy-sh/core/src/error/payload";
@@ -3,8 +3,8 @@
3
3
  import { CloudflareInvalidResponseError, cloudflareRequest, isNotFoundError } from "../client/errors.js";
4
4
  import { CloudflareManager } from "../client/manager.js";
5
5
  import { stepFailure, terminalWorkflowError } from "./stepFailure.js";
6
- import { z } from "zod";
7
6
  import { UpstreamTimeoutError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { z } from "zod";
8
8
  //#region src/workflows/workflowsClient.ts
9
9
  const WorkflowInstanceStatus = z.enum([
10
10
  "queued",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pithy-sh/cloudflare",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,7 @@
40
40
  "js-base64": "^3.9.2"
41
41
  },
42
42
  "peerDependencies": {
43
- "@pithy-sh/core": "^0.8.0",
43
+ "@pithy-sh/core": "^0.10.0",
44
44
  "zod": "^4.4.0"
45
45
  },
46
46
  "devDependencies": {
@@ -1,7 +1,8 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- import type { AIRunParams, AIRunResponse } from "cloudflare/resources/ai/ai";
4
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { AIRunResponse } from "cloudflare/resources/ai/ai";
5
6
  import type { ModelListResponse } from "cloudflare/resources/ai/models/models";
6
7
  import { fromUint8Array } from "js-base64";
7
8
  import { z } from "zod";
@@ -128,6 +129,39 @@ export interface AnalyzeImageOptions {
128
129
 
129
130
  const DEFAULT_IMAGE_PROMPT = "Describe this image in a concise sentence suitable for alt text.";
130
131
 
132
+ /**
133
+ * What a Workers AI model name may look like: `@<namespace>/` and then one to three segments, each made of
134
+ * letters, digits, `.`, `_` and `-` and starting with a letter or digit — `@cf/baai/bge-m3`,
135
+ * `@cf/llava-hf/llava-1.5-7b-hf`, `@hf/thebloke/deepseek-coder-6.7b-instruct-awq`.
136
+ *
137
+ * **The name goes into the request path unencoded, so this pattern is the whole of the defense.** A segment
138
+ * cannot be `.` or `..` (it must start with a letter or digit), and nothing that ends a path, opens a query
139
+ * or a fragment, or escapes a character (`?`, `#`, `%`, `\`, whitespace) is in the alphabet. A name outside
140
+ * it is refused before a request exists.
141
+ */
142
+ const AI_MODEL_NAME = /^@[a-z0-9][a-z0-9-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*){1,3}$/;
143
+
144
+ /**
145
+ * The REST path that runs `model`: `/accounts/<id>/ai/run/@cf/baai/bge-m3`, the model's slashes literal.
146
+ *
147
+ * **Not the SDK's `ai.run` (#709).** cloudflare 7.0.0 builds that path with its `path` tag, which
148
+ * percent-encodes every path parameter, the `/`s inside a model name included. Cloudflare routes on the
149
+ * literal slashes, so `/ai/run/@cf%2Fbaai%2Fbge-m3` answers 7000 "No route for that URI" for every model.
150
+ * The path is built here instead, and a name that could change its shape is refused by {@link AI_MODEL_NAME}.
151
+ *
152
+ * @throws ValidationError (`validation/invalid_input`) when `model` is not a Workers AI model name.
153
+ */
154
+ export function aiRunPath(accountId: string, model: string): string {
155
+ if (!AI_MODEL_NAME.test(model)) {
156
+ throw new ValidationError({
157
+ message: `"${model.slice(0, 120)}" is not a Workers AI model name.`,
158
+ action: "Pass a model name such as @cf/baai/bge-m3. The Workers AI model catalog lists them.",
159
+ detail: `model name ${JSON.stringify(model)} does not match ${AI_MODEL_NAME}`,
160
+ });
161
+ }
162
+ return `/accounts/${encodeURIComponent(accountId)}/ai/run/${model}`;
163
+ }
164
+
131
165
  /**
132
166
  * Out-of-Worker Workers AI access over the REST API: run any model, generate text, generate
133
167
  * embeddings, analyze an image, and list models from a CLI/CI/provisioning context. Inside a
@@ -138,12 +172,17 @@ const DEFAULT_IMAGE_PROMPT = "Describe this image in a concise sentence suitable
138
172
  export class CloudflareAIManager extends CloudflareManager {
139
173
  /**
140
174
  * Run a Cloudflare AI model by name with an arbitrary JSON input. The single seam every typed
141
- * helper builds on. The account id is injected for you; the rest of `input` is the model's body.
175
+ * helper builds on. The account is addressed for you; `input` is the model's body.
176
+ *
177
+ * Through the SDK's raw `post` to {@link aiRunPath}, not `ai.run`, whose path encoding Cloudflare cannot
178
+ * route. A malformed model name throws `validation/invalid_input` before any request is sent.
142
179
  */
143
180
  async runModel(model: string, input: Record<string, unknown>): Promise<AIRunResponse> {
144
- return cloudflareRequest(`AI run model '${model}'`, () =>
145
- this.getClient().ai.run(model, { account_id: this.accountId, ...input } as AIRunParams),
146
- );
181
+ const path = aiRunPath(this.accountId, model);
182
+ return cloudflareRequest(`AI run model '${model}'`, async () => {
183
+ const envelope = await this.getClient().post<{ result: AIRunResponse }>(path, { body: input });
184
+ return envelope.result;
185
+ });
147
186
  }
148
187
 
149
188
  /**
@@ -17,6 +17,15 @@ export interface CloudflareManagerConfig {
17
17
  apiToken: string;
18
18
  /** The Cloudflare account id all operations target (`CLOUDFLARE_ACCOUNT_ID`). */
19
19
  accountId: string;
20
+ /**
21
+ * The token to call with *now*, read at every call, when it can change during the manager's life (#759).
22
+ *
23
+ * A signed-in session's access token lasts an hour and is renewed on disk ahead of that, so a manager
24
+ * built at the start of a long run would otherwise keep sending the token it was constructed with.
25
+ * Absent, {@link apiToken} is the token for good — which is every command that finishes inside the hour
26
+ * dispatch guarantees it.
27
+ */
28
+ tokenProvider?: () => string;
20
29
  }
21
30
 
22
31
  /**
@@ -26,9 +35,10 @@ export interface CloudflareManagerConfig {
26
35
  * (`validateServiceAccess`).
27
36
  */
28
37
  export abstract class CloudflareManager {
29
- private readonly client: Cloudflare;
38
+ /** The SDK client, and the token it was built for — rebuilt when {@link getApiToken} answers another. */
39
+ private client: { token: string; sdk: Cloudflare };
30
40
 
31
- private readonly apiToken: string;
41
+ private readonly token: () => string;
32
42
 
33
43
  protected readonly accountId: string;
34
44
 
@@ -39,22 +49,30 @@ export abstract class CloudflareManager {
39
49
  if (!config.accountId) {
40
50
  throw new CloudflareNotConfiguredError({ detail: "Missing accountId in CloudflareManagerConfig." });
41
51
  }
42
- this.client = new Cloudflare({ apiToken: config.apiToken });
43
- this.apiToken = config.apiToken;
52
+ const initial = config.apiToken;
53
+ this.token = config.tokenProvider ?? (() => initial);
54
+ this.client = { token: initial, sdk: new Cloudflare({ apiToken: initial }) };
44
55
  this.accountId = config.accountId;
45
56
  }
46
57
 
47
58
  /** The configured SDK client. Subclasses route typed calls through this. */
48
59
  protected getClient(): Cloudflare {
49
- return this.client;
60
+ const token = this.getApiToken();
61
+ if (token !== this.client.token) this.client = { token, sdk: new Cloudflare({ apiToken: token }) };
62
+ return this.client.sdk;
50
63
  }
51
64
 
52
65
  /**
53
66
  * The raw API token, for subclasses that fall back to direct `fetch()` for endpoints the typed
54
67
  * SDK does not cover (e.g. Workers Builds, event subscriptions). The documented escape hatch.
68
+ * Read through the provider at every call, so it is the current token, never the constructed one.
55
69
  */
56
70
  protected getApiToken(): string {
57
- return this.apiToken;
71
+ const token = this.token();
72
+ if (!token) {
73
+ throw new CloudflareNotConfiguredError({ detail: "The tokenProvider answered no apiToken." });
74
+ }
75
+ return token;
58
76
  }
59
77
 
60
78
  /** The account id all operations target. */
@@ -7,7 +7,7 @@ import type { D1 } from "cloudflare/resources/d1/d1";
7
7
  import type { QueryResult } from "cloudflare/resources/d1/database";
8
8
  import { CloudflareNotConfiguredError, cloudflareRequest, reasonOf } from "../client/errors";
9
9
  import { CloudflareManager, type CloudflareManagerConfig } from "../client/manager";
10
- import { D1PreparedStatementREST } from "./d1PreparedStatement";
10
+ import { D1_QUERY_REQUEST, D1PreparedStatementREST } from "./d1PreparedStatement";
11
11
 
12
12
  /** Config for the D1 manager: the shared client config plus the database it targets. */
13
13
  export interface D1ManagerConfig extends CloudflareManagerConfig {
@@ -68,7 +68,7 @@ export class CloudflareD1Manager extends CloudflareManager implements D1Database
68
68
 
69
69
  async exec(query: string): Promise<D1ExecResult> {
70
70
  const response = await cloudflareRequest("D1 exec", () =>
71
- this.getClient().d1.database.query(this.databaseId, { account_id: this.accountId, sql: query }),
71
+ this.getClient().d1.database.query(this.databaseId, { account_id: this.accountId, sql: query }, D1_QUERY_REQUEST),
72
72
  );
73
73
  const result = response.result[0];
74
74
  return { count: result?.results?.length ?? 0, duration: result?.meta?.duration ?? 0 };
@@ -89,7 +89,11 @@ export class CloudflareD1Manager extends CloudflareManager implements D1Database
89
89
  /** Run one SQL statement and return its raw REST result sets. */
90
90
  async executeQuery(sql: string, params?: string[]): Promise<QueryResult[]> {
91
91
  const response = await cloudflareRequest("D1 query", () =>
92
- this.getClient().d1.database.query(this.databaseId, { account_id: this.accountId, sql, params }),
92
+ this.getClient().d1.database.query(
93
+ this.databaseId,
94
+ { account_id: this.accountId, sql, params },
95
+ D1_QUERY_REQUEST,
96
+ ),
93
97
  );
94
98
  return response.result;
95
99
  }
@@ -31,6 +31,17 @@ function mapMeta(restMeta?: QueryResult.Meta): D1Meta & Record<string, unknown>
31
31
  };
32
32
  }
33
33
 
34
+ /**
35
+ * The request options every D1 query is sent with: **no retries** (#709).
36
+ *
37
+ * The SDK retries a request twice by default, on a connection timeout, a 408, a 409, a 429 or a 5xx. That is
38
+ * safe for a read of a resource and wrong for a query, which is not idempotent: D1 can apply a statement and
39
+ * answer slowly, and the resent copy then runs against the changed database. A migration's `CREATE TABLE` did
40
+ * exactly that and failed `table already exists`, on a step that had succeeded. Sent once, a slow answer
41
+ * surfaces as the timeout it was, and `cloudflareRequest` already tells the operator to check before retrying.
42
+ */
43
+ export const D1_QUERY_REQUEST = { maxRetries: 0 } as const;
44
+
34
45
  /** The REST `params` array is strings; coerce each bound value the way the D1 REST API expects. */
35
46
  function toStringParams(params: unknown[]): string[] {
36
47
  return params.map((value) => {
@@ -88,7 +99,11 @@ export class D1PreparedStatementREST<T = Record<string, unknown>> implements D1P
88
99
  async raw<U = unknown[]>(options?: { columnNames?: boolean }): Promise<U[] | [string[], ...U[]]> {
89
100
  const params = this.boundParams.length > 0 ? toStringParams(this.boundParams) : undefined;
90
101
  const response = await cloudflareRequest("D1 raw query", () =>
91
- this.client.d1.database.raw(this.databaseId, { account_id: this.accountId, sql: this.sql, params }),
102
+ this.client.d1.database.raw(
103
+ this.databaseId,
104
+ { account_id: this.accountId, sql: this.sql, params },
105
+ D1_QUERY_REQUEST,
106
+ ),
92
107
  );
93
108
  const rawResult = response.result[0];
94
109
  const rows = (rawResult?.results?.rows ?? []) as U[];
@@ -102,7 +117,11 @@ export class D1PreparedStatementREST<T = Record<string, unknown>> implements D1P
102
117
  private async executeQuery<U>(): Promise<D1Result<U>> {
103
118
  const params = this.boundParams.length > 0 ? toStringParams(this.boundParams) : undefined;
104
119
  const response = await cloudflareRequest("D1 query", () =>
105
- this.client.d1.database.query(this.databaseId, { account_id: this.accountId, sql: this.sql, params }),
120
+ this.client.d1.database.query(
121
+ this.databaseId,
122
+ { account_id: this.accountId, sql: this.sql, params },
123
+ D1_QUERY_REQUEST,
124
+ ),
106
125
  );
107
126
  const queryResult = response.result[0];
108
127
  return {
@@ -0,0 +1,70 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { commandName } from "@pithy-sh/core/src/cli/command";
5
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
6
+
7
+ /** What a callback said: a code to redeem, or Cloudflare's refusal. */
8
+ export type CallbackAnswer = { kind: "code"; code: string } | { kind: "error"; error: string; description: string };
9
+
10
+ /** What the address must match: the `state` and `redirect_uri` this sign-in sent. */
11
+ export interface CallbackExpectation {
12
+ /** The `state` in the authorize URL. */
13
+ state: string;
14
+ /** The `redirect_uri` in the authorize URL, verbatim. */
15
+ redirectUri: string;
16
+ }
17
+
18
+ /**
19
+ * Read the address a browser landed on — from the loopback listener or pasted by hand — into an answer.
20
+ *
21
+ * **One reader for both paths, so they cannot check different things.** A remote session pastes what its
22
+ * browser shows, because the redirect lands on the browser's own loopback interface, where nothing is
23
+ * listening (measured over SSH, 2026-10-15). The pasted address is held to exactly what a delivered
24
+ * callback is: the same origin and path this sign-in sent, the same `state`, and a `code` or an `error`.
25
+ *
26
+ * Accepting a paste is safe because of PKCE, not because of this check — a code is worthless without the
27
+ * verifier, and the verifier never left this process. The `state` check is what stops a code from a
28
+ * *different* sign-in being redeemed into this one.
29
+ */
30
+ export function readCallback(address: string, expect: CallbackExpectation): CallbackAnswer {
31
+ const text = address.trim().replace(/^["']|["']$/g, "");
32
+ let url: URL;
33
+ try {
34
+ url = new URL(text);
35
+ } catch {
36
+ throw notTheAddress(expect, "it is not an address");
37
+ }
38
+ const sent = new URL(expect.redirectUri);
39
+ if (url.origin !== sent.origin || url.pathname !== sent.pathname) {
40
+ throw notTheAddress(expect, `it is ${url.origin}${url.pathname}`);
41
+ }
42
+ if (url.searchParams.get("state") !== expect.state) {
43
+ throw new ValidationError({
44
+ message: "That address belongs to a different sign-in: its state does not match.",
45
+ action: `Paste the address from the browser tab this sign-in opened, or run ${commandName("cloudflare connect")} again.`,
46
+ detail: "callback state did not match the state this sign-in sent",
47
+ });
48
+ }
49
+ const error = url.searchParams.get("error");
50
+ if (error !== null) {
51
+ return { kind: "error", error, description: url.searchParams.get("error_description") ?? error };
52
+ }
53
+ const code = url.searchParams.get("code");
54
+ if (code === null || code === "") {
55
+ throw new ValidationError({
56
+ message: "That address carries no code and no error.",
57
+ action: "Paste the whole address from the browser's address bar, after you approve.",
58
+ detail: "callback had neither `code` nor `error`",
59
+ });
60
+ }
61
+ return { kind: "code", code };
62
+ }
63
+
64
+ function notTheAddress(expect: CallbackExpectation, why: string): ValidationError {
65
+ return new ValidationError({
66
+ message: `That is not the address this sign-in sent the browser to: ${why}.`,
67
+ action: `Paste the whole address your browser landed on. It starts with ${expect.redirectUri}.`,
68
+ detail: `expected ${expect.redirectUri}`,
69
+ });
70
+ }
@@ -0,0 +1,39 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { cloudflareRequest, decodeResponse } from "../client/errors";
6
+
7
+ /** One OAuth scope, as Cloudflare's catalog lists it. */
8
+ export const OAuthScope = z
9
+ .object({
10
+ id: z.string().describe("The scope a client requests: `d1.write`."),
11
+ name: z.string().describe("Its display name, which is the permission group's own: `D1 Write`."),
12
+ category: z.string().optional().describe("Cloudflare's grouping, e.g. `developer_platform`."),
13
+ })
14
+ .describe("One scope in `GET /oauth/scopes`.");
15
+ export type OAuthScope = z.output<typeof OAuthScope>;
16
+
17
+ const ScopeList = z.object({ result: z.array(OAuthScope) });
18
+
19
+ /** The catalog's endpoint. Global: it needs authentication and no account. */
20
+ export const OAUTH_SCOPES_URL = "https://api.cloudflare.com/client/v4/oauth/scopes";
21
+
22
+ /**
23
+ * **Cloudflare's OAuth scope catalog: every scope a third-party client may register (#759).**
24
+ *
25
+ * The third column of the permission table is checked against this the way the group names are checked
26
+ * against the account, because a scope id that drifts from it is a sign-in Cloudflare refuses with
27
+ * `invalid_scope` — at the consent screen of an adopter, rather than in this repository's CI.
28
+ */
29
+ export async function listOAuthScopes(options: {
30
+ apiToken: string;
31
+ fetch?: (url: string, init: { headers: Record<string, string> }) => Promise<Response>;
32
+ }): Promise<OAuthScope[]> {
33
+ const send = options.fetch ?? ((url, init) => globalThis.fetch(url, init));
34
+ const raw = await cloudflareRequest("list OAuth scopes", async () => {
35
+ const response = await send(OAUTH_SCOPES_URL, { headers: { authorization: `Bearer ${options.apiToken}` } });
36
+ return (await response.json()) as unknown;
37
+ });
38
+ return decodeResponse(ScopeList, raw, "oauth scopes list").result;
39
+ }