@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.
- package/dist/d1/d1PreparedStatement.d.ts.map +1 -1
- package/dist/d1/d1PreparedStatement.js +10 -5
- package/dist/tokens/bindingPermissions.d.ts +25 -0
- package/dist/tokens/bindingPermissions.d.ts.map +1 -0
- package/dist/tokens/bindingPermissions.js +37 -0
- package/dist/tokens/profiles.d.ts +4 -6
- package/dist/tokens/profiles.d.ts.map +1 -1
- package/dist/tokens/profiles.js +12 -13
- package/package.json +2 -2
- package/src/d1/d1PreparedStatement.ts +13 -6
- package/src/tokens/bindingPermissions.ts +46 -0
- package/src/tokens/profiles.ts +13 -25
|
@@ -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;
|
|
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
|
-
/**
|
|
40
|
-
|
|
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 ?
|
|
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 ?
|
|
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`.
|
|
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`,
|
|
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
|
|
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
|
|
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;
|
|
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"}
|
package/dist/tokens/profiles.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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`,
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 ?
|
|
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 ?
|
|
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
|
+
}
|
package/src/tokens/profiles.ts
CHANGED
|
@@ -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`.
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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`,
|
|
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
|
|
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
|
|
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 [];
|