@pithy-sh/cloudflare 0.5.0 → 0.7.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 +78 -0
- package/dist/analytics/analyticsManager.d.ts.map +1 -0
- package/dist/analytics/analyticsManager.js +206 -0
- package/dist/analytics/datasets.d.ts +26 -0
- package/dist/analytics/datasets.d.ts.map +1 -0
- package/dist/analytics/datasets.js +26 -0
- package/dist/analytics/window.d.ts +50 -0
- package/dist/analytics/window.d.ts.map +1 -0
- package/dist/analytics/window.js +67 -0
- package/dist/artifacts/artifactsManager.d.ts +52 -0
- package/dist/artifacts/artifactsManager.d.ts.map +1 -0
- package/dist/artifacts/artifactsManager.js +150 -0
- package/dist/client/clients.d.ts +13 -1
- package/dist/client/clients.d.ts.map +1 -1
- package/dist/client/clients.js +22 -1
- package/dist/client/errors.d.ts +4 -3
- package/dist/client/errors.d.ts.map +1 -1
- package/dist/media/imageManager.d.ts +25 -0
- package/dist/media/imageManager.d.ts.map +1 -1
- package/dist/media/imageManager.js +58 -2
- package/dist/media/streamManager.d.ts +15 -0
- package/dist/media/streamManager.d.ts.map +1 -1
- package/dist/media/streamManager.js +28 -1
- package/dist/oauth/scopes.d.ts +2 -2
- package/dist/oauth/scopes.js +2 -2
- package/dist/queue/queueProvisioner.d.ts +30 -0
- package/dist/queue/queueProvisioner.d.ts.map +1 -0
- package/dist/queue/queueProvisioner.js +72 -0
- package/dist/r2/r2Manager.d.ts +11 -1
- package/dist/r2/r2Manager.d.ts.map +1 -1
- package/dist/r2/r2Manager.js +18 -1
- package/dist/tokens/accountTokensManager.d.ts +1 -0
- package/dist/tokens/accountTokensManager.d.ts.map +1 -1
- package/dist/tokens/accountTokensManager.js +2 -1
- package/dist/tokens/permissions.d.ts +10 -0
- package/dist/tokens/permissions.d.ts.map +1 -1
- package/dist/tokens/permissions.js +12 -2
- package/dist/tokens/profiles.d.ts +6 -30
- package/dist/tokens/profiles.d.ts.map +1 -1
- package/dist/tokens/profiles.js +44 -36
- package/dist/tokens/runtime.d.ts +16 -0
- package/dist/tokens/runtime.d.ts.map +1 -0
- package/dist/tokens/runtime.js +18 -0
- package/package.json +3 -3
- package/src/analytics/analyticsManager.ts +311 -0
- package/src/analytics/datasets.ts +30 -0
- package/src/analytics/window.ts +101 -0
- package/src/artifacts/artifactsManager.ts +199 -0
- package/src/client/clients.ts +28 -1
- package/src/client/errors.ts +4 -3
- package/src/media/imageManager.ts +93 -1
- package/src/media/streamManager.ts +34 -0
- package/src/oauth/scopes.ts +2 -2
- package/src/queue/queueProvisioner.ts +79 -0
- package/src/r2/r2Manager.ts +29 -2
- package/src/tokens/accountTokensManager.ts +12 -0
- package/src/tokens/permissions.ts +27 -0
- package/src/tokens/profiles.ts +54 -70
- package/src/tokens/runtime.ts +18 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { CloudflareManager } from "../client/manager";
|
|
5
|
+
import { type AnalyticsDataset } from "./datasets";
|
|
6
|
+
/**
|
|
7
|
+
* **Cloudflare's GraphQL Analytics API, for the account-level datasets the kit reads (#728).**
|
|
8
|
+
*
|
|
9
|
+
* One endpoint, `POST /graphql`, reached through the SDK's raw `post` like every other call the typed SDK
|
|
10
|
+
* does not cover — never a hand-rolled `fetch` (CLAUDE.md §Cloudflare access). It answers HTTP 200 with
|
|
11
|
+
* `{ data, errors }` even when it refuses, so `errors` is read on every answer and a non-empty one is a
|
|
12
|
+
* refusal in Cloudflare's own words.
|
|
13
|
+
*
|
|
14
|
+
* The token it runs on is the application's one `runtime` token, carrying "Account Analytics Read", whose grain is
|
|
15
|
+
* the whole account. This manager narrows nothing on its own: every query takes the keys it is about, and
|
|
16
|
+
* the caller decides which keys those are.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* The most groups one Turnstile read asks for. A group is one sitekey and one event type, and Cloudflare
|
|
20
|
+
* documents ten event types, so a project's one or two widgets need a few dozen. An answer that fills the
|
|
21
|
+
* limit is refused rather than read short.
|
|
22
|
+
*/
|
|
23
|
+
export declare const TURNSTILE_EVENT_GROUP_LIMIT = 1000;
|
|
24
|
+
/**
|
|
25
|
+
* How many keys one query filters on. The answer is one group per key, so this is also the `limit`, and
|
|
26
|
+
* it sits far under the 10,000 `maxPageSize` the datasets advertise — a large library is several bounded
|
|
27
|
+
* queries, never one unbounded filter.
|
|
28
|
+
*/
|
|
29
|
+
export declare const ANALYTICS_KEY_CHUNK = 500;
|
|
30
|
+
/** One dataset's limits, as `viewer.accounts.settings` reports them. Every duration is in seconds. */
|
|
31
|
+
export declare const DatasetLimits: z.ZodObject<{
|
|
32
|
+
enabled: z.ZodBoolean;
|
|
33
|
+
maxDuration: z.ZodNumber;
|
|
34
|
+
notOlderThan: z.ZodNumber;
|
|
35
|
+
maxPageSize: z.ZodNumber;
|
|
36
|
+
}, z.core.$strip>;
|
|
37
|
+
export type DatasetLimits = z.infer<typeof DatasetLimits>;
|
|
38
|
+
/** A window and the keys to read it for. */
|
|
39
|
+
export interface WindowQuery {
|
|
40
|
+
/** The window's start, inclusive. */
|
|
41
|
+
since: Date;
|
|
42
|
+
/** The window's end, exclusive. */
|
|
43
|
+
until: Date;
|
|
44
|
+
}
|
|
45
|
+
/** Out-of-band reads of the account's delivery analytics. */
|
|
46
|
+
export declare class CloudflareAnalyticsManager extends CloudflareManager {
|
|
47
|
+
getServiceType(): string;
|
|
48
|
+
/** Whether the token can read the account's analytics settings. Never throws. */
|
|
49
|
+
validateServiceAccess(): Promise<boolean>;
|
|
50
|
+
/**
|
|
51
|
+
* Run one query and hand back its `data`, refusing in Cloudflare's own words when `errors` is non-empty.
|
|
52
|
+
* The status is a 200 either way, so the envelope is the only place a refusal can be read.
|
|
53
|
+
*/
|
|
54
|
+
private query;
|
|
55
|
+
/** The one account in an answer, refused when the token's viewer cannot see it. */
|
|
56
|
+
private onlyAccount;
|
|
57
|
+
/**
|
|
58
|
+
* Each dataset's limits for this account — whether it is served at all, its retention, and the widest
|
|
59
|
+
* window one query may ask for. Read, never assumed: the numbers differ per dataset and per plan.
|
|
60
|
+
*/
|
|
61
|
+
datasetLimits(datasets: readonly AnalyticsDataset[]): Promise<Partial<Record<AnalyticsDataset, DatasetLimits>>>;
|
|
62
|
+
/** Minutes of video delivered per Stream uid inside the window, for the uids given. Absent means none. */
|
|
63
|
+
streamMinutesViewed(window: WindowQuery & {
|
|
64
|
+
uids: readonly string[];
|
|
65
|
+
}): Promise<Map<string, number>>;
|
|
66
|
+
/** Delivery requests per hosted image id inside the window, for the ids given. Absent means none. */
|
|
67
|
+
imageRequests(window: WindowQuery & {
|
|
68
|
+
imageIds: readonly string[];
|
|
69
|
+
}): Promise<Map<string, number>>;
|
|
70
|
+
/**
|
|
71
|
+
* Turnstile events per sitekey and event type inside the window, for the sitekeys given (#731). The sitekey
|
|
72
|
+
* filter is what scopes the account-wide dataset to the widgets the caller is about. Absent means none.
|
|
73
|
+
*/
|
|
74
|
+
turnstileEvents(window: WindowQuery & {
|
|
75
|
+
siteKeys: readonly string[];
|
|
76
|
+
}): Promise<Map<string, Map<string, number>>>;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=analyticsManager.d.ts.map
|
|
@@ -0,0 +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;AAErG;;;;;;;;;;;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;IAsBnB,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"}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { CloudflareInvalidResponseError, cloudflareRefusal, cloudflareRequest, decodeResponse } from "../client/errors.js";
|
|
4
|
+
import { CloudflareManager } from "../client/manager.js";
|
|
5
|
+
import { IMAGE_REQUESTS, STREAM_MINUTES, TURNSTILE_EVENTS } from "./datasets.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
//#region src/analytics/analyticsManager.ts
|
|
8
|
+
/**
|
|
9
|
+
* **Cloudflare's GraphQL Analytics API, for the account-level datasets the kit reads (#728).**
|
|
10
|
+
*
|
|
11
|
+
* One endpoint, `POST /graphql`, reached through the SDK's raw `post` like every other call the typed SDK
|
|
12
|
+
* does not cover — never a hand-rolled `fetch` (CLAUDE.md §Cloudflare access). It answers HTTP 200 with
|
|
13
|
+
* `{ data, errors }` even when it refuses, so `errors` is read on every answer and a non-empty one is a
|
|
14
|
+
* refusal in Cloudflare's own words.
|
|
15
|
+
*
|
|
16
|
+
* The token it runs on is the application's one `runtime` token, carrying "Account Analytics Read", whose grain is
|
|
17
|
+
* the whole account. This manager narrows nothing on its own: every query takes the keys it is about, and
|
|
18
|
+
* the caller decides which keys those are.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* The most groups one Turnstile read asks for. A group is one sitekey and one event type, and Cloudflare
|
|
22
|
+
* documents ten event types, so a project's one or two widgets need a few dozen. An answer that fills the
|
|
23
|
+
* limit is refused rather than read short.
|
|
24
|
+
*/
|
|
25
|
+
const TURNSTILE_EVENT_GROUP_LIMIT = 1e3;
|
|
26
|
+
/**
|
|
27
|
+
* How many keys one query filters on. The answer is one group per key, so this is also the `limit`, and
|
|
28
|
+
* it sits far under the 10,000 `maxPageSize` the datasets advertise — a large library is several bounded
|
|
29
|
+
* queries, never one unbounded filter.
|
|
30
|
+
*/
|
|
31
|
+
const ANALYTICS_KEY_CHUNK = 500;
|
|
32
|
+
/** One dataset's limits, as `viewer.accounts.settings` reports them. Every duration is in seconds. */
|
|
33
|
+
const DatasetLimits = z.object({
|
|
34
|
+
enabled: z.boolean().describe("Whether Cloudflare serves this dataset to this account at all."),
|
|
35
|
+
maxDuration: z.number().describe("The widest window one query may ask for, in seconds."),
|
|
36
|
+
notOlderThan: z.number().describe("How far back the dataset reaches, in seconds — its retention."),
|
|
37
|
+
maxPageSize: z.number().describe("The largest `limit` one query may pass.")
|
|
38
|
+
}).describe("One analytics dataset's limits for this account, as Cloudflare's settings node reports them.");
|
|
39
|
+
/** The GraphQL error code for a spent rate budget — the one refusal a caller may retry. */
|
|
40
|
+
const RATE_BUDGET_CODE = "budget";
|
|
41
|
+
/** One entry of a GraphQL `errors` array. */
|
|
42
|
+
const GraphQLError = z.object({
|
|
43
|
+
message: z.string().optional().describe("Cloudflare's own sentence for the failure."),
|
|
44
|
+
extensions: z.object({ code: z.string().optional().describe("Cloudflare's machine code for the failure.") }).partial().optional().describe("Cloudflare's structured extras.")
|
|
45
|
+
}).describe("One error the GraphQL endpoint answered.");
|
|
46
|
+
/** The envelope every GraphQL answer arrives in. `data` is decoded per query. */
|
|
47
|
+
const GraphQLAnswer = z.object({
|
|
48
|
+
data: z.unknown().optional().describe("The query's result, when it ran."),
|
|
49
|
+
errors: z.array(GraphQLError).nullish().describe("Why the query did not run, when it did not.")
|
|
50
|
+
}).describe("The GraphQL endpoint's answer envelope.");
|
|
51
|
+
/** The one account a query is about, from `viewer.accounts(filter: { accountTag })`. */
|
|
52
|
+
function accountOf(account) {
|
|
53
|
+
return z.object({ viewer: z.object({ accounts: z.array(account).describe("The accounts the filter matched.") }).describe("The authenticated viewer.") }).describe("A GraphQL answer about one account.");
|
|
54
|
+
}
|
|
55
|
+
const StreamMinutesGroup = z.object({
|
|
56
|
+
sum: z.object({ minutesViewed: z.number().describe("Minutes of video delivered.") }).describe("The summed metrics."),
|
|
57
|
+
dimensions: z.object({ uid: z.string().describe("The Stream video uid.") }).describe("The group's key.")
|
|
58
|
+
}).describe("Minutes delivered for one video.");
|
|
59
|
+
const ImageRequestsGroup = z.object({
|
|
60
|
+
count: z.number().describe("Delivery requests for the image."),
|
|
61
|
+
dimensions: z.object({ imageId: z.string().describe("The Cloudflare Images id.") }).describe("The group's key.")
|
|
62
|
+
}).describe("Delivery requests for one hosted image.");
|
|
63
|
+
const TurnstileEventGroup = z.object({
|
|
64
|
+
count: z.number().describe("Events of this type for this sitekey."),
|
|
65
|
+
dimensions: z.object({
|
|
66
|
+
siteKey: z.string().describe("The widget's public sitekey."),
|
|
67
|
+
eventType: z.string().describe("The Turnstile event type, as Cloudflare names it.")
|
|
68
|
+
}).describe("The group's key.")
|
|
69
|
+
}).describe("Events of one type for one Turnstile widget.");
|
|
70
|
+
/** Split keys into bounded chunks. */
|
|
71
|
+
function chunks(items, size) {
|
|
72
|
+
const out = [];
|
|
73
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/** Out-of-band reads of the account's delivery analytics. */
|
|
77
|
+
var CloudflareAnalyticsManager = class extends CloudflareManager {
|
|
78
|
+
getServiceType() {
|
|
79
|
+
return "Analytics";
|
|
80
|
+
}
|
|
81
|
+
/** Whether the token can read the account's analytics settings. Never throws. */
|
|
82
|
+
async validateServiceAccess() {
|
|
83
|
+
try {
|
|
84
|
+
await this.datasetLimits([STREAM_MINUTES]);
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Run one query and hand back its `data`, refusing in Cloudflare's own words when `errors` is non-empty.
|
|
92
|
+
* The status is a 200 either way, so the envelope is the only place a refusal can be read.
|
|
93
|
+
*/
|
|
94
|
+
async query(operation, query, variables) {
|
|
95
|
+
const raw = await cloudflareRequest(operation, () => this.getClient().post("/graphql", { body: {
|
|
96
|
+
query,
|
|
97
|
+
variables
|
|
98
|
+
} }), { permission: "Account Analytics Read" });
|
|
99
|
+
const answer = decodeResponse(GraphQLAnswer, raw, operation);
|
|
100
|
+
const errors = answer.errors ?? [];
|
|
101
|
+
if (errors.length > 0) throw cloudflareRefusal({
|
|
102
|
+
problem: `Cloudflare request failed: ${operation}.`,
|
|
103
|
+
apiErrors: errors.map((error) => ({ ...error.message === void 0 ? {} : { message: error.message } })),
|
|
104
|
+
permission: "Account Analytics Read",
|
|
105
|
+
...errors.some((error) => error.extensions?.code === RATE_BUDGET_CODE) ? { status: 429 } : {},
|
|
106
|
+
detail: JSON.stringify(errors)
|
|
107
|
+
});
|
|
108
|
+
return answer.data;
|
|
109
|
+
}
|
|
110
|
+
/** The one account in an answer, refused when the token's viewer cannot see it. */
|
|
111
|
+
onlyAccount(schema, data, operation) {
|
|
112
|
+
const [account] = decodeResponse(accountOf(schema), data, operation).viewer.accounts;
|
|
113
|
+
if (account === void 0) throw new CloudflareInvalidResponseError({
|
|
114
|
+
message: `Cloudflare answered no account for ${operation}. The token cannot see this account.`,
|
|
115
|
+
detail: `viewer.accounts was empty for account ${this.accountId}`
|
|
116
|
+
});
|
|
117
|
+
return account;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Each dataset's limits for this account — whether it is served at all, its retention, and the widest
|
|
121
|
+
* window one query may ask for. Read, never assumed: the numbers differ per dataset and per plan.
|
|
122
|
+
*/
|
|
123
|
+
async datasetLimits(datasets) {
|
|
124
|
+
const fields = datasets.map((dataset) => `${dataset} { enabled maxDuration notOlderThan maxPageSize }`).join(" ");
|
|
125
|
+
const operation = "read analytics dataset limits";
|
|
126
|
+
const data = await this.query(operation, `query ($account: string!) { viewer { accounts(filter: { accountTag: $account }) { settings { ${fields} } } } }`, { account: this.accountId });
|
|
127
|
+
const shape = z.object({ settings: z.object(Object.fromEntries(datasets.map((dataset) => [dataset, DatasetLimits.nullish()]))).describe("The datasets asked about. A dataset this account is not served is null or absent.") }).describe("The account's analytics settings.");
|
|
128
|
+
const settings = this.onlyAccount(shape, data, operation).settings;
|
|
129
|
+
const limits = {};
|
|
130
|
+
for (const dataset of datasets) {
|
|
131
|
+
const entry = settings[dataset];
|
|
132
|
+
if (entry !== null && entry !== void 0) limits[dataset] = entry;
|
|
133
|
+
}
|
|
134
|
+
return limits;
|
|
135
|
+
}
|
|
136
|
+
/** Minutes of video delivered per Stream uid inside the window, for the uids given. Absent means none. */
|
|
137
|
+
async streamMinutesViewed(window) {
|
|
138
|
+
const totals = /* @__PURE__ */ new Map();
|
|
139
|
+
const operation = "read Stream minutes viewed";
|
|
140
|
+
for (const uids of chunks(window.uids, 500)) {
|
|
141
|
+
const data = await this.query(operation, `query ($account: string!, $filter: AccountStreamMinutesViewedAdaptiveGroupsFilter_InputObject, $limit: uint64!) { viewer { accounts(filter: { accountTag: $account }) { ${STREAM_MINUTES}(filter: $filter, limit: $limit) { sum { minutesViewed } dimensions { uid } } } } }`, {
|
|
142
|
+
account: this.accountId,
|
|
143
|
+
filter: {
|
|
144
|
+
datetime_geq: window.since.toISOString(),
|
|
145
|
+
datetime_lt: window.until.toISOString(),
|
|
146
|
+
uid_in: uids
|
|
147
|
+
},
|
|
148
|
+
limit: uids.length
|
|
149
|
+
});
|
|
150
|
+
const groups = this.onlyAccount(z.object({ [STREAM_MINUTES]: z.array(StreamMinutesGroup).describe("Minutes per video.") }), data, operation)[STREAM_MINUTES];
|
|
151
|
+
for (const group of groups) totals.set(group.dimensions.uid, (totals.get(group.dimensions.uid) ?? 0) + group.sum.minutesViewed);
|
|
152
|
+
}
|
|
153
|
+
return totals;
|
|
154
|
+
}
|
|
155
|
+
/** Delivery requests per hosted image id inside the window, for the ids given. Absent means none. */
|
|
156
|
+
async imageRequests(window) {
|
|
157
|
+
const totals = /* @__PURE__ */ new Map();
|
|
158
|
+
const operation = "read Images delivery requests";
|
|
159
|
+
for (const imageIds of chunks(window.imageIds, 500)) {
|
|
160
|
+
const data = await this.query(operation, `query ($account: string!, $filter: AccountImagesHostedRequestsAdaptiveGroupsFilter_InputObject, $limit: uint64!) { viewer { accounts(filter: { accountTag: $account }) { ${IMAGE_REQUESTS}(filter: $filter, limit: $limit) { count dimensions { imageId } } } } }`, {
|
|
161
|
+
account: this.accountId,
|
|
162
|
+
filter: {
|
|
163
|
+
datetime_geq: window.since.toISOString(),
|
|
164
|
+
datetime_lt: window.until.toISOString(),
|
|
165
|
+
imageId_in: imageIds
|
|
166
|
+
},
|
|
167
|
+
limit: imageIds.length
|
|
168
|
+
});
|
|
169
|
+
const groups = this.onlyAccount(z.object({ [IMAGE_REQUESTS]: z.array(ImageRequestsGroup).describe("Requests per image.") }), data, operation)[IMAGE_REQUESTS];
|
|
170
|
+
for (const group of groups) totals.set(group.dimensions.imageId, (totals.get(group.dimensions.imageId) ?? 0) + group.count);
|
|
171
|
+
}
|
|
172
|
+
return totals;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Turnstile events per sitekey and event type inside the window, for the sitekeys given (#731). The sitekey
|
|
176
|
+
* filter is what scopes the account-wide dataset to the widgets the caller is about. Absent means none.
|
|
177
|
+
*/
|
|
178
|
+
async turnstileEvents(window) {
|
|
179
|
+
const totals = /* @__PURE__ */ new Map();
|
|
180
|
+
if (window.siteKeys.length === 0) return totals;
|
|
181
|
+
const operation = "read Turnstile events";
|
|
182
|
+
const data = await this.query(operation, `query ($account: string!, $filter: AccountTurnstileAdaptiveGroupsFilter_InputObject!, $limit: uint64!) { viewer { accounts(filter: { accountTag: $account }) { ${TURNSTILE_EVENTS}(filter: $filter, limit: $limit) { count dimensions { siteKey eventType } } } } }`, {
|
|
183
|
+
account: this.accountId,
|
|
184
|
+
filter: {
|
|
185
|
+
datetime_geq: window.since.toISOString(),
|
|
186
|
+
datetime_lt: window.until.toISOString(),
|
|
187
|
+
siteKey_in: [...window.siteKeys]
|
|
188
|
+
},
|
|
189
|
+
limit: TURNSTILE_EVENT_GROUP_LIMIT
|
|
190
|
+
});
|
|
191
|
+
const groups = this.onlyAccount(z.object({ [TURNSTILE_EVENTS]: z.array(TurnstileEventGroup).describe("Events per sitekey and type.") }), data, operation)[TURNSTILE_EVENTS];
|
|
192
|
+
if (groups.length >= 1e3) throw new CloudflareInvalidResponseError({
|
|
193
|
+
message: `Cloudflare answered ${groups.length} Turnstile event groups, the most one read asks for. The counts past them are missing.`,
|
|
194
|
+
detail: `${TURNSTILE_EVENTS} filled limit ${TURNSTILE_EVENT_GROUP_LIMIT} for ${window.siteKeys.length} sitekeys`
|
|
195
|
+
});
|
|
196
|
+
for (const group of groups) {
|
|
197
|
+
const { siteKey, eventType } = group.dimensions;
|
|
198
|
+
const events = totals.get(siteKey) ?? /* @__PURE__ */ new Map();
|
|
199
|
+
events.set(eventType, (events.get(eventType) ?? 0) + group.count);
|
|
200
|
+
totals.set(siteKey, events);
|
|
201
|
+
}
|
|
202
|
+
return totals;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
//#endregion
|
|
206
|
+
export { ANALYTICS_KEY_CHUNK, CloudflareAnalyticsManager, DatasetLimits, TURNSTILE_EVENT_GROUP_LIMIT };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* **The analytics datasets the kit reads, by their GraphQL names**, in a module of their own. A Worker
|
|
5
|
+
* names a dataset to read its limits and to word a refusal, and importing the manager to learn a string
|
|
6
|
+
* would put the Cloudflare SDK on every module that does (#731). Nothing here imports anything.
|
|
7
|
+
*/
|
|
8
|
+
/** Minutes of Stream video delivered, groupable by video `uid`. Account-level, US-only under a metadata boundary. */
|
|
9
|
+
export declare const STREAM_MINUTES = "streamMinutesViewedAdaptiveGroups";
|
|
10
|
+
/**
|
|
11
|
+
* Hosted Images delivery requests, groupable by `imageId` — the id Cloudflare Images assigns at upload.
|
|
12
|
+
*
|
|
13
|
+
* Not `imagesRequestsAdaptiveGroups`: that dataset groups by time and nothing else, so it can only ever
|
|
14
|
+
* answer an account total, which cannot be scoped to an environment. This one carries the image id,
|
|
15
|
+
* found by schema introspection on 2026-10-17.
|
|
16
|
+
*/
|
|
17
|
+
export declare const IMAGE_REQUESTS = "imagesHostedRequestsAdaptiveGroups";
|
|
18
|
+
/**
|
|
19
|
+
* Turnstile challenge events, groupable by `siteKey` and `eventType` (#731). Account-level, and served under
|
|
20
|
+
* a US and an EU metadata boundary alike, so a boundary does not rule it out. Found by schema introspection
|
|
21
|
+
* on 2026-10-18; Cloudflare marks it Beta.
|
|
22
|
+
*/
|
|
23
|
+
export declare const TURNSTILE_EVENTS = "turnstileAdaptiveGroups";
|
|
24
|
+
/** The datasets this manager queries. */
|
|
25
|
+
export type AnalyticsDataset = typeof STREAM_MINUTES | typeof IMAGE_REQUESTS | typeof TURNSTILE_EVENTS;
|
|
26
|
+
//# sourceMappingURL=datasets.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"datasets.d.ts","sourceRoot":"","sources":["../../src/analytics/datasets.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AAEH,qHAAqH;AACrH,eAAO,MAAM,cAAc,sCAAsC,CAAC;AAElE;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,uCAAuC,CAAC;AAEnE;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,4BAA4B,CAAC;AAE1D,yCAAyC;AACzC,MAAM,MAAM,gBAAgB,GAAG,OAAO,cAAc,GAAG,OAAO,cAAc,GAAG,OAAO,gBAAgB,CAAC"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
//#region src/analytics/datasets.ts
|
|
4
|
+
/**
|
|
5
|
+
* **The analytics datasets the kit reads, by their GraphQL names**, in a module of their own. A Worker
|
|
6
|
+
* names a dataset to read its limits and to word a refusal, and importing the manager to learn a string
|
|
7
|
+
* would put the Cloudflare SDK on every module that does (#731). Nothing here imports anything.
|
|
8
|
+
*/
|
|
9
|
+
/** Minutes of Stream video delivered, groupable by video `uid`. Account-level, US-only under a metadata boundary. */
|
|
10
|
+
const STREAM_MINUTES = "streamMinutesViewedAdaptiveGroups";
|
|
11
|
+
/**
|
|
12
|
+
* Hosted Images delivery requests, groupable by `imageId` — the id Cloudflare Images assigns at upload.
|
|
13
|
+
*
|
|
14
|
+
* Not `imagesRequestsAdaptiveGroups`: that dataset groups by time and nothing else, so it can only ever
|
|
15
|
+
* answer an account total, which cannot be scoped to an environment. This one carries the image id,
|
|
16
|
+
* found by schema introspection on 2026-10-17.
|
|
17
|
+
*/
|
|
18
|
+
const IMAGE_REQUESTS = "imagesHostedRequestsAdaptiveGroups";
|
|
19
|
+
/**
|
|
20
|
+
* Turnstile challenge events, groupable by `siteKey` and `eventType` (#731). Account-level, and served under
|
|
21
|
+
* a US and an EU metadata boundary alike, so a boundary does not rule it out. Found by schema introspection
|
|
22
|
+
* on 2026-10-18; Cloudflare marks it Beta.
|
|
23
|
+
*/
|
|
24
|
+
const TURNSTILE_EVENTS = "turnstileAdaptiveGroups";
|
|
25
|
+
//#endregion
|
|
26
|
+
export { IMAGE_REQUESTS, STREAM_MINUTES, TURNSTILE_EVENTS };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
|
|
4
|
+
import type { DatasetLimits } from "./analyticsManager";
|
|
5
|
+
/**
|
|
6
|
+
* **The window an analytics read covers, and the limits it is held to (#728, #731).**
|
|
7
|
+
*
|
|
8
|
+
* Written once for every capability that reads Cloudflare's GraphQL Analytics. The rules are the same for
|
|
9
|
+
* each: the last {@link DEFAULT_WINDOW_DAYS} days by default, a window that ends before it starts refused,
|
|
10
|
+
* and a window past the dataset's retention or wider than one read may ask for refused, naming the cap in
|
|
11
|
+
* days. The refusal is each caller's own code, so it is handed to a factory rather than thrown here.
|
|
12
|
+
*/
|
|
13
|
+
/** The default window, in days, ending now. Fits every dataset the kit reads. */
|
|
14
|
+
export declare const DEFAULT_WINDOW_DAYS = 30;
|
|
15
|
+
/** A resolved window: `since` inclusive, `until` exclusive. */
|
|
16
|
+
export interface AnalyticsWindow {
|
|
17
|
+
since: Date;
|
|
18
|
+
until: Date;
|
|
19
|
+
}
|
|
20
|
+
/** Why a window was refused, in the fields a caller's error class takes. */
|
|
21
|
+
export interface WindowRefusal {
|
|
22
|
+
/** The sentence the caller reads. */
|
|
23
|
+
message: string;
|
|
24
|
+
/** The cap and the dataset, for a client that translates. */
|
|
25
|
+
params?: MessageParams;
|
|
26
|
+
/** The numbers behind it, for the log. */
|
|
27
|
+
detail: string;
|
|
28
|
+
}
|
|
29
|
+
/** Builds the caller's own error for a refused window. */
|
|
30
|
+
export type RefuseWindow = (refusal: WindowRefusal) => Error;
|
|
31
|
+
/** One dataset as a read names it: the GraphQL node, and how an operator would say it. */
|
|
32
|
+
export interface MeasuredDataset {
|
|
33
|
+
dataset: string;
|
|
34
|
+
label: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The window a request asks for: the last {@link DEFAULT_WINDOW_DAYS} days by default, a `since` alone
|
|
38
|
+
* running to now, and an `until` alone reaching back the default span. A window that ends before it
|
|
39
|
+
* starts is refused.
|
|
40
|
+
*/
|
|
41
|
+
export declare function resolveAnalyticsWindow(input: {
|
|
42
|
+
since?: Date;
|
|
43
|
+
until?: Date;
|
|
44
|
+
}, now: Date, refuse: RefuseWindow): AnalyticsWindow;
|
|
45
|
+
/**
|
|
46
|
+
* Hold a window to one dataset's limits, read from the account's own settings rather than assumed. The
|
|
47
|
+
* refusal names the cap in days, so a caller can pick a window that works.
|
|
48
|
+
*/
|
|
49
|
+
export declare function checkAnalyticsWindow(window: AnalyticsWindow, limits: DatasetLimits, measured: MeasuredDataset, now: Date, refuse: RefuseWindow): void;
|
|
50
|
+
//# sourceMappingURL=window.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"window.d.ts","sourceRoot":"","sources":["../../src/analytics/window.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AACrE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAExD;;;;;;;GAOG;AAEH,iFAAiF;AACjF,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAItC,+DAA+D;AAC/D,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,IAAI,CAAC;IACZ,KAAK,EAAE,IAAI,CAAC;CACb;AAED,4EAA4E;AAC5E,MAAM,WAAW,aAAa;IAC5B,qCAAqC;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,0CAA0C;IAC1C,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,0DAA0D;AAC1D,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,EAAE,aAAa,KAAK,KAAK,CAAC;AAE7D,0FAA0F;AAC1F,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf;AAOD;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE;IAAE,KAAK,CAAC,EAAE,IAAI,CAAC;IAAC,KAAK,CAAC,EAAE,IAAI,CAAA;CAAE,EACrC,GAAG,EAAE,IAAI,EACT,MAAM,EAAE,YAAY,GACnB,eAAe,CAUjB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,eAAe,EACvB,MAAM,EAAE,aAAa,EACrB,QAAQ,EAAE,eAAe,EACzB,GAAG,EAAE,IAAI,EACT,MAAM,EAAE,YAAY,GACnB,IAAI,CAmBN"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
//#region src/analytics/window.ts
|
|
4
|
+
/**
|
|
5
|
+
* **The window an analytics read covers, and the limits it is held to (#728, #731).**
|
|
6
|
+
*
|
|
7
|
+
* Written once for every capability that reads Cloudflare's GraphQL Analytics. The rules are the same for
|
|
8
|
+
* each: the last {@link DEFAULT_WINDOW_DAYS} days by default, a window that ends before it starts refused,
|
|
9
|
+
* and a window past the dataset's retention or wider than one read may ask for refused, naming the cap in
|
|
10
|
+
* days. The refusal is each caller's own code, so it is handed to a factory rather than thrown here.
|
|
11
|
+
*/
|
|
12
|
+
/** The default window, in days, ending now. Fits every dataset the kit reads. */
|
|
13
|
+
const DEFAULT_WINDOW_DAYS = 30;
|
|
14
|
+
const DAY_MS = 864e5;
|
|
15
|
+
/** Whole days in a span of seconds, for a cap stated to a person. */
|
|
16
|
+
function days(seconds) {
|
|
17
|
+
return Math.floor(seconds / 86400);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The window a request asks for: the last {@link DEFAULT_WINDOW_DAYS} days by default, a `since` alone
|
|
21
|
+
* running to now, and an `until` alone reaching back the default span. A window that ends before it
|
|
22
|
+
* starts is refused.
|
|
23
|
+
*/
|
|
24
|
+
function resolveAnalyticsWindow(input, now, refuse) {
|
|
25
|
+
const until = input.until ?? now;
|
|
26
|
+
const since = input.since ?? /* @__PURE__ */ new Date(until.getTime() - 30 * DAY_MS);
|
|
27
|
+
if (since.getTime() >= until.getTime()) throw refuse({
|
|
28
|
+
message: "That window ends before it starts. Put since before until.",
|
|
29
|
+
detail: `since ${since.toISOString()} is not before until ${until.toISOString()}`
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
since,
|
|
33
|
+
until
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Hold a window to one dataset's limits, read from the account's own settings rather than assumed. The
|
|
38
|
+
* refusal names the cap in days, so a caller can pick a window that works.
|
|
39
|
+
*/
|
|
40
|
+
function checkAnalyticsWindow(window, limits, measured, now, refuse) {
|
|
41
|
+
const reach = (now.getTime() - window.since.getTime()) / 1e3;
|
|
42
|
+
if (reach > limits.notOlderThan) {
|
|
43
|
+
const maxDays = days(limits.notOlderThan);
|
|
44
|
+
throw refuse({
|
|
45
|
+
message: `That window starts ${days(reach)} days ago. Cloudflare keeps ${measured.label} for ${maxDays} days.`,
|
|
46
|
+
params: {
|
|
47
|
+
maxDays,
|
|
48
|
+
dataset: measured.dataset
|
|
49
|
+
},
|
|
50
|
+
detail: `${measured.dataset} notOlderThan ${limits.notOlderThan}s; window reaches ${Math.round(reach)}s`
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const span = (window.until.getTime() - window.since.getTime()) / 1e3;
|
|
54
|
+
if (span > limits.maxDuration) {
|
|
55
|
+
const maxDays = days(limits.maxDuration);
|
|
56
|
+
throw refuse({
|
|
57
|
+
message: `That window spans ${days(span)} days. Cloudflare answers at most ${maxDays} days of ${measured.label} at once.`,
|
|
58
|
+
params: {
|
|
59
|
+
maxDays,
|
|
60
|
+
dataset: measured.dataset
|
|
61
|
+
},
|
|
62
|
+
detail: `${measured.dataset} maxDuration ${limits.maxDuration}s; window spans ${Math.round(span)}s`
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
export { DEFAULT_WINDOW_DAYS, checkAnalyticsWindow, resolveAnalyticsWindow };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { CloudflareManager } from "../client/manager";
|
|
4
|
+
/**
|
|
5
|
+
* Account-level Artifacts control plane: **list and delete the repos in a namespace, then the namespace** (#673).
|
|
6
|
+
*
|
|
7
|
+
* Inside a Worker a repo is reached through the `artifacts` binding. This is the other side of that: `pithy feature
|
|
8
|
+
* destroy` reaps every repo in a feature's namespace, because repos cost storage and nothing else ever deletes one.
|
|
9
|
+
* There is no create here, because Cloudflare creates a namespace the first time a repo is created in it and the kit
|
|
10
|
+
* creates repos only from the Worker. There is a namespace delete, because nothing else removes one.
|
|
11
|
+
*
|
|
12
|
+
* The SDK this package targets exposes no Artifacts resource, so this uses the documented escape hatch — the raw
|
|
13
|
+
* token and `fetch` — against the paths [the REST API](https://developers.cloudflare.com/artifacts/api/rest-api/)
|
|
14
|
+
* publishes, and validates what comes back.
|
|
15
|
+
*/
|
|
16
|
+
export declare class CloudflareArtifactsManager extends CloudflareManager {
|
|
17
|
+
getServiceType(): string;
|
|
18
|
+
/** Prove access by listing namespaces — a read, never a write. Never throws. */
|
|
19
|
+
validateServiceAccess(): Promise<boolean>;
|
|
20
|
+
/**
|
|
21
|
+
* Every repo name in a namespace, across every page. **A namespace that does not exist has none**: Cloudflare
|
|
22
|
+
* makes one on its first repo, so a feature that never created a repo has no namespace, and teardown of it is a
|
|
23
|
+
* clean no-op rather than a failure.
|
|
24
|
+
*/
|
|
25
|
+
listRepos(namespace: string): Promise<string[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Delete one repo and every token on it. **Resolves whether one was there**, so teardown re-runs safely: a repo
|
|
28
|
+
* already gone is `false`, not an error. Cloudflare answers `202 Accepted`, so the storage may take a moment to
|
|
29
|
+
* be released after this returns.
|
|
30
|
+
*/
|
|
31
|
+
deleteRepo(namespace: string, name: string): Promise<boolean>;
|
|
32
|
+
/**
|
|
33
|
+
* Delete a namespace, once it is empty. Cloudflare makes one on the first repo and nothing else ever deletes it,
|
|
34
|
+
* so a feature's would outlive the feature. `DELETE /accounts/{id}/artifacts/namespaces/{namespace}` is in
|
|
35
|
+
* Cloudflare's published API schema, not on the Artifacts REST page: it deletes an empty namespace, answers `204`,
|
|
36
|
+
* and answers `409` while a repo remains.
|
|
37
|
+
*
|
|
38
|
+
* **Three answers, none of them a failure of teardown.** `deleted` it was there; `absent` it is already gone or was
|
|
39
|
+
* never created, so a re-run is a clean no-op; `occupied` a repo is still in it — a repo delete is `202 Accepted`,
|
|
40
|
+
* so one just reaped can hold the namespace a moment longer, and the caller decides how long to wait.
|
|
41
|
+
*/
|
|
42
|
+
deleteNamespace(namespace: string): Promise<"deleted" | "absent" | "occupied">;
|
|
43
|
+
private namespacePath;
|
|
44
|
+
private send;
|
|
45
|
+
/**
|
|
46
|
+
* One REST call, its envelope unwrapped. `null` for a 404 — the namespace or the repo is not there, which every
|
|
47
|
+
* caller here reads as nothing to do. {@link OCCUPIED} for a 409 when the caller asks for it — a namespace that
|
|
48
|
+
* still holds a repo. Any other failure is Cloudflare's refusal, in Cloudflare's words.
|
|
49
|
+
*/
|
|
50
|
+
private call;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=artifactsManager.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"artifactsManager.d.ts","sourceRoot":"","sources":["../../src/artifacts/artifactsManager.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAoCtD;;;;;;;;;;;GAWG;AACH,qBAAa,0BAA2B,SAAQ,iBAAiB;IAC/D,cAAc,IAAI,MAAM,CAEvB;IAED,gFAAgF;IAC1E,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED;;;;OAIG;IACG,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAmBpD;IAED;;;;OAIG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAOlE;IAED;;;;;;;;;OASG;IACG,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,QAAQ,GAAG,UAAU,CAAC,CAOnF;IAED,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,IAAI;IAIZ;;;;OAIG;YACW,IAAI;CAwCnB"}
|