@pithy-sh/cloudflare 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/profiles.js +1 -1
- 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
|
@@ -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":"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"}
|
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 };
|
package/dist/tokens/profiles.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
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 { RUNTIME_PROFILE, RUNTIME_TOKEN_SECRET } from "./runtime.js";
|
|
7
7
|
//#region src/tokens/profiles.ts
|
|
8
8
|
/** The valid stores, for validating a `--store` flag or a profile's `defaultStore`. */
|
|
9
9
|
const TOKEN_STORES = [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pithy-sh/cloudflare",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"js-base64": "^3.9.2"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"@pithy-sh/core": "^0.
|
|
43
|
+
"@pithy-sh/core": "^0.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) {
|