@pithy-sh/cloudflare 0.9.0 → 0.9.2

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.
@@ -1 +1 @@
1
- {"version":3,"file":"d1PreparedStatement.d.ts","sourceRoot":"","sources":["../../src/d1/d1PreparedStatement.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAU,mBAAmB,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AACvF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AA6B7C;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB;aAAK,UAAU,EAAE,CAAC;CAAW,CAAC;AAY3D,qBAAa,uBAAuB,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAE,YAAW,mBAAmB;IAC9F,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAE7B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAY;IAExC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAa;IAEpC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAEnC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IAEpC,YAAY,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,GAAE,OAAO,EAAO,EAM9G;IAED,gGAAgG;IAChG,IAAI,CAAC,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,uBAAuB,CAAC,CAAC,CAAC,CAErD;IAED,GAAG,CAAC,CAAC,GAAG,CAAC,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAEjC;IAED,GAAG,CAAC,CAAC,GAAG,CAAC,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAEjC;IAEK,KAAK,CAAC,CAAC,GAAG,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IACvD,KAAK,CAAC,CAAC,GAAG,CAAC,KAAK,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IASlC,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,EAAE,OAAO,EAAE;QAAE,WAAW,EAAE,IAAI,CAAA;KAAE,GAAG,OAAO,CAAC,CAAC,MAAM,EAAE,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IAChF,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,EAAE,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,KAAK,CAAA;KAAE,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC;YAmB5D,YAAY;CAgB3B"}
1
+ {"version":3,"file":"d1PreparedStatement.d.ts","sourceRoot":"","sources":["../../src/d1/d1PreparedStatement.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAU,mBAAmB,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AACvF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AA6B7C;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB;aAAK,UAAU,EAAE,CAAC;CAAW,CAAC;AAmB3D,qBAAa,uBAAuB,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAE,YAAW,mBAAmB;IAC9F,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAE7B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAY;IAExC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAa;IAEpC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAEnC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IAEpC,YAAY,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,GAAE,OAAO,EAAO,EAM9G;IAED,gGAAgG;IAChG,IAAI,CAAC,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,uBAAuB,CAAC,CAAC,CAAC,CAErD;IAED,GAAG,CAAC,CAAC,GAAG,CAAC,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAEjC;IAED,GAAG,CAAC,CAAC,GAAG,CAAC,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAEjC;IAEK,KAAK,CAAC,CAAC,GAAG,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IACvD,KAAK,CAAC,CAAC,GAAG,CAAC,KAAK,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IASlC,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,EAAE,OAAO,EAAE;QAAE,WAAW,EAAE,IAAI,CAAA;KAAE,GAAG,OAAO,CAAC,CAAC,MAAM,EAAE,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IAChF,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,EAAE,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,KAAK,CAAA;KAAE,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC;YAmB5D,YAAY;CAgB3B"}
@@ -36,10 +36,15 @@ function mapMeta(restMeta) {
36
36
  * surfaces as the timeout it was, and `cloudflareRequest` already tells the operator to check before retrying.
37
37
  */
38
38
  const D1_QUERY_REQUEST = { maxRetries: 0 };
39
- /** The REST `params` array is strings; coerce each bound value the way the D1 REST API expects. */
40
- function toStringParams(params) {
39
+ /**
40
+ * Encode each bound value for the REST `params` array: a string as itself, a number or boolean as its
41
+ * string, and a null as JSON `null`, which D1 binds as SQL NULL. `""` would bind as text, and a nullable
42
+ * column would hold a value no reader takes for absent. Numbers stay strings because a JSON number binds
43
+ * as `real`, and the column's affinity converts the string.
44
+ */
45
+ function toRestParams(params) {
41
46
  return params.map((value) => {
42
- if (value === null || value === void 0) return "";
47
+ if (value === null || value === void 0) return null;
43
48
  if (typeof value === "string") return value;
44
49
  if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
45
50
  return JSON.stringify(value);
@@ -75,7 +80,7 @@ var D1PreparedStatementREST = class D1PreparedStatementREST {
75
80
  return row;
76
81
  }
77
82
  async raw(options) {
78
- const params = this.boundParams.length > 0 ? toStringParams(this.boundParams) : void 0;
83
+ const params = this.boundParams.length > 0 ? toRestParams(this.boundParams) : void 0;
79
84
  const rawResult = (await cloudflareRequest("D1 raw query", () => this.client.d1.database.raw(this.databaseId, {
80
85
  account_id: this.accountId,
81
86
  sql: this.sql,
@@ -86,7 +91,7 @@ var D1PreparedStatementREST = class D1PreparedStatementREST {
86
91
  return rows;
87
92
  }
88
93
  async executeQuery() {
89
- const params = this.boundParams.length > 0 ? toStringParams(this.boundParams) : void 0;
94
+ const params = this.boundParams.length > 0 ? toRestParams(this.boundParams) : void 0;
90
95
  const queryResult = (await cloudflareRequest("D1 query", () => this.client.d1.database.query(this.databaseId, {
91
96
  account_id: this.accountId,
92
97
  sql: this.sql,
@@ -0,0 +1,25 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { BindingType } from "@pithy-sh/core/src/capability/bindings";
4
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
5
+ import type { PermissionKey } from "./permissions";
6
+ /**
7
+ * **What a binding of each kind needs the `ci-system` token to carry**, so that provisioning, deploying and
8
+ * tearing down the resource behind it succeed under that token and under a signed-in session.
9
+ *
10
+ * Read off the binding rather than declared by a capability, because the adopter's own app binds resources
11
+ * too and has no `ciPermissions` anyone would think to write. A kind a provision command creates with no
12
+ * entry here fails `cli/src/provision/bindingPermissions.test.ts`.
13
+ *
14
+ * - `d1`, `secret` and `workflow` name what the `ci-system` base already carries.
15
+ * - `kv`, `r2` and `vectorize` take write alone. Provisioning lists before it creates, and a write scope
16
+ * covers its read, as `OAUTH_SCOPES` records.
17
+ * - `queue` and `artifacts` are reached by the API only, to create or reap; a Worker uses its binding.
18
+ */
19
+ export declare const BINDING_PERMISSIONS: Readonly<Partial<Record<BindingType, readonly PermissionKey[]>>>;
20
+ /**
21
+ * The permission keys every binding the capabilities declare needs, each once, in first-seen order. The
22
+ * adopter's own app is a capability here like any other, so a bucket it declares counts.
23
+ */
24
+ export declare function bindingPermissions(capabilities: readonly Capability[]): PermissionKey[];
25
+ //# sourceMappingURL=bindingPermissions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bindingPermissions.d.ts","sourceRoot":"","sources":["../../src/tokens/bindingPermissions.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wCAAwC,CAAC;AAC1E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0CAA0C,CAAC;AAC3E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAEnD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,mBAAmB,EAAE,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,SAAS,aAAa,EAAE,CAAC,CAAC,CAShG,CAAC;AAEF;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,YAAY,EAAE,SAAS,UAAU,EAAE,GAAG,aAAa,EAAE,CAUvF"}
@@ -0,0 +1,37 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/tokens/bindingPermissions.ts
4
+ /**
5
+ * **What a binding of each kind needs the `ci-system` token to carry**, so that provisioning, deploying and
6
+ * tearing down the resource behind it succeed under that token and under a signed-in session.
7
+ *
8
+ * Read off the binding rather than declared by a capability, because the adopter's own app binds resources
9
+ * too and has no `ciPermissions` anyone would think to write. A kind a provision command creates with no
10
+ * entry here fails `cli/src/provision/bindingPermissions.test.ts`.
11
+ *
12
+ * - `d1`, `secret` and `workflow` name what the `ci-system` base already carries.
13
+ * - `kv`, `r2` and `vectorize` take write alone. Provisioning lists before it creates, and a write scope
14
+ * covers its read, as `OAUTH_SCOPES` records.
15
+ * - `queue` and `artifacts` are reached by the API only, to create or reap; a Worker uses its binding.
16
+ */
17
+ const BINDING_PERMISSIONS = {
18
+ d1: ["d1:read", "d1:write"],
19
+ kv: ["kv:write"],
20
+ r2: ["r2:write"],
21
+ vectorize: ["vectorize:write"],
22
+ queue: ["queues:write"],
23
+ artifacts: ["artifacts:write"],
24
+ secret: ["secrets:read", "secrets:write"],
25
+ workflow: ["workers:write"]
26
+ };
27
+ /**
28
+ * The permission keys every binding the capabilities declare needs, each once, in first-seen order. The
29
+ * adopter's own app is a capability here like any other, so a bucket it declares counts.
30
+ */
31
+ function bindingPermissions(capabilities) {
32
+ const keys = [];
33
+ for (const capability of capabilities) for (const binding of capability.requiredBindings) for (const key of BINDING_PERMISSIONS[binding.type] ?? []) if (!keys.includes(key)) keys.push(key);
34
+ return keys;
35
+ }
36
+ //#endregion
37
+ export { BINDING_PERMISSIONS, bindingPermissions };
@@ -12,8 +12,7 @@ import { type PermissionKey } from "./permissions";
12
12
  * - `dev-vars` — `<config>/<project>/tokens.json`, keyed by environment and outside every checkout.
13
13
  * **Read by an operator, never by a command** — nothing resolves a credential from it. The store for
14
14
  * the `ci-system` token: mint it here, read the value
15
- * out, and set it as CI's `CLOUDFLARE_API_TOKEN`. It wrote `.dev.vars.<env>` *inside* the project until
16
- * #182; the name stays because it is a public `--store` flag value.
15
+ * out, and set it as CI's `CLOUDFLARE_API_TOKEN`. The name is a public `--store` flag value.
17
16
  * - `ephemeral` — nothing is written; the value is used in-process and discarded. The one-step CI
18
17
  * path — CI can't read the CF Secrets Store, so it mints and uses the token in the same job.
19
18
  */
@@ -77,17 +76,16 @@ export declare function permissionsForKeys(keys: PermissionKey[], accountId: str
77
76
  *
78
77
  * This is the second policy on the `ci-system` token, beside the account-scoped one every profile
79
78
  * builds. It exists because `pithy deploy` of an environment with a declared domain calls
80
- * `POST /zones/<zone>/workers/routes`, the one deploy step the CI credential could not make (#651) — and
79
+ * `POST /zones/<zone>/workers/routes`, which no account-scoped group reaches — and
81
80
  * it is a policy of its own rather than a permission on the profile because its resources are zones,
82
81
  * which a profile cannot name: they are resolved from the domains at mint time.
83
82
  *
84
83
  * **This limits the route, not the token.** The account policy beside it carries Workers Scripts Write,
85
- * which attaches a custom domain on any zone the account holds (measured, #709; `docs/TOKENS.md`). Only
84
+ * which attaches a custom domain on any zone the account holds (measured; `docs/TOKENS.md`). Only
86
85
  * the route calls are held to the declared zones.
87
86
  *
88
87
  * **Empty for an empty zone list, deliberately.** A project that declares no domain attaches no route,
89
- * so it mints exactly the token it minted before this existed. No new permission for a project that
90
- * needs none.
88
+ * so it gets no zone permission.
91
89
  */
92
90
  export declare function routePermissions(zoneIds: readonly string[]): TokenPermission[];
93
91
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"profiles.d.ts","sourceRoot":"","sources":["../../src/tokens/profiles.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0CAA0C,CAAC;AAE3E,OAAO,EAAmB,KAAK,eAAe,EAAiB,MAAM,wBAAwB,CAAC;AAC9F,OAAO,EAIL,KAAK,aAAa,EAEnB,MAAM,eAAe,CAAC;AAGvB;;;;;;;;;;;;;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,2GAA2G;IAC3G,YAAY,CAAC,EAAE,UAAU,CAAC;IAC1B,sDAAsD;IACtD,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,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;AAiFD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,SAAS,UAAU,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAMtG;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"}
1
+ {"version":3,"file":"profiles.d.ts","sourceRoot":"","sources":["../../src/tokens/profiles.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0CAA0C,CAAC;AAE3E,OAAO,EAAmB,KAAK,eAAe,EAAiB,MAAM,wBAAwB,CAAC;AAE9F,OAAO,EAIL,KAAK,aAAa,EAEnB,MAAM,eAAe,CAAC;AAGvB;;;;;;;;;;;;GAYG;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,2GAA2G;IAC3G,YAAY,CAAC,EAAE,UAAU,CAAC;IAC1B,sDAAsD;IACtD,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,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;AAsED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,SAAS,UAAU,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAMtG;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;;;;;;;;;;;;;;;;GAgBG;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"}
@@ -4,6 +4,7 @@ import { CloudflareNotConfiguredError } from "../client/errors.js";
4
4
  import { RUNTIME_PROFILE, RUNTIME_TOKEN_SECRET } from "./runtime.js";
5
5
  import { accountResource, zoneResources } from "./accountTokensManager.js";
6
6
  import { PERMISSION_GROUPS, isPermissionKey, isZoneScopedPermission, resolvePermissionKeys } from "./permissions.js";
7
+ import { bindingPermissions } from "./bindingPermissions.js";
7
8
  //#region src/tokens/profiles.ts
8
9
  /** The valid stores, for validating a `--store` flag or a profile's `defaultStore`. */
9
10
  const TOKEN_STORES = [
@@ -47,17 +48,16 @@ function assertPermissionKeys(keys, where) {
47
48
  }
48
49
  /**
49
50
  * Build the aggregating `ci-system` profile: the base permissions ∪ every capability's `ciPermissions` ∪
50
- * `queues:write` where a composed capability binds a queue ∪ `artifacts:write` where one binds Artifacts.
51
- *
52
- * **The queue permission is derived from the binding, not declared (#662).** `pithy provision` creates the queue
53
- * behind every `queue` binding it composes, the adopter's own app's included, and the app has no `ciPermissions`
54
- * anyone would think to write. A permission read off the binding cannot be forgotten by whoever declares it.
51
+ * what every composed binding needs, from `BINDING_PERMISSIONS`. The adopter's own app counts as a
52
+ * capability, so a resource it binds brings its permission with no declaration.
55
53
  */
56
54
  function ciSystemProfile(capabilities) {
57
55
  const permissions = [...CI_SYSTEM_BASE];
58
- for (const capability of capabilities) for (const key of assertPermissionKeys(capability.ciPermissions ?? [], `Capability "${capability.name}"`)) if (!permissions.includes(key)) permissions.push(key);
59
- if (capabilities.some((capability) => capability.requiredBindings.some((binding) => binding.type === "queue")) && !permissions.includes("queues:write")) permissions.push("queues:write");
60
- if (capabilities.some((capability) => capability.requiredBindings.some((binding) => binding.type === "artifacts")) && !permissions.includes("artifacts:write")) permissions.push("artifacts:write");
56
+ const add = (key) => {
57
+ if (!permissions.includes(key)) permissions.push(key);
58
+ };
59
+ for (const capability of capabilities) for (const key of assertPermissionKeys(capability.ciPermissions ?? [], `Capability "${capability.name}"`)) add(key);
60
+ for (const key of bindingPermissions(capabilities)) add(key);
61
61
  return {
62
62
  name: CI_SYSTEM_PROFILE,
63
63
  permissions,
@@ -127,7 +127,7 @@ function resolveProfile(profiles, name, override) {
127
127
  /**
128
128
  * Refuse a zone-scoped key anywhere a policy is being built **account-scoped**.
129
129
  *
130
- * A zone-level permission group handed the account resource is the quiet failure (#651): Cloudflare
130
+ * A zone-level permission group handed the account resource is the quiet failure: Cloudflare
131
131
  * mints the token without complaint and then answers "No access to the specified resource" at the call,
132
132
  * which is a CI failure hours later with nothing in it naming the scope. Which zones a token gets is
133
133
  * never a typed choice — it is the project's declared `domains`, resolved at mint time — so a key naming
@@ -156,17 +156,16 @@ function permissionsForKeys(keys, accountId) {
156
156
  *
157
157
  * This is the second policy on the `ci-system` token, beside the account-scoped one every profile
158
158
  * builds. It exists because `pithy deploy` of an environment with a declared domain calls
159
- * `POST /zones/<zone>/workers/routes`, the one deploy step the CI credential could not make (#651) — and
159
+ * `POST /zones/<zone>/workers/routes`, which no account-scoped group reaches — and
160
160
  * it is a policy of its own rather than a permission on the profile because its resources are zones,
161
161
  * which a profile cannot name: they are resolved from the domains at mint time.
162
162
  *
163
163
  * **This limits the route, not the token.** The account policy beside it carries Workers Scripts Write,
164
- * which attaches a custom domain on any zone the account holds (measured, #709; `docs/TOKENS.md`). Only
164
+ * which attaches a custom domain on any zone the account holds (measured; `docs/TOKENS.md`). Only
165
165
  * the route calls are held to the declared zones.
166
166
  *
167
167
  * **Empty for an empty zone list, deliberately.** A project that declares no domain attaches no route,
168
- * so it mints exactly the token it minted before this existed. No new permission for a project that
169
- * needs none.
168
+ * so it gets no zone permission.
170
169
  */
171
170
  function routePermissions(zoneIds) {
172
171
  if (zoneIds.length === 0) return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pithy-sh/cloudflare",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
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.16.0",
43
+ "@pithy-sh/core": "^0.16.1",
44
44
  "zod": "^4.4.0"
45
45
  },
46
46
  "devDependencies": {
@@ -42,14 +42,21 @@ function mapMeta(restMeta?: QueryResult.Meta): D1Meta & Record<string, unknown>
42
42
  */
43
43
  export const D1_QUERY_REQUEST = { maxRetries: 0 } as const;
44
44
 
45
- /** The REST `params` array is strings; coerce each bound value the way the D1 REST API expects. */
46
- function toStringParams(params: unknown[]): string[] {
47
- return params.map((value) => {
48
- if (value === null || value === undefined) return "";
45
+ /**
46
+ * Encode each bound value for the REST `params` array: a string as itself, a number or boolean as its
47
+ * string, and a null as JSON `null`, which D1 binds as SQL NULL. `""` would bind as text, and a nullable
48
+ * column would hold a value no reader takes for absent. Numbers stay strings because a JSON number binds
49
+ * as `real`, and the column's affinity converts the string.
50
+ */
51
+ function toRestParams(params: unknown[]): string[] {
52
+ const encoded = params.map((value): string | null => {
53
+ if (value === null || value === undefined) return null;
49
54
  if (typeof value === "string") return value;
50
55
  if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
51
56
  return JSON.stringify(value);
52
57
  });
58
+ // The SDK types `params` as strings only; the API takes a null in the array.
59
+ return encoded as string[];
53
60
  }
54
61
 
55
62
  export class D1PreparedStatementREST<T = Record<string, unknown>> implements D1PreparedStatement {
@@ -97,7 +104,7 @@ export class D1PreparedStatementREST<T = Record<string, unknown>> implements D1P
97
104
  async raw<U = unknown[]>(options: { columnNames: true }): Promise<[string[], ...U[]]>;
98
105
  async raw<U = unknown[]>(options?: { columnNames?: false }): Promise<U[]>;
99
106
  async raw<U = unknown[]>(options?: { columnNames?: boolean }): Promise<U[] | [string[], ...U[]]> {
100
- const params = this.boundParams.length > 0 ? toStringParams(this.boundParams) : undefined;
107
+ const params = this.boundParams.length > 0 ? toRestParams(this.boundParams) : undefined;
101
108
  const response = await cloudflareRequest("D1 raw query", () =>
102
109
  this.client.d1.database.raw(
103
110
  this.databaseId,
@@ -115,7 +122,7 @@ export class D1PreparedStatementREST<T = Record<string, unknown>> implements D1P
115
122
  }
116
123
 
117
124
  private async executeQuery<U>(): Promise<D1Result<U>> {
118
- const params = this.boundParams.length > 0 ? toStringParams(this.boundParams) : undefined;
125
+ const params = this.boundParams.length > 0 ? toRestParams(this.boundParams) : undefined;
119
126
  const response = await cloudflareRequest("D1 query", () =>
120
127
  this.client.d1.database.query(
121
128
  this.databaseId,
@@ -0,0 +1,46 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { BindingType } from "@pithy-sh/core/src/capability/bindings";
5
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
6
+ import type { PermissionKey } from "./permissions";
7
+
8
+ /**
9
+ * **What a binding of each kind needs the `ci-system` token to carry**, so that provisioning, deploying and
10
+ * tearing down the resource behind it succeed under that token and under a signed-in session.
11
+ *
12
+ * Read off the binding rather than declared by a capability, because the adopter's own app binds resources
13
+ * too and has no `ciPermissions` anyone would think to write. A kind a provision command creates with no
14
+ * entry here fails `cli/src/provision/bindingPermissions.test.ts`.
15
+ *
16
+ * - `d1`, `secret` and `workflow` name what the `ci-system` base already carries.
17
+ * - `kv`, `r2` and `vectorize` take write alone. Provisioning lists before it creates, and a write scope
18
+ * covers its read, as `OAUTH_SCOPES` records.
19
+ * - `queue` and `artifacts` are reached by the API only, to create or reap; a Worker uses its binding.
20
+ */
21
+ export const BINDING_PERMISSIONS: Readonly<Partial<Record<BindingType, readonly PermissionKey[]>>> = {
22
+ d1: ["d1:read", "d1:write"],
23
+ kv: ["kv:write"],
24
+ r2: ["r2:write"],
25
+ vectorize: ["vectorize:write"],
26
+ queue: ["queues:write"],
27
+ artifacts: ["artifacts:write"],
28
+ secret: ["secrets:read", "secrets:write"],
29
+ workflow: ["workers:write"],
30
+ };
31
+
32
+ /**
33
+ * The permission keys every binding the capabilities declare needs, each once, in first-seen order. The
34
+ * adopter's own app is a capability here like any other, so a bucket it declares counts.
35
+ */
36
+ export function bindingPermissions(capabilities: readonly Capability[]): PermissionKey[] {
37
+ const keys: PermissionKey[] = [];
38
+ for (const capability of capabilities) {
39
+ for (const binding of capability.requiredBindings) {
40
+ for (const key of BINDING_PERMISSIONS[binding.type] ?? []) {
41
+ if (!keys.includes(key)) keys.push(key);
42
+ }
43
+ }
44
+ }
45
+ return keys;
46
+ }
@@ -4,6 +4,7 @@
4
4
  import type { Capability } from "@pithy-sh/core/src/capability/capability";
5
5
  import { CloudflareNotConfiguredError } from "../client/errors";
6
6
  import { accountResource, type TokenPermission, zoneResources } from "./accountTokensManager";
7
+ import { bindingPermissions } from "./bindingPermissions";
7
8
  import {
8
9
  isPermissionKey,
9
10
  isZoneScopedPermission,
@@ -22,8 +23,7 @@ import { RUNTIME_PROFILE, RUNTIME_TOKEN_SECRET } from "./runtime";
22
23
  * - `dev-vars` — `<config>/<project>/tokens.json`, keyed by environment and outside every checkout.
23
24
  * **Read by an operator, never by a command** — nothing resolves a credential from it. The store for
24
25
  * the `ci-system` token: mint it here, read the value
25
- * out, and set it as CI's `CLOUDFLARE_API_TOKEN`. It wrote `.dev.vars.<env>` *inside* the project until
26
- * #182; the name stays because it is a public `--store` flag value.
26
+ * out, and set it as CI's `CLOUDFLARE_API_TOKEN`. The name is a public `--store` flag value.
27
27
  * - `ephemeral` — nothing is written; the value is used in-process and discarded. The one-step CI
28
28
  * path — CI can't read the CF Secrets Store, so it mints and uses the token in the same job.
29
29
  */
@@ -108,29 +108,18 @@ function assertPermissionKeys(keys: readonly string[], where: string): Permissio
108
108
 
109
109
  /**
110
110
  * Build the aggregating `ci-system` profile: the base permissions ∪ every capability's `ciPermissions` ∪
111
- * `queues:write` where a composed capability binds a queue ∪ `artifacts:write` where one binds Artifacts.
112
- *
113
- * **The queue permission is derived from the binding, not declared (#662).** `pithy provision` creates the queue
114
- * behind every `queue` binding it composes, the adopter's own app's included, and the app has no `ciPermissions`
115
- * anyone would think to write. A permission read off the binding cannot be forgotten by whoever declares it.
111
+ * what every composed binding needs, from `BINDING_PERMISSIONS`. The adopter's own app counts as a
112
+ * capability, so a resource it binds brings its permission with no declaration.
116
113
  */
117
114
  function ciSystemProfile(capabilities: readonly Capability[]): TokenProfile {
118
115
  const permissions: PermissionKey[] = [...CI_SYSTEM_BASE];
116
+ const add = (key: PermissionKey) => {
117
+ if (!permissions.includes(key)) permissions.push(key);
118
+ };
119
119
  for (const capability of capabilities) {
120
- for (const key of assertPermissionKeys(capability.ciPermissions ?? [], `Capability "${capability.name}"`)) {
121
- if (!permissions.includes(key)) permissions.push(key);
122
- }
120
+ for (const key of assertPermissionKeys(capability.ciPermissions ?? [], `Capability "${capability.name}"`)) add(key);
123
121
  }
124
- const bindsQueue = capabilities.some((capability) =>
125
- capability.requiredBindings.some((binding) => binding.type === "queue"),
126
- );
127
- if (bindsQueue && !permissions.includes("queues:write")) permissions.push("queues:write");
128
- // **And `artifacts:write` where one binds Artifacts (#673)**, for the same reason: `pithy feature destroy` reaps
129
- // every repo in the feature's namespace under this token, and the app that binds it declares no `ciPermissions`.
130
- const bindsArtifacts = capabilities.some((capability) =>
131
- capability.requiredBindings.some((binding) => binding.type === "artifacts"),
132
- );
133
- if (bindsArtifacts && !permissions.includes("artifacts:write")) permissions.push("artifacts:write");
122
+ for (const key of bindingPermissions(capabilities)) add(key);
134
123
  return {
135
124
  name: CI_SYSTEM_PROFILE,
136
125
  permissions,
@@ -214,7 +203,7 @@ export function resolveProfile(
214
203
  /**
215
204
  * Refuse a zone-scoped key anywhere a policy is being built **account-scoped**.
216
205
  *
217
- * A zone-level permission group handed the account resource is the quiet failure (#651): Cloudflare
206
+ * A zone-level permission group handed the account resource is the quiet failure: Cloudflare
218
207
  * mints the token without complaint and then answers "No access to the specified resource" at the call,
219
208
  * which is a CI failure hours later with nothing in it naming the scope. Which zones a token gets is
220
209
  * never a typed choice — it is the project's declared `domains`, resolved at mint time — so a key naming
@@ -243,17 +232,16 @@ export function permissionsForKeys(keys: PermissionKey[], accountId: string): To
243
232
  *
244
233
  * This is the second policy on the `ci-system` token, beside the account-scoped one every profile
245
234
  * builds. It exists because `pithy deploy` of an environment with a declared domain calls
246
- * `POST /zones/<zone>/workers/routes`, the one deploy step the CI credential could not make (#651) — and
235
+ * `POST /zones/<zone>/workers/routes`, which no account-scoped group reaches — and
247
236
  * it is a policy of its own rather than a permission on the profile because its resources are zones,
248
237
  * which a profile cannot name: they are resolved from the domains at mint time.
249
238
  *
250
239
  * **This limits the route, not the token.** The account policy beside it carries Workers Scripts Write,
251
- * which attaches a custom domain on any zone the account holds (measured, #709; `docs/TOKENS.md`). Only
240
+ * which attaches a custom domain on any zone the account holds (measured; `docs/TOKENS.md`). Only
252
241
  * the route calls are held to the declared zones.
253
242
  *
254
243
  * **Empty for an empty zone list, deliberately.** A project that declares no domain attaches no route,
255
- * so it mints exactly the token it minted before this existed. No new permission for a project that
256
- * needs none.
244
+ * so it gets no zone permission.
257
245
  */
258
246
  export function routePermissions(zoneIds: readonly string[]): TokenPermission[] {
259
247
  if (zoneIds.length === 0) return [];