@pithy-sh/cloudflare 0.8.1 → 0.9.1
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/analytics/analyticsManager.d.ts +3 -3
- package/dist/analytics/analyticsManager.d.ts.map +1 -1
- package/dist/analytics/analyticsManager.js +17 -11
- package/dist/analytics/forbidden.d.ts +50 -0
- package/dist/analytics/forbidden.d.ts.map +1 -0
- package/dist/analytics/forbidden.js +66 -0
- package/dist/client/errors.d.ts +14 -18
- package/dist/client/errors.d.ts.map +1 -1
- package/dist/client/errors.js +24 -28
- 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 +13 -14
- package/package.json +2 -2
- package/src/analytics/analyticsManager.ts +16 -8
- package/src/analytics/forbidden.ts +99 -0
- package/src/client/errors.ts +26 -30
- package/src/tokens/bindingPermissions.ts +46 -0
- package/src/tokens/profiles.ts +13 -25
|
@@ -4,14 +4,14 @@ import { z } from "zod";
|
|
|
4
4
|
import { CloudflareManager } from "../client/manager";
|
|
5
5
|
import { type AnalyticsDataset } from "./datasets";
|
|
6
6
|
/**
|
|
7
|
-
* **Cloudflare's GraphQL Analytics API, for the account-level datasets the kit reads
|
|
7
|
+
* **Cloudflare's GraphQL Analytics API, for the account-level datasets the kit reads.**
|
|
8
8
|
*
|
|
9
9
|
* One endpoint, `POST /graphql`, reached through the SDK's raw `post` like every other call the typed SDK
|
|
10
10
|
* does not cover — never a hand-rolled `fetch` (CLAUDE.md §Cloudflare access). It answers HTTP 200 with
|
|
11
11
|
* `{ data, errors }` even when it refuses, so `errors` is read on every answer and a non-empty one is a
|
|
12
12
|
* refusal in Cloudflare's own words.
|
|
13
13
|
*
|
|
14
|
-
* The token it runs on is the application's one `runtime` token, carrying
|
|
14
|
+
* The token it runs on is the application's one `runtime` token, carrying {@link ANALYTICS_PERMISSION}, whose grain is
|
|
15
15
|
* the whole account. This manager narrows nothing on its own: every query takes the keys it is about, and
|
|
16
16
|
* the caller decides which keys those are.
|
|
17
17
|
*/
|
|
@@ -68,7 +68,7 @@ export declare class CloudflareAnalyticsManager extends CloudflareManager {
|
|
|
68
68
|
imageIds: readonly string[];
|
|
69
69
|
}): Promise<Map<string, number>>;
|
|
70
70
|
/**
|
|
71
|
-
* Turnstile events per sitekey and event type inside the window, for the sitekeys given
|
|
71
|
+
* Turnstile events per sitekey and event type inside the window, for the sitekeys given. The sitekey
|
|
72
72
|
* filter is what scopes the account-wide dataset to the widgets the caller is about. Absent means none.
|
|
73
73
|
*/
|
|
74
74
|
turnstileEvents(window: WindowQuery & {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"analyticsManager.d.ts","sourceRoot":"","sources":["../../src/analytics/analyticsManager.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,EAAE,KAAK,gBAAgB,EAAoD,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"analyticsManager.d.ts","sourceRoot":"","sources":["../../src/analytics/analyticsManager.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,EAAE,KAAK,gBAAgB,EAAoD,MAAM,YAAY,CAAC;AAGrG;;;;;;;;;;;GAWG;AAEH;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,OAAO,CAAC;AAEhD;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,MAAM,CAAC;AAEvC,sGAAsG;AACtG,eAAO,MAAM,aAAa;;;;;iBAOiF,CAAC;AAC5G,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAgE1D,4CAA4C;AAC5C,MAAM,WAAW,WAAW;IAC1B,qCAAqC;IACrC,KAAK,EAAE,IAAI,CAAC;IACZ,mCAAmC;IACnC,KAAK,EAAE,IAAI,CAAC;CACb;AASD,6DAA6D;AAC7D,qBAAa,0BAA2B,SAAQ,iBAAiB;IAC/D,cAAc,IAAI,MAAM,CAEvB;IAED,iFAAiF;IAC3E,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED;;;OAGG;YACW,KAAK;IA6BnB,mFAAmF;IACnF,OAAO,CAAC,WAAW;IAWnB;;;OAGG;IACG,aAAa,CACjB,QAAQ,EAAE,SAAS,gBAAgB,EAAE,GACpC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,gBAAgB,EAAE,aAAa,CAAC,CAAC,CAAC,CA2B3D;IAED,0GAA0G;IACpG,mBAAmB,CAAC,MAAM,EAAE,WAAW,GAAG;QAAE,IAAI,EAAE,SAAS,MAAM,EAAE,CAAA;KAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAuBzG;IAED,qGAAqG;IAC/F,aAAa,CAAC,MAAM,EAAE,WAAW,GAAG;QAAE,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;KAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CA2BvG;IAED;;;OAGG;IACG,eAAe,CACnB,MAAM,EAAE,WAAW,GAAG;QAAE,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;KAAE,GACpD,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAmC3C;CACF"}
|
|
@@ -3,17 +3,18 @@
|
|
|
3
3
|
import { CloudflareInvalidResponseError, cloudflareRefusal, cloudflareRequest, decodeResponse } from "../client/errors.js";
|
|
4
4
|
import { CloudflareManager } from "../client/manager.js";
|
|
5
5
|
import { IMAGE_REQUESTS, STREAM_MINUTES, TURNSTILE_EVENTS } from "./datasets.js";
|
|
6
|
+
import { ANALYTICS_PERMISSION, GRAPHQL_AUTH_CODES } from "./forbidden.js";
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
//#region src/analytics/analyticsManager.ts
|
|
8
9
|
/**
|
|
9
|
-
* **Cloudflare's GraphQL Analytics API, for the account-level datasets the kit reads
|
|
10
|
+
* **Cloudflare's GraphQL Analytics API, for the account-level datasets the kit reads.**
|
|
10
11
|
*
|
|
11
12
|
* One endpoint, `POST /graphql`, reached through the SDK's raw `post` like every other call the typed SDK
|
|
12
13
|
* does not cover — never a hand-rolled `fetch` (CLAUDE.md §Cloudflare access). It answers HTTP 200 with
|
|
13
14
|
* `{ data, errors }` even when it refuses, so `errors` is read on every answer and a non-empty one is a
|
|
14
15
|
* refusal in Cloudflare's own words.
|
|
15
16
|
*
|
|
16
|
-
* The token it runs on is the application's one `runtime` token, carrying
|
|
17
|
+
* The token it runs on is the application's one `runtime` token, carrying {@link ANALYTICS_PERMISSION}, whose grain is
|
|
17
18
|
* the whole account. This manager narrows nothing on its own: every query takes the keys it is about, and
|
|
18
19
|
* the caller decides which keys those are.
|
|
19
20
|
*/
|
|
@@ -95,16 +96,21 @@ var CloudflareAnalyticsManager = class extends CloudflareManager {
|
|
|
95
96
|
const raw = await cloudflareRequest(operation, () => this.getClient().post("/graphql", { body: {
|
|
96
97
|
query,
|
|
97
98
|
variables
|
|
98
|
-
} }), { permission:
|
|
99
|
+
} }), { permission: ANALYTICS_PERMISSION });
|
|
99
100
|
const answer = decodeResponse(GraphQLAnswer, raw, operation);
|
|
100
101
|
const errors = answer.errors ?? [];
|
|
101
|
-
if (errors.length > 0)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
102
|
+
if (errors.length > 0) {
|
|
103
|
+
const codes = errors.map((error) => error.extensions?.code);
|
|
104
|
+
const status = codes.some((code) => code === RATE_BUDGET_CODE) ? 429 : codes.some((code) => code !== void 0 && GRAPHQL_AUTH_CODES.has(code)) ? 403 : void 0;
|
|
105
|
+
throw cloudflareRefusal({
|
|
106
|
+
problem: `Cloudflare request failed: ${operation}.`,
|
|
107
|
+
apiErrors: errors.map((error) => ({ ...error.message === void 0 ? {} : { message: error.message } })),
|
|
108
|
+
permission: ANALYTICS_PERMISSION,
|
|
109
|
+
...status === void 0 ? {} : { status },
|
|
110
|
+
detail: JSON.stringify(errors),
|
|
111
|
+
cause: errors
|
|
112
|
+
});
|
|
113
|
+
}
|
|
108
114
|
return answer.data;
|
|
109
115
|
}
|
|
110
116
|
/** The one account in an answer, refused when the token's viewer cannot see it. */
|
|
@@ -172,7 +178,7 @@ var CloudflareAnalyticsManager = class extends CloudflareManager {
|
|
|
172
178
|
return totals;
|
|
173
179
|
}
|
|
174
180
|
/**
|
|
175
|
-
* Turnstile events per sitekey and event type inside the window, for the sitekeys given
|
|
181
|
+
* Turnstile events per sitekey and event type inside the window, for the sitekeys given. The sitekey
|
|
176
182
|
* filter is what scopes the account-wide dataset to the widgets the caller is about. Absent means none.
|
|
177
183
|
*/
|
|
178
184
|
async turnstileEvents(window) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
4
|
+
import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
|
|
5
|
+
/**
|
|
6
|
+
* **An analytics read the Worker's own token may not make, as each caller's own code.**
|
|
7
|
+
*
|
|
8
|
+
* Every capability that reads Cloudflare's GraphQL Analytics does it on the one `runtime` token, which needs
|
|
9
|
+
* {@link ANALYTICS_PERMISSION}. A refusal for want of it is `cloudflare/request_failed` like any other, and
|
|
10
|
+
* that code names no permission a client can read. So a caller wraps its read in {@link guardForbidden},
|
|
11
|
+
* which hands a missing grant to the caller's own error class with the permission in `params` and the
|
|
12
|
+
* remedy in `message`, and lets every other failure through unchanged. No SDK is imported here, so a
|
|
13
|
+
* caller that loads the manager lazily keeps doing so.
|
|
14
|
+
*/
|
|
15
|
+
/** The account permission group every analytics read needs, as Cloudflare's token editor names it. */
|
|
16
|
+
export declare const ANALYTICS_PERMISSION = "Account Analytics Read";
|
|
17
|
+
/** The GraphQL `extensions.code` that means the token may not read the account it asked about. */
|
|
18
|
+
export declare const GRAPHQL_AUTH_CODES: ReadonlySet<string>;
|
|
19
|
+
/** A missing grant, in the fields a caller's error class takes. */
|
|
20
|
+
export interface ForbiddenRefusal {
|
|
21
|
+
/** The sentence the caller reads: the permission, and the rotate that adds it. */
|
|
22
|
+
message: string;
|
|
23
|
+
/** `permission`, for a client that translates. */
|
|
24
|
+
params: MessageParams;
|
|
25
|
+
/** The operator's line from the Cloudflare refusal, kept for the terminal and the log. */
|
|
26
|
+
action?: string;
|
|
27
|
+
/** The Cloudflare refusal's detail, kept for the log. */
|
|
28
|
+
detail?: string;
|
|
29
|
+
}
|
|
30
|
+
/** Builds the caller's own error for a missing grant. */
|
|
31
|
+
export type RefuseForbidden = (refusal: ForbiddenRefusal, options: {
|
|
32
|
+
cause: unknown;
|
|
33
|
+
}) => Error;
|
|
34
|
+
/** Where the read ran and what to call it, for the sentence. */
|
|
35
|
+
export interface ForbiddenContext {
|
|
36
|
+
/** The environment this Worker serves, or null when it names none. */
|
|
37
|
+
environment: string | null;
|
|
38
|
+
/** The read as the sentence names it: "the delivery analytics read". */
|
|
39
|
+
read: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Whether a failure is an analytics query refused because the credentials did not get in: a 401 or 403, or
|
|
43
|
+
* Cloudflare's auth-class code, or GraphQL's `authz`. A spent rate budget, a 5xx and a rejected query are not.
|
|
44
|
+
*/
|
|
45
|
+
export declare function isAnalyticsForbidden(error: unknown): boolean;
|
|
46
|
+
/** A missing grant on `error`, as the fields of the caller's own error. */
|
|
47
|
+
export declare function forbiddenRefusal(error: PithyError, context: ForbiddenContext): ForbiddenRefusal;
|
|
48
|
+
/** Run an analytics read, refusing a missing grant with the caller's own error and passing every other failure on. */
|
|
49
|
+
export declare function guardForbidden<T>(read: () => Promise<T>, context: ForbiddenContext, refuse: RefuseForbidden): Promise<T>;
|
|
50
|
+
//# sourceMappingURL=forbidden.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"forbidden.d.ts","sourceRoot":"","sources":["../../src/analytics/forbidden.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,UAAU,EAAE,MAAM,qCAAqC,CAAC;AACjE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AAIrE;;;;;;;;;GASG;AAEH,sGAAsG;AACtG,eAAO,MAAM,oBAAoB,2BAA2B,CAAC;AAE7D,kGAAkG;AAClG,eAAO,MAAM,kBAAkB,EAAE,WAAW,CAAC,MAAM,CAAsB,CAAC;AAE1E,mEAAmE;AACnE,MAAM,WAAW,gBAAgB;IAC/B,kFAAkF;IAClF,OAAO,EAAE,MAAM,CAAC;IAChB,kDAAkD;IAClD,MAAM,EAAE,aAAa,CAAC;IACtB,0FAA0F;IAC1F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yDAAyD;IACzD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,yDAAyD;AACzD,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,EAAE,gBAAgB,EAAE,OAAO,EAAE;IAAE,KAAK,EAAE,OAAO,CAAA;CAAE,KAAK,KAAK,CAAC;AAEhG,gEAAgE;AAChE,MAAM,WAAW,gBAAgB;IAC/B,sEAAsE;IACtE,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;CACd;AAWD;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAG5D;AAWD,2EAA2E;AAC3E,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,gBAAgB,GAAG,gBAAgB,CAO/F;AAED,sHAAsH;AACtH,wBAAsB,cAAc,CAAC,CAAC,EACpC,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACtB,OAAO,EAAE,gBAAgB,EACzB,MAAM,EAAE,eAAe,GACtB,OAAO,CAAC,CAAC,CAAC,CAOZ"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { isAuthClassFailure } from "../client/errors.js";
|
|
4
|
+
import { RUNTIME_PROFILE } from "../tokens/runtime.js";
|
|
5
|
+
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import { commandName, remedy } from "@pithy-sh/core/src/cli/command";
|
|
7
|
+
//#region src/analytics/forbidden.ts
|
|
8
|
+
/**
|
|
9
|
+
* **An analytics read the Worker's own token may not make, as each caller's own code.**
|
|
10
|
+
*
|
|
11
|
+
* Every capability that reads Cloudflare's GraphQL Analytics does it on the one `runtime` token, which needs
|
|
12
|
+
* {@link ANALYTICS_PERMISSION}. A refusal for want of it is `cloudflare/request_failed` like any other, and
|
|
13
|
+
* that code names no permission a client can read. So a caller wraps its read in {@link guardForbidden},
|
|
14
|
+
* which hands a missing grant to the caller's own error class with the permission in `params` and the
|
|
15
|
+
* remedy in `message`, and lets every other failure through unchanged. No SDK is imported here, so a
|
|
16
|
+
* caller that loads the manager lazily keeps doing so.
|
|
17
|
+
*/
|
|
18
|
+
/** The account permission group every analytics read needs, as Cloudflare's token editor names it. */
|
|
19
|
+
const ANALYTICS_PERMISSION = "Account Analytics Read";
|
|
20
|
+
/** The GraphQL `extensions.code` that means the token may not read the account it asked about. */
|
|
21
|
+
const GRAPHQL_AUTH_CODES = /* @__PURE__ */ new Set(["authz"]);
|
|
22
|
+
/** Whether a GraphQL `errors` array, kept as a refusal's cause, says the token may not read the account. */
|
|
23
|
+
function isGraphQLAuthRefusal(cause) {
|
|
24
|
+
if (!Array.isArray(cause)) return false;
|
|
25
|
+
return cause.some((entry) => {
|
|
26
|
+
const code = entry?.extensions?.code;
|
|
27
|
+
return typeof code === "string" && GRAPHQL_AUTH_CODES.has(code);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Whether a failure is an analytics query refused because the credentials did not get in: a 401 or 403, or
|
|
32
|
+
* Cloudflare's auth-class code, or GraphQL's `authz`. A spent rate budget, a 5xx and a rejected query are not.
|
|
33
|
+
*/
|
|
34
|
+
function isAnalyticsForbidden(error) {
|
|
35
|
+
if (!(error instanceof PithyError) || error.payload.code !== "cloudflare/request_failed") return false;
|
|
36
|
+
return isAuthClassFailure(error.cause) || isGraphQLAuthRefusal(error.cause);
|
|
37
|
+
}
|
|
38
|
+
/** The remedy a caller reads: rotate re-scopes the runtime token, where a mint only rolls its value. */
|
|
39
|
+
function rotateSentence(environment) {
|
|
40
|
+
const tail = "to re-scope it; a mint only rolls the value.";
|
|
41
|
+
if (environment === null) return `Rotate it with \`${commandName("token rotate")}\` ${tail}`;
|
|
42
|
+
return remedy("token rotate", {
|
|
43
|
+
args: [RUNTIME_PROFILE],
|
|
44
|
+
flags: { "--env": environment }
|
|
45
|
+
}, { runtime: true }).say((command) => `Run \`${command}\` ${tail}`);
|
|
46
|
+
}
|
|
47
|
+
/** A missing grant on `error`, as the fields of the caller's own error. */
|
|
48
|
+
function forbiddenRefusal(error, context) {
|
|
49
|
+
return {
|
|
50
|
+
message: `Cloudflare refused ${context.read}: this Worker's ${RUNTIME_PROFILE} token lacks ${ANALYTICS_PERMISSION}. ${rotateSentence(context.environment)}`,
|
|
51
|
+
params: { permission: ANALYTICS_PERMISSION },
|
|
52
|
+
...error.payload.action === void 0 ? {} : { action: error.payload.action },
|
|
53
|
+
...error.payload.detail === void 0 ? {} : { detail: error.payload.detail }
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Run an analytics read, refusing a missing grant with the caller's own error and passing every other failure on. */
|
|
57
|
+
async function guardForbidden(read, context, refuse) {
|
|
58
|
+
try {
|
|
59
|
+
return await read();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (!(error instanceof PithyError) || !isAnalyticsForbidden(error)) throw error;
|
|
62
|
+
throw refuse(forbiddenRefusal(error, context), { cause: error });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
export { ANALYTICS_PERMISSION, GRAPHQL_AUTH_CODES, forbiddenRefusal, guardForbidden, isAnalyticsForbidden };
|
package/dist/client/errors.d.ts
CHANGED
|
@@ -56,9 +56,8 @@ export declare class CloudflareInvalidResponseError extends PithyError {
|
|
|
56
56
|
* One entry of Cloudflare's `errors[]` array — the API's own answer to a failed call.
|
|
57
57
|
*
|
|
58
58
|
* Every v4 endpoint answers a failure with `{ success: false, errors: [{ code, message,
|
|
59
|
-
* documentation_url }] }`, and the SDK
|
|
60
|
-
*
|
|
61
|
-
* grant, a revoked token, a wrong account and an outage all rendered as the same sentence.
|
|
59
|
+
* documentation_url }] }`, and the SDK parses it onto the thrown `APIError`. It is what tells a token
|
|
60
|
+
* missing one product's grant, a revoked token, a wrong account and an outage apart.
|
|
62
61
|
*/
|
|
63
62
|
export interface CloudflareApiError {
|
|
64
63
|
/** Cloudflare's numeric error code — the stable discriminator (`10000` is its auth-class code). */
|
|
@@ -96,13 +95,9 @@ export declare function cloudflareRequest<T>(operation: string, fn: () => Promis
|
|
|
96
95
|
* Compose the refusal every failed Cloudflare call renders: the problem line, then the API's own
|
|
97
96
|
* answer, then — only for an auth-class code — what to do about it.
|
|
98
97
|
*
|
|
99
|
-
* One place, so a fix reaches every provisioner at once
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* direct upload and two integration helpers were composing their own, so `pithy token mint` — the
|
|
103
|
-
* bootstrap command an under-scoped token meets first — still printed the operation and nothing else
|
|
104
|
-
* on the identical 401 body. `refusalSites.test.ts` now fails any `new CloudflareRequestError(`
|
|
105
|
-
* outside this file, so "the only place" is enforced rather than remembered.
|
|
98
|
+
* One place, so a fix reaches every provisioner at once. `refusalSites.test.ts` fails any
|
|
99
|
+
* `new CloudflareRequestError(` outside this file, so "the only place" is enforced rather than
|
|
100
|
+
* remembered.
|
|
106
101
|
*
|
|
107
102
|
* CLAUDE.md §Errors decides where each string lands. Cloudflare's sentence and its docs link are the
|
|
108
103
|
* **caller's** information — what happened, in the words of whoever it happened to — so they go in
|
|
@@ -118,14 +113,10 @@ export declare function cloudflareRequest<T>(operation: string, fn: () => Promis
|
|
|
118
113
|
* for, and pinned by `core`'s payload test. The words are composed the same way either side of that
|
|
119
114
|
* branch, so only the two machine-readable fields differ.
|
|
120
115
|
*
|
|
121
|
-
* **The message is one line,
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* from the remedy, so a multi-line `message` was declined whole at the reader and the operator got
|
|
126
|
-
* `core/workflow_failed` 500 about durable execution instead of the API answer this issue exists to
|
|
127
|
-
* show them. The same newline is what the CLI's action line is, so an upstream sentence containing one
|
|
128
|
-
* forges a remedy Pithy never wrote. `Cloudflare said:` marks the boundary between our words and
|
|
116
|
+
* **The message is one line, a kit-wide rule.** A `PithyError` raised inside a `classifiedSteps` step
|
|
117
|
+
* crosses a durable boundary as one string whose newline separates the sentence from the remedy, so a
|
|
118
|
+
* multi-line `message` is declined whole at the reader. The same newline is what the CLI's action line
|
|
119
|
+
* is, so an upstream sentence containing one would forge a remedy Pithy never wrote. `Cloudflare said:` marks the boundary between our words and
|
|
129
120
|
* theirs with a word instead of an indent, and a word survives a step record, a `--json` line and a
|
|
130
121
|
* browser.
|
|
131
122
|
*/
|
|
@@ -183,6 +174,11 @@ export declare function cloudflareSaid(problem: string, apiErrors: CloudflareApi
|
|
|
183
174
|
* one somebody clicks.
|
|
184
175
|
*/
|
|
185
176
|
export declare function cloudflareApiErrors(source: unknown): CloudflareApiError[];
|
|
177
|
+
/**
|
|
178
|
+
* Whether a thrown SDK error is the credentials failing rather than the request — the same judgment the
|
|
179
|
+
* refusal's action line makes, read off the throw a {@link cloudflareRefusal} keeps as its `cause`.
|
|
180
|
+
*/
|
|
181
|
+
export declare function isAuthClassFailure(error: unknown): boolean;
|
|
186
182
|
/**
|
|
187
183
|
* The HTTP status of a thrown SDK error, when it carries one. Exported because a call site that
|
|
188
184
|
* composes its own refusal through {@link cloudflareRefusal} has to hand it the same status this
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/client/errors.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,UAAU,EAAuC,MAAM,qCAAqC,CAAC;AACtG,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AACrE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kCAAkC,CAAC;AAEvE,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAE7B;;;;;;GAMG;AAEH,uFAAuF;AACvF,UAAU,mBAAmB;IAC3B,mDAAmD;IACnD,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,4CAA4C;IAC5C,MAAM,CAAC,EAAE,cAAc,CAAC;IACxB,sEAAsE;IACtE,MAAM,CAAC,EAAE,cAAc,CAAC;IACxB;;;OAGG;IACH,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB;AAED,qFAAqF;AACrF,qBAAa,4BAA6B,SAAQ,UAAU;IAC1D,YAAY,IAAI,GAAE,mBAAwB,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,EAYxE;CACF;AAED;;;;;;;;;GASG;AACH,qBAAa,sBAAuB,SAAQ,UAAU;IACpD,YAAY,IAAI,GAAE,mBAAwB,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,EAYxE;CACF;AAED,+FAA+F;AAC/F,qBAAa,8BAA+B,SAAQ,UAAU;IAC5D,YAAY,IAAI,GAAE,mBAAwB,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,EAYxE;CACF;AAED
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/client/errors.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,UAAU,EAAuC,MAAM,qCAAqC,CAAC;AACtG,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AACrE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kCAAkC,CAAC;AAEvE,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAE7B;;;;;;GAMG;AAEH,uFAAuF;AACvF,UAAU,mBAAmB;IAC3B,mDAAmD;IACnD,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,4CAA4C;IAC5C,MAAM,CAAC,EAAE,cAAc,CAAC;IACxB,sEAAsE;IACtE,MAAM,CAAC,EAAE,cAAc,CAAC;IACxB;;;OAGG;IACH,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB;AAED,qFAAqF;AACrF,qBAAa,4BAA6B,SAAQ,UAAU;IAC1D,YAAY,IAAI,GAAE,mBAAwB,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,EAYxE;CACF;AAED;;;;;;;;;GASG;AACH,qBAAa,sBAAuB,SAAQ,UAAU;IACpD,YAAY,IAAI,GAAE,mBAAwB,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,EAYxE;CACF;AAED,+FAA+F;AAC/F,qBAAa,8BAA+B,SAAQ,UAAU;IAC5D,YAAY,IAAI,GAAE,mBAAwB,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,EAYxE;CACF;AAED;;;;;;GAMG;AACH,MAAM,WAAW,kBAAkB;IACjC,mGAAmG;IACnG,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0EAA0E;IAC1E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8EAA8E;IAC9E,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AA0FD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,iBAAiB,CAAC,CAAC,EACvC,SAAS,EAAE,MAAM,EACjB,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACpB,OAAO,CAAC,EAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,GAChC,OAAO,CAAC,CAAC,CAAC,CAyBZ;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IACtC,oEAAoE;IACpE,OAAO,EAAE,MAAM,CAAC;IAChB,wEAAwE;IACxE,SAAS,EAAE,kBAAkB,EAAE,CAAC;IAChC,iGAAiG;IACjG,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sDAAsD;IACtD,MAAM,EAAE,MAAM,CAAC;IACf,0BAA0B;IAC1B,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,GAAG,UAAU,CAeb;AA8DD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,kBAAkB,EAAE,GAC9B;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,aAAa,CAAA;CAAE,CAuB5C;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,OAAO,GAAG,kBAAkB,EAAE,CAQzE;AA4FD;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE1D;AAgGD;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAI3D;AAED,kGAAkG;AAClG,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEhD;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAG/C;AAED,kGAAkG;AAClG,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEvD;AAED;;;;;;;;;GASG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE5D;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,CAAC,SAAS,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CASzG"}
|
package/dist/client/errors.js
CHANGED
|
@@ -104,10 +104,9 @@ function oneLine(text) {
|
|
|
104
104
|
* Cloudflare's auth-class codes — the ones that mean "your credentials did not get you in", as
|
|
105
105
|
* opposed to a missing resource (`12000`), a conflict (`12042`) or a rejected argument (`10202`).
|
|
106
106
|
*
|
|
107
|
-
* Status alone is not the discriminator
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* code is what identifies the class and the status is only a fallback for a body-less throw.
|
|
107
|
+
* Status alone is not the discriminator: Cloudflare returns `10000` under 400, 401 and 403 depending
|
|
108
|
+
* on the endpoint, so the code is what identifies the class and the status is only a fallback for a
|
|
109
|
+
* body-less throw.
|
|
111
110
|
*/
|
|
112
111
|
const AUTH_CLASS_CODES = /* @__PURE__ */ new Set([
|
|
113
112
|
9103,
|
|
@@ -188,13 +187,9 @@ async function cloudflareRequest(operation, fn, options) {
|
|
|
188
187
|
* Compose the refusal every failed Cloudflare call renders: the problem line, then the API's own
|
|
189
188
|
* answer, then — only for an auth-class code — what to do about it.
|
|
190
189
|
*
|
|
191
|
-
* One place, so a fix reaches every provisioner at once
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
* direct upload and two integration helpers were composing their own, so `pithy token mint` — the
|
|
195
|
-
* bootstrap command an under-scoped token meets first — still printed the operation and nothing else
|
|
196
|
-
* on the identical 401 body. `refusalSites.test.ts` now fails any `new CloudflareRequestError(`
|
|
197
|
-
* outside this file, so "the only place" is enforced rather than remembered.
|
|
190
|
+
* One place, so a fix reaches every provisioner at once. `refusalSites.test.ts` fails any
|
|
191
|
+
* `new CloudflareRequestError(` outside this file, so "the only place" is enforced rather than
|
|
192
|
+
* remembered.
|
|
198
193
|
*
|
|
199
194
|
* CLAUDE.md §Errors decides where each string lands. Cloudflare's sentence and its docs link are the
|
|
200
195
|
* **caller's** information — what happened, in the words of whoever it happened to — so they go in
|
|
@@ -210,14 +205,10 @@ async function cloudflareRequest(operation, fn, options) {
|
|
|
210
205
|
* for, and pinned by `core`'s payload test. The words are composed the same way either side of that
|
|
211
206
|
* branch, so only the two machine-readable fields differ.
|
|
212
207
|
*
|
|
213
|
-
* **The message is one line,
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* from the remedy, so a multi-line `message` was declined whole at the reader and the operator got
|
|
218
|
-
* `core/workflow_failed` 500 about durable execution instead of the API answer this issue exists to
|
|
219
|
-
* show them. The same newline is what the CLI's action line is, so an upstream sentence containing one
|
|
220
|
-
* forges a remedy Pithy never wrote. `Cloudflare said:` marks the boundary between our words and
|
|
208
|
+
* **The message is one line, a kit-wide rule.** A `PithyError` raised inside a `classifiedSteps` step
|
|
209
|
+
* crosses a durable boundary as one string whose newline separates the sentence from the remedy, so a
|
|
210
|
+
* multi-line `message` is declined whole at the reader. The same newline is what the CLI's action line
|
|
211
|
+
* is, so an upstream sentence containing one would forge a remedy Pithy never wrote. `Cloudflare said:` marks the boundary between our words and
|
|
221
212
|
* theirs with a word instead of an indent, and a word survives a step record, a `--json` line and a
|
|
222
213
|
* browser.
|
|
223
214
|
*/
|
|
@@ -254,7 +245,7 @@ function cloudflareRefusal(args) {
|
|
|
254
245
|
* which is the transient failure by definition. The timeout statuses never reach here — `isTimeout`
|
|
255
246
|
* claims 408, 504 and 524 first, and they are the 504 half of the same pair.
|
|
256
247
|
*
|
|
257
|
-
* **A statusless failure is not covered here, and deliberately so
|
|
248
|
+
* **A statusless failure is not covered here, and deliberately so.** A connection that reset
|
|
258
249
|
* mid-write is genuinely transient and ending a rotation on one is expensive — but "carries no status" is
|
|
259
250
|
* far wider than "the network faltered". A `TypeError` raised in our own request builder carries no status
|
|
260
251
|
* either, and a predicate that called it transient would retry a code defect on the Workflow's own cadence,
|
|
@@ -404,7 +395,7 @@ function apiErrorParams(entry) {
|
|
|
404
395
|
*
|
|
405
396
|
* **The API's code decides; the status is only consulted when there is no code.** Cloudflare returns
|
|
406
397
|
* `10000` under 400, 401 and 403 depending on the endpoint, which is why the status cannot be the
|
|
407
|
-
* discriminator — and the converse
|
|
398
|
+
* discriminator — and the converse holds too: a 403 is *also* how Cloudflare
|
|
408
399
|
* answers `10021 Script startup exceeded CPU limit`, and "add Account → Workers Scripts to it" changes
|
|
409
400
|
* nothing about a CPU limit. So when Cloudflare named any code at all, the codes are the whole of the
|
|
410
401
|
* evidence and the status is not consulted; only a body-less throw (`401 status code (no body)`) falls
|
|
@@ -414,13 +405,11 @@ function apiErrorParams(entry) {
|
|
|
414
405
|
* Cloudflare answered `10000` for this one call; that is all it said. A token missing this product's
|
|
415
406
|
* grant, a revoked token and a token pointed at the wrong account all produce it, and this process can
|
|
416
407
|
* separate none of the three. So the line names all three, in the order an operator can check them,
|
|
417
|
-
* and asserts none.
|
|
418
|
-
* excluded the third by construction: a token in the *wrong* account reaches other products in that
|
|
419
|
-
* account, so the operator was told to add a grant they already held.
|
|
408
|
+
* and asserts none.
|
|
420
409
|
*
|
|
421
|
-
* The **level** is deliberately absent too.
|
|
422
|
-
*
|
|
423
|
-
*
|
|
410
|
+
* The **level** is deliberately absent too. A list call is satisfied by Read, so "Turnstile → Edit"
|
|
411
|
+
* would be a guess dressed as an instruction. Name the group; the operator picks the level their calls
|
|
412
|
+
* need.
|
|
424
413
|
*/
|
|
425
414
|
function permissionAction(apiErrors, status, hint) {
|
|
426
415
|
if (!isAuthClassRefusal(apiErrors, status)) return void 0;
|
|
@@ -436,6 +425,13 @@ function isAuthClassRefusal(apiErrors, status) {
|
|
|
436
425
|
if (coded.length > 0) return coded.some((entry) => AUTH_CLASS_CODES.has(entry.code));
|
|
437
426
|
return status === 401 || status === 403;
|
|
438
427
|
}
|
|
428
|
+
/**
|
|
429
|
+
* Whether a thrown SDK error is the credentials failing rather than the request — the same judgment the
|
|
430
|
+
* refusal's action line makes, read off the throw a {@link cloudflareRefusal} keeps as its `cause`.
|
|
431
|
+
*/
|
|
432
|
+
function isAuthClassFailure(error) {
|
|
433
|
+
return isAuthClassRefusal(cloudflareApiErrors(error), statusOf(error));
|
|
434
|
+
}
|
|
439
435
|
/** The account permission group behind the refused endpoint, per Cloudflare's own docs link. */
|
|
440
436
|
function accountPermissionOf(apiErrors) {
|
|
441
437
|
for (const entry of apiErrors) {
|
|
@@ -575,4 +571,4 @@ function decodeResponse(schema, raw, context) {
|
|
|
575
571
|
return parsed.data;
|
|
576
572
|
}
|
|
577
573
|
//#endregion
|
|
578
|
-
export { CloudflareInvalidResponseError, CloudflareNotConfiguredError, CloudflareRequestError, cloudflareApiErrors, cloudflareRefusal, cloudflareRequest, cloudflareSaid, decodeResponse, isAuthorizationError, isNotFoundError, messageOf, reasonOf, statusOf };
|
|
574
|
+
export { CloudflareInvalidResponseError, CloudflareNotConfiguredError, CloudflareRequestError, cloudflareApiErrors, cloudflareRefusal, cloudflareRequest, cloudflareSaid, decodeResponse, isAuthClassFailure, isAuthorizationError, isNotFoundError, messageOf, reasonOf, statusOf };
|
|
@@ -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
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
import { CloudflareNotConfiguredError } from "../client/errors.js";
|
|
4
|
+
import { RUNTIME_PROFILE, RUNTIME_TOKEN_SECRET } from "./runtime.js";
|
|
4
5
|
import { accountResource, zoneResources } from "./accountTokensManager.js";
|
|
5
6
|
import { PERMISSION_GROUPS, isPermissionKey, isZoneScopedPermission, resolvePermissionKeys } from "./permissions.js";
|
|
6
|
-
import {
|
|
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.
|
|
3
|
+
"version": "0.9.1",
|
|
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.16.0",
|
|
44
44
|
"zod": "^4.4.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
@@ -5,16 +5,17 @@ import { z } from "zod";
|
|
|
5
5
|
import { CloudflareInvalidResponseError, cloudflareRefusal, cloudflareRequest, decodeResponse } from "../client/errors";
|
|
6
6
|
import { CloudflareManager } from "../client/manager";
|
|
7
7
|
import { type AnalyticsDataset, IMAGE_REQUESTS, STREAM_MINUTES, TURNSTILE_EVENTS } from "./datasets";
|
|
8
|
+
import { ANALYTICS_PERMISSION, GRAPHQL_AUTH_CODES } from "./forbidden";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
|
-
* **Cloudflare's GraphQL Analytics API, for the account-level datasets the kit reads
|
|
11
|
+
* **Cloudflare's GraphQL Analytics API, for the account-level datasets the kit reads.**
|
|
11
12
|
*
|
|
12
13
|
* One endpoint, `POST /graphql`, reached through the SDK's raw `post` like every other call the typed SDK
|
|
13
14
|
* does not cover — never a hand-rolled `fetch` (CLAUDE.md §Cloudflare access). It answers HTTP 200 with
|
|
14
15
|
* `{ data, errors }` even when it refuses, so `errors` is read on every answer and a non-empty one is a
|
|
15
16
|
* refusal in Cloudflare's own words.
|
|
16
17
|
*
|
|
17
|
-
* The token it runs on is the application's one `runtime` token, carrying
|
|
18
|
+
* The token it runs on is the application's one `runtime` token, carrying {@link ANALYTICS_PERMISSION}, whose grain is
|
|
18
19
|
* the whole account. This manager narrows nothing on its own: every query takes the keys it is about, and
|
|
19
20
|
* the caller decides which keys those are.
|
|
20
21
|
*/
|
|
@@ -145,19 +146,26 @@ export class CloudflareAnalyticsManager extends CloudflareManager {
|
|
|
145
146
|
const raw = await cloudflareRequest(
|
|
146
147
|
operation,
|
|
147
148
|
() => this.getClient().post<unknown>("/graphql", { body: { query, variables } }),
|
|
148
|
-
{ permission:
|
|
149
|
+
{ permission: ANALYTICS_PERMISSION },
|
|
149
150
|
);
|
|
150
151
|
const answer = decodeResponse(GraphQLAnswer, raw, operation);
|
|
151
152
|
const errors = answer.errors ?? [];
|
|
152
153
|
if (errors.length > 0) {
|
|
154
|
+
const codes = errors.map((error) => error.extensions?.code);
|
|
155
|
+
// Every refusal answers 200, so the status handed on is the one the GraphQL code means: a spent rate
|
|
156
|
+
// budget is the 429 worth asking again, and `authz` is the 403 whose action line names the grant.
|
|
157
|
+
const status = codes.some((code) => code === RATE_BUDGET_CODE)
|
|
158
|
+
? 429
|
|
159
|
+
: codes.some((code) => code !== undefined && GRAPHQL_AUTH_CODES.has(code))
|
|
160
|
+
? 403
|
|
161
|
+
: undefined;
|
|
153
162
|
throw cloudflareRefusal({
|
|
154
163
|
problem: `Cloudflare request failed: ${operation}.`,
|
|
155
164
|
apiErrors: errors.map((error) => ({ ...(error.message === undefined ? {} : { message: error.message }) })),
|
|
156
|
-
permission:
|
|
157
|
-
|
|
158
|
-
// is the one GraphQL refusal that is worth asking again, so it is handed on as the 429 it means.
|
|
159
|
-
...(errors.some((error) => error.extensions?.code === RATE_BUDGET_CODE) ? { status: 429 } : {}),
|
|
165
|
+
permission: ANALYTICS_PERMISSION,
|
|
166
|
+
...(status === undefined ? {} : { status }),
|
|
160
167
|
detail: JSON.stringify(errors),
|
|
168
|
+
cause: errors,
|
|
161
169
|
});
|
|
162
170
|
}
|
|
163
171
|
return answer.data;
|
|
@@ -267,7 +275,7 @@ export class CloudflareAnalyticsManager extends CloudflareManager {
|
|
|
267
275
|
}
|
|
268
276
|
|
|
269
277
|
/**
|
|
270
|
-
* Turnstile events per sitekey and event type inside the window, for the sitekeys given
|
|
278
|
+
* Turnstile events per sitekey and event type inside the window, for the sitekeys given. The sitekey
|
|
271
279
|
* filter is what scopes the account-wide dataset to the widgets the caller is about. Absent means none.
|
|
272
280
|
*/
|
|
273
281
|
async turnstileEvents(
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { commandName, remedy } from "@pithy-sh/core/src/cli/command";
|
|
5
|
+
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
|
|
7
|
+
import { isAuthClassFailure } from "../client/errors";
|
|
8
|
+
import { RUNTIME_PROFILE } from "../tokens/runtime";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* **An analytics read the Worker's own token may not make, as each caller's own code.**
|
|
12
|
+
*
|
|
13
|
+
* Every capability that reads Cloudflare's GraphQL Analytics does it on the one `runtime` token, which needs
|
|
14
|
+
* {@link ANALYTICS_PERMISSION}. A refusal for want of it is `cloudflare/request_failed` like any other, and
|
|
15
|
+
* that code names no permission a client can read. So a caller wraps its read in {@link guardForbidden},
|
|
16
|
+
* which hands a missing grant to the caller's own error class with the permission in `params` and the
|
|
17
|
+
* remedy in `message`, and lets every other failure through unchanged. No SDK is imported here, so a
|
|
18
|
+
* caller that loads the manager lazily keeps doing so.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The account permission group every analytics read needs, as Cloudflare's token editor names it. */
|
|
22
|
+
export const ANALYTICS_PERMISSION = "Account Analytics Read";
|
|
23
|
+
|
|
24
|
+
/** The GraphQL `extensions.code` that means the token may not read the account it asked about. */
|
|
25
|
+
export const GRAPHQL_AUTH_CODES: ReadonlySet<string> = new Set(["authz"]);
|
|
26
|
+
|
|
27
|
+
/** A missing grant, in the fields a caller's error class takes. */
|
|
28
|
+
export interface ForbiddenRefusal {
|
|
29
|
+
/** The sentence the caller reads: the permission, and the rotate that adds it. */
|
|
30
|
+
message: string;
|
|
31
|
+
/** `permission`, for a client that translates. */
|
|
32
|
+
params: MessageParams;
|
|
33
|
+
/** The operator's line from the Cloudflare refusal, kept for the terminal and the log. */
|
|
34
|
+
action?: string;
|
|
35
|
+
/** The Cloudflare refusal's detail, kept for the log. */
|
|
36
|
+
detail?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Builds the caller's own error for a missing grant. */
|
|
40
|
+
export type RefuseForbidden = (refusal: ForbiddenRefusal, options: { cause: unknown }) => Error;
|
|
41
|
+
|
|
42
|
+
/** Where the read ran and what to call it, for the sentence. */
|
|
43
|
+
export interface ForbiddenContext {
|
|
44
|
+
/** The environment this Worker serves, or null when it names none. */
|
|
45
|
+
environment: string | null;
|
|
46
|
+
/** The read as the sentence names it: "the delivery analytics read". */
|
|
47
|
+
read: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Whether a GraphQL `errors` array, kept as a refusal's cause, says the token may not read the account. */
|
|
51
|
+
function isGraphQLAuthRefusal(cause: unknown): boolean {
|
|
52
|
+
if (!Array.isArray(cause)) return false;
|
|
53
|
+
return cause.some((entry: unknown) => {
|
|
54
|
+
const code = (entry as { extensions?: { code?: unknown } } | null)?.extensions?.code;
|
|
55
|
+
return typeof code === "string" && GRAPHQL_AUTH_CODES.has(code);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether a failure is an analytics query refused because the credentials did not get in: a 401 or 403, or
|
|
61
|
+
* Cloudflare's auth-class code, or GraphQL's `authz`. A spent rate budget, a 5xx and a rejected query are not.
|
|
62
|
+
*/
|
|
63
|
+
export function isAnalyticsForbidden(error: unknown): boolean {
|
|
64
|
+
if (!(error instanceof PithyError) || error.payload.code !== "cloudflare/request_failed") return false;
|
|
65
|
+
return isAuthClassFailure(error.cause) || isGraphQLAuthRefusal(error.cause);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The remedy a caller reads: rotate re-scopes the runtime token, where a mint only rolls its value. */
|
|
69
|
+
function rotateSentence(environment: string | null): string {
|
|
70
|
+
const tail = "to re-scope it; a mint only rolls the value.";
|
|
71
|
+
if (environment === null) return `Rotate it with \`${commandName("token rotate")}\` ${tail}`;
|
|
72
|
+
return remedy("token rotate", { args: [RUNTIME_PROFILE], flags: { "--env": environment } }, { runtime: true }).say(
|
|
73
|
+
(command) => `Run \`${command}\` ${tail}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A missing grant on `error`, as the fields of the caller's own error. */
|
|
78
|
+
export function forbiddenRefusal(error: PithyError, context: ForbiddenContext): ForbiddenRefusal {
|
|
79
|
+
return {
|
|
80
|
+
message: `Cloudflare refused ${context.read}: this Worker's ${RUNTIME_PROFILE} token lacks ${ANALYTICS_PERMISSION}. ${rotateSentence(context.environment)}`,
|
|
81
|
+
params: { permission: ANALYTICS_PERMISSION },
|
|
82
|
+
...(error.payload.action === undefined ? {} : { action: error.payload.action }),
|
|
83
|
+
...(error.payload.detail === undefined ? {} : { detail: error.payload.detail }),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Run an analytics read, refusing a missing grant with the caller's own error and passing every other failure on. */
|
|
88
|
+
export async function guardForbidden<T>(
|
|
89
|
+
read: () => Promise<T>,
|
|
90
|
+
context: ForbiddenContext,
|
|
91
|
+
refuse: RefuseForbidden,
|
|
92
|
+
): Promise<T> {
|
|
93
|
+
try {
|
|
94
|
+
return await read();
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (!(error instanceof PithyError) || !isAnalyticsForbidden(error)) throw error;
|
|
97
|
+
throw refuse(forbiddenRefusal(error, context), { cause: error });
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/client/errors.ts
CHANGED
|
@@ -94,9 +94,8 @@ export class CloudflareInvalidResponseError extends PithyError {
|
|
|
94
94
|
* One entry of Cloudflare's `errors[]` array — the API's own answer to a failed call.
|
|
95
95
|
*
|
|
96
96
|
* Every v4 endpoint answers a failure with `{ success: false, errors: [{ code, message,
|
|
97
|
-
* documentation_url }] }`, and the SDK
|
|
98
|
-
*
|
|
99
|
-
* grant, a revoked token, a wrong account and an outage all rendered as the same sentence.
|
|
97
|
+
* documentation_url }] }`, and the SDK parses it onto the thrown `APIError`. It is what tells a token
|
|
98
|
+
* missing one product's grant, a revoked token, a wrong account and an outage apart.
|
|
100
99
|
*/
|
|
101
100
|
export interface CloudflareApiError {
|
|
102
101
|
/** Cloudflare's numeric error code — the stable discriminator (`10000` is its auth-class code). */
|
|
@@ -162,10 +161,9 @@ function oneLine(text: string): string {
|
|
|
162
161
|
* Cloudflare's auth-class codes — the ones that mean "your credentials did not get you in", as
|
|
163
162
|
* opposed to a missing resource (`12000`), a conflict (`12042`) or a rejected argument (`10202`).
|
|
164
163
|
*
|
|
165
|
-
* Status alone is not the discriminator
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* code is what identifies the class and the status is only a fallback for a body-less throw.
|
|
164
|
+
* Status alone is not the discriminator: Cloudflare returns `10000` under 400, 401 and 403 depending
|
|
165
|
+
* on the endpoint, so the code is what identifies the class and the status is only a fallback for a
|
|
166
|
+
* body-less throw.
|
|
169
167
|
*/
|
|
170
168
|
const AUTH_CLASS_CODES = new Set([9103, 9106, 9107, 9109, 10000, 10001]);
|
|
171
169
|
|
|
@@ -252,13 +250,9 @@ export async function cloudflareRequest<T>(
|
|
|
252
250
|
* Compose the refusal every failed Cloudflare call renders: the problem line, then the API's own
|
|
253
251
|
* answer, then — only for an auth-class code — what to do about it.
|
|
254
252
|
*
|
|
255
|
-
* One place, so a fix reaches every provisioner at once
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
* direct upload and two integration helpers were composing their own, so `pithy token mint` — the
|
|
259
|
-
* bootstrap command an under-scoped token meets first — still printed the operation and nothing else
|
|
260
|
-
* on the identical 401 body. `refusalSites.test.ts` now fails any `new CloudflareRequestError(`
|
|
261
|
-
* outside this file, so "the only place" is enforced rather than remembered.
|
|
253
|
+
* One place, so a fix reaches every provisioner at once. `refusalSites.test.ts` fails any
|
|
254
|
+
* `new CloudflareRequestError(` outside this file, so "the only place" is enforced rather than
|
|
255
|
+
* remembered.
|
|
262
256
|
*
|
|
263
257
|
* CLAUDE.md §Errors decides where each string lands. Cloudflare's sentence and its docs link are the
|
|
264
258
|
* **caller's** information — what happened, in the words of whoever it happened to — so they go in
|
|
@@ -274,14 +268,10 @@ export async function cloudflareRequest<T>(
|
|
|
274
268
|
* for, and pinned by `core`'s payload test. The words are composed the same way either side of that
|
|
275
269
|
* branch, so only the two machine-readable fields differ.
|
|
276
270
|
*
|
|
277
|
-
* **The message is one line,
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
* from the remedy, so a multi-line `message` was declined whole at the reader and the operator got
|
|
282
|
-
* `core/workflow_failed` 500 about durable execution instead of the API answer this issue exists to
|
|
283
|
-
* show them. The same newline is what the CLI's action line is, so an upstream sentence containing one
|
|
284
|
-
* forges a remedy Pithy never wrote. `Cloudflare said:` marks the boundary between our words and
|
|
271
|
+
* **The message is one line, a kit-wide rule.** A `PithyError` raised inside a `classifiedSteps` step
|
|
272
|
+
* crosses a durable boundary as one string whose newline separates the sentence from the remedy, so a
|
|
273
|
+
* multi-line `message` is declined whole at the reader. The same newline is what the CLI's action line
|
|
274
|
+
* is, so an upstream sentence containing one would forge a remedy Pithy never wrote. `Cloudflare said:` marks the boundary between our words and
|
|
285
275
|
* theirs with a word instead of an indent, and a word survives a step record, a `--json` line and a
|
|
286
276
|
* browser.
|
|
287
277
|
*/
|
|
@@ -341,7 +331,7 @@ export function cloudflareRefusal(args: {
|
|
|
341
331
|
* which is the transient failure by definition. The timeout statuses never reach here — `isTimeout`
|
|
342
332
|
* claims 408, 504 and 524 first, and they are the 504 half of the same pair.
|
|
343
333
|
*
|
|
344
|
-
* **A statusless failure is not covered here, and deliberately so
|
|
334
|
+
* **A statusless failure is not covered here, and deliberately so.** A connection that reset
|
|
345
335
|
* mid-write is genuinely transient and ending a rotation on one is expensive — but "carries no status" is
|
|
346
336
|
* far wider than "the network faltered". A `TypeError` raised in our own request builder carries no status
|
|
347
337
|
* either, and a predicate that called it transient would retry a code defect on the Workflow's own cadence,
|
|
@@ -504,7 +494,7 @@ function apiErrorParams(entry: CloudflareApiError | undefined): MessageParams {
|
|
|
504
494
|
*
|
|
505
495
|
* **The API's code decides; the status is only consulted when there is no code.** Cloudflare returns
|
|
506
496
|
* `10000` under 400, 401 and 403 depending on the endpoint, which is why the status cannot be the
|
|
507
|
-
* discriminator — and the converse
|
|
497
|
+
* discriminator — and the converse holds too: a 403 is *also* how Cloudflare
|
|
508
498
|
* answers `10021 Script startup exceeded CPU limit`, and "add Account → Workers Scripts to it" changes
|
|
509
499
|
* nothing about a CPU limit. So when Cloudflare named any code at all, the codes are the whole of the
|
|
510
500
|
* evidence and the status is not consulted; only a body-less throw (`401 status code (no body)`) falls
|
|
@@ -514,13 +504,11 @@ function apiErrorParams(entry: CloudflareApiError | undefined): MessageParams {
|
|
|
514
504
|
* Cloudflare answered `10000` for this one call; that is all it said. A token missing this product's
|
|
515
505
|
* grant, a revoked token and a token pointed at the wrong account all produce it, and this process can
|
|
516
506
|
* separate none of the three. So the line names all three, in the order an operator can check them,
|
|
517
|
-
* and asserts none.
|
|
518
|
-
* excluded the third by construction: a token in the *wrong* account reaches other products in that
|
|
519
|
-
* account, so the operator was told to add a grant they already held.
|
|
507
|
+
* and asserts none.
|
|
520
508
|
*
|
|
521
|
-
* The **level** is deliberately absent too.
|
|
522
|
-
*
|
|
523
|
-
*
|
|
509
|
+
* The **level** is deliberately absent too. A list call is satisfied by Read, so "Turnstile → Edit"
|
|
510
|
+
* would be a guess dressed as an instruction. Name the group; the operator picks the level their calls
|
|
511
|
+
* need.
|
|
524
512
|
*/
|
|
525
513
|
function permissionAction(apiErrors: CloudflareApiError[], status?: number, hint?: string): string | undefined {
|
|
526
514
|
if (!isAuthClassRefusal(apiErrors, status)) return undefined;
|
|
@@ -540,6 +528,14 @@ function isAuthClassRefusal(apiErrors: CloudflareApiError[], status?: number): b
|
|
|
540
528
|
return status === 401 || status === 403;
|
|
541
529
|
}
|
|
542
530
|
|
|
531
|
+
/**
|
|
532
|
+
* Whether a thrown SDK error is the credentials failing rather than the request — the same judgment the
|
|
533
|
+
* refusal's action line makes, read off the throw a {@link cloudflareRefusal} keeps as its `cause`.
|
|
534
|
+
*/
|
|
535
|
+
export function isAuthClassFailure(error: unknown): boolean {
|
|
536
|
+
return isAuthClassRefusal(cloudflareApiErrors(error), statusOf(error));
|
|
537
|
+
}
|
|
538
|
+
|
|
543
539
|
/** The account permission group behind the refused endpoint, per Cloudflare's own docs link. */
|
|
544
540
|
function accountPermissionOf(apiErrors: CloudflareApiError[]): string | undefined {
|
|
545
541
|
for (const entry of apiErrors) {
|
|
@@ -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 [];
|