@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.
- package/README.md +3 -3
- package/dist/ai/aiManager.d.ts +15 -1
- package/dist/ai/aiManager.d.ts.map +1 -1
- package/dist/ai/aiManager.js +39 -6
- package/dist/client/manager.d.ts +13 -2
- package/dist/client/manager.d.ts.map +1 -1
- package/dist/client/manager.js +18 -5
- package/dist/d1/d1Manager.d.ts.map +1 -1
- package/dist/d1/d1Manager.js +3 -3
- package/dist/d1/d1PreparedStatement.d.ts +12 -0
- package/dist/d1/d1PreparedStatement.d.ts.map +1 -1
- package/dist/d1/d1PreparedStatement.js +13 -3
- package/dist/oauth/callback.d.ts +32 -0
- package/dist/oauth/callback.d.ts.map +1 -0
- package/dist/oauth/callback.js +58 -0
- package/dist/oauth/catalog.d.ts +26 -0
- package/dist/oauth/catalog.d.ts.map +1 -0
- package/dist/oauth/catalog.js +30 -0
- package/dist/oauth/client.d.ts +126 -0
- package/dist/oauth/client.d.ts.map +1 -0
- package/dist/oauth/client.js +200 -0
- package/dist/oauth/scopes.d.ts +62 -0
- package/dist/oauth/scopes.d.ts.map +1 -0
- package/dist/oauth/scopes.js +79 -0
- package/dist/secrets/secretsStoreManager.d.ts.map +1 -1
- package/dist/secrets/secretsStoreManager.js +3 -2
- package/dist/test-utils/settle.d.ts +79 -0
- package/dist/test-utils/settle.d.ts.map +1 -0
- package/dist/test-utils/settle.js +105 -0
- package/dist/tokens/accountTokensManager.d.ts +7 -7
- package/dist/tokens/permissions.d.ts +39 -0
- package/dist/tokens/permissions.d.ts.map +1 -1
- package/dist/tokens/permissions.js +45 -1
- package/dist/tokens/profiles.d.ts +4 -0
- package/dist/tokens/profiles.d.ts.map +1 -1
- package/dist/tokens/profiles.js +4 -0
- package/dist/workers/buildsManager.js +1 -1
- package/dist/workflows/stepFailure.js +1 -1
- package/dist/workflows/workflowsClient.js +1 -1
- package/package.json +2 -2
- package/src/ai/aiManager.ts +44 -5
- package/src/client/manager.ts +24 -6
- package/src/d1/d1Manager.ts +7 -3
- package/src/d1/d1PreparedStatement.ts +21 -2
- package/src/oauth/callback.ts +70 -0
- package/src/oauth/catalog.ts +39 -0
- package/src/oauth/client.ts +265 -0
- package/src/oauth/scopes.ts +91 -0
- package/src/secrets/secretsStoreManager.ts +2 -1
- package/src/test-utils/settle.ts +139 -0
- package/src/tokens/permissions.ts +54 -0
- 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;
|
|
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
|
|
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"}
|
package/dist/tokens/profiles.js
CHANGED
|
@@ -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.
|
|
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.
|
|
43
|
+
"@pithy-sh/core": "^0.10.0",
|
|
44
44
|
"zod": "^4.4.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
package/src/ai/aiManager.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
-
import
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
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
|
/**
|
package/src/client/manager.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
43
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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. */
|
package/src/d1/d1Manager.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
+
}
|