pagesight 0.18.0 → 0.20.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/README.md +19 -0
- package/docs/changes.md +118 -0
- package/docs/cloudflare.md +31 -0
- package/docs/crawl.md +64 -0
- package/docs/investigation.md +85 -0
- package/docs/measurement.md +178 -0
- package/docs/monitoring.md +39 -0
- package/docs/opportunities.md +78 -0
- package/docs/rendering.md +102 -0
- package/docs/seo-agent-workflow.md +153 -0
- package/docs/snapshots.md +7 -0
- package/docs/usage.md +25 -17
- package/package.json +5 -2
- package/src/api/assessment.ts +315 -0
- package/src/api/change-record.ts +31 -0
- package/src/api/cloudflare.ts +201 -0
- package/src/api/compare-snapshots.ts +5 -215
- package/src/api/crawl.ts +53 -0
- package/src/api/evaluate-change.ts +189 -0
- package/src/api/execute.ts +42 -0
- package/src/api/followup-changes.ts +264 -0
- package/src/api/ga-freshness.ts +20 -0
- package/src/api/ga-realtime.ts +40 -0
- package/src/api/http-url.ts +13 -0
- package/src/api/investigation.ts +344 -0
- package/src/api/opportunities.ts +317 -0
- package/src/api/report-table.ts +216 -0
- package/src/api/reports.ts +19 -1
- package/src/api/schema.ts +162 -11
- package/src/api/snapshot.ts +20 -1
- package/src/api/technical-changes.ts +124 -0
- package/src/api/ui-findings.ts +1 -1
- package/src/api/verify-render.ts +134 -0
- package/src/assessment-text.ts +55 -0
- package/src/cli.ts +173 -38
- package/src/followup-manifest.ts +42 -0
- package/src/followup-text.ts +45 -0
- package/src/investigation-text.ts +32 -0
- package/src/opportunities-text.ts +61 -0
- package/src/providers/cloudflare.ts +46 -0
- package/src/tools/observe.ts +1 -1
- package/src/web/fetch.ts +2 -1
- package/src/web/render-browser.ts +190 -0
- package/src/web/render-dom.ts +72 -0
- package/src/web/render-network.ts +122 -0
- package/src/web/site-graph.ts +443 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { capture, type Evidence } from "./evidence.js";
|
|
3
|
+
import type { ImportedSnapshot } from "./evidence-schema.js";
|
|
4
|
+
import { configSchema, gaRequestSchema, gscRequestSchema } from "./schema.js";
|
|
5
|
+
import { snapshotOperations, observationName } from "./snapshot.js";
|
|
6
|
+
import { canonical, normalizeReport, parseNumericValue } from "./report-table.js";
|
|
7
|
+
import { normalizeGaProperty } from "../providers/ga.js";
|
|
8
|
+
|
|
9
|
+
interface Finding {
|
|
10
|
+
code: string;
|
|
11
|
+
level: "attention" | "info" | "unknown";
|
|
12
|
+
message: string;
|
|
13
|
+
sources: string[];
|
|
14
|
+
nextCheck: string;
|
|
15
|
+
}
|
|
16
|
+
export interface AssessmentTable {
|
|
17
|
+
observation: string;
|
|
18
|
+
scope: "search-property" | "production" | "production-organic" | "all-hostnames";
|
|
19
|
+
dimensions: string[];
|
|
20
|
+
metrics: string[];
|
|
21
|
+
rows: Array<{ keys: string[]; values: Array<string | number> }>;
|
|
22
|
+
observedRows: number;
|
|
23
|
+
displayedRows: number;
|
|
24
|
+
complete: boolean;
|
|
25
|
+
limitations: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function reportScope(provider: string, request: unknown) {
|
|
29
|
+
if (provider === "ga") {
|
|
30
|
+
const { offset: _offset, limit: _limit, returnPropertyQuota: _quota, ...scope } = gaRequestSchema.parse(request);
|
|
31
|
+
return scope;
|
|
32
|
+
}
|
|
33
|
+
const { startRow: _offset, rowLimit: _limit, ...scope } = gscRequestSchema.parse(request);
|
|
34
|
+
return scope;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function assessSnapshot(snapshot: ImportedSnapshot, maxRows: number): Promise<Evidence> {
|
|
38
|
+
const { context, observations } = snapshot.pages[0].response;
|
|
39
|
+
const config = configSchema.parse(context.config);
|
|
40
|
+
const { startDate, endDate } = context.requestedDates;
|
|
41
|
+
const hash = Bun.CryptoHasher.hash("sha256", canonical(snapshot), "hex");
|
|
42
|
+
const result = await capture("pagesight", "assess", snapshot.target, { snapshotSha256: hash, maxRows }, async () => {
|
|
43
|
+
const findings: Finding[] = [];
|
|
44
|
+
const tables: AssessmentTable[] = [];
|
|
45
|
+
let configuredKeyEvents: Array<{ eventName: string; countingMethod?: string }> = [];
|
|
46
|
+
const keyEventSource = observations.find((o) => o.name === "ga.key-events");
|
|
47
|
+
const add = (code: string, level: Finding["level"], message: string, sources: string[], nextCheck: string) =>
|
|
48
|
+
findings.push({ code, level, message, sources, nextCheck });
|
|
49
|
+
if (config.gaProperty) {
|
|
50
|
+
if (!config.context.successEvents.length)
|
|
51
|
+
add(
|
|
52
|
+
"no-success-events",
|
|
53
|
+
"attention",
|
|
54
|
+
"No success events are designated in the site configuration. Reported key events are not validated product outcomes.",
|
|
55
|
+
["context.config.context.successEvents"],
|
|
56
|
+
"Define the useful visitor action, then verify its tracking before evaluating SEO outcomes.",
|
|
57
|
+
);
|
|
58
|
+
else
|
|
59
|
+
add(
|
|
60
|
+
"success-events-unverified",
|
|
61
|
+
"info",
|
|
62
|
+
`Site configuration designates ${config.context.successEvents.length} success event(s); showing ${Math.min(config.context.successEvents.length, maxRows)}: ${config.context.successEvents.slice(0, maxRows).join(", ")} as success events; Pagesight has not independently validated them.`,
|
|
63
|
+
["context.config.context.successEvents"],
|
|
64
|
+
"Verify each event against a real user action and provider reports.",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (config.gaProperty) {
|
|
68
|
+
if (!keyEventSource || keyEventSource.status === "error" || !keyEventSource.pages.length)
|
|
69
|
+
add(
|
|
70
|
+
"key-event-metadata-unavailable",
|
|
71
|
+
"unknown",
|
|
72
|
+
"Configured GA key-event metadata is unavailable.",
|
|
73
|
+
["ga.key-events"],
|
|
74
|
+
"Read GA key-event metadata separately from observed event counts.",
|
|
75
|
+
);
|
|
76
|
+
else {
|
|
77
|
+
try {
|
|
78
|
+
if (
|
|
79
|
+
keyEventSource.provider !== "ga" ||
|
|
80
|
+
keyEventSource.operation !== "key-events" ||
|
|
81
|
+
keyEventSource.target !== normalizeGaProperty(config.gaProperty)
|
|
82
|
+
)
|
|
83
|
+
throw new Error();
|
|
84
|
+
const metadata = z.object({
|
|
85
|
+
keyEvents: z
|
|
86
|
+
.array(z.object({ eventName: z.string().min(1), countingMethod: z.string().optional() }))
|
|
87
|
+
.default([]),
|
|
88
|
+
nextPageToken: z.string().optional(),
|
|
89
|
+
});
|
|
90
|
+
const pages = keyEventSource.pages.map((p) => metadata.parse(p.response));
|
|
91
|
+
configuredKeyEvents = pages.flatMap((p) => p.keyEvents);
|
|
92
|
+
if (pages.some((p) => p.nextPageToken) || keyEventSource.status !== "ok" || keyEventSource.error)
|
|
93
|
+
add(
|
|
94
|
+
"key-event-metadata-partial",
|
|
95
|
+
"unknown",
|
|
96
|
+
"Only part of the configured key-event list is available.",
|
|
97
|
+
["ga.key-events"],
|
|
98
|
+
"Retrieve the remaining metadata before claiming this is the complete configuration.",
|
|
99
|
+
);
|
|
100
|
+
add(
|
|
101
|
+
"configured-key-events",
|
|
102
|
+
"info",
|
|
103
|
+
`GA configuration contains ${configuredKeyEvents.length} observed key-event entries; showing ${Math.min(configuredKeyEvents.length, maxRows)}: ${
|
|
104
|
+
configuredKeyEvents
|
|
105
|
+
.slice(0, maxRows)
|
|
106
|
+
.map((e) => `${e.eventName}${e.countingMethod ? ` (${e.countingMethod})` : ""}`)
|
|
107
|
+
.join(", ") || "no observed key-event entries"
|
|
108
|
+
}. Configuration does not prove events occurred or outcomes are valid.`,
|
|
109
|
+
["ga.key-events"],
|
|
110
|
+
"Compare configured events with observed report rows and separately validate their business meaning.",
|
|
111
|
+
);
|
|
112
|
+
} catch {
|
|
113
|
+
add(
|
|
114
|
+
"key-event-metadata-unusable",
|
|
115
|
+
"unknown",
|
|
116
|
+
"Key-event metadata does not match the configured property or supported shape.",
|
|
117
|
+
["ga.key-events"],
|
|
118
|
+
"Recollect key-event metadata for the configured property.",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const operation of snapshotOperations(config, startDate, endDate, 1)) {
|
|
124
|
+
if (operation.operation !== "ga.report" && operation.operation !== "gsc.report") continue;
|
|
125
|
+
if (
|
|
126
|
+
operation.operation === "gsc.report" &&
|
|
127
|
+
operation.request.dimensions?.some((d) => ["date", "hour"].includes(d))
|
|
128
|
+
)
|
|
129
|
+
continue;
|
|
130
|
+
const name = observationName(operation);
|
|
131
|
+
const observation = observations.find((o) => o.name === name);
|
|
132
|
+
if (!observation || observation.status === "error" || !observation.pages.length) {
|
|
133
|
+
add(
|
|
134
|
+
"report-unavailable",
|
|
135
|
+
"unknown",
|
|
136
|
+
`${name}: no usable report is available.`,
|
|
137
|
+
[name],
|
|
138
|
+
"Collect or retry this report; absence is not zero activity.",
|
|
139
|
+
);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const provider = operation.operation === "ga.report" ? "ga" : "gsc";
|
|
144
|
+
const target = operation.operation === "ga.report" ? normalizeGaProperty(operation.property) : operation.site;
|
|
145
|
+
if (observation.provider !== provider || observation.operation !== "report" || observation.target !== target)
|
|
146
|
+
throw new Error("Provider or property does not match the site configuration.");
|
|
147
|
+
for (const page of observation.pages)
|
|
148
|
+
if (canonical(reportScope(provider, page.request)) !== canonical(reportScope(provider, operation.request)))
|
|
149
|
+
throw new Error("Report filters, dates or dimensions do not match the configured snapshot report.");
|
|
150
|
+
const normalized = normalizeReport(observation, context);
|
|
151
|
+
if (
|
|
152
|
+
provider === "gsc" &&
|
|
153
|
+
normalized.dimensions.length === 0 &&
|
|
154
|
+
observation.pages.some(
|
|
155
|
+
(p) => (p.response as { responseAggregationType?: string }).responseAggregationType !== "byProperty",
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
throw new Error("GSC property totals require byProperty response aggregation.");
|
|
159
|
+
const allRows = [...normalized.rows.values()];
|
|
160
|
+
const limitations = [...new Set([...observation.warnings, ...normalized.warnings])];
|
|
161
|
+
const complete =
|
|
162
|
+
observation.status === "ok" && !observation.error && observation.pagination?.exhausted === true;
|
|
163
|
+
const sorted = [...allRows].sort((a, b) => {
|
|
164
|
+
const av = parseNumericValue(a.values[0]);
|
|
165
|
+
const bv = parseNumericValue(b.values[0]);
|
|
166
|
+
return (bv ?? -Infinity) - (av ?? -Infinity) || canonical(a.keys).localeCompare(canonical(b.keys));
|
|
167
|
+
});
|
|
168
|
+
const scope =
|
|
169
|
+
provider === "gsc"
|
|
170
|
+
? "search-property"
|
|
171
|
+
: normalized.dimensions[0] === "hostName"
|
|
172
|
+
? "all-hostnames"
|
|
173
|
+
: name.endsWith(".organic")
|
|
174
|
+
? "production-organic"
|
|
175
|
+
: "production";
|
|
176
|
+
tables.push({
|
|
177
|
+
observation: name,
|
|
178
|
+
scope,
|
|
179
|
+
dimensions: normalized.dimensions,
|
|
180
|
+
metrics: normalized.metrics,
|
|
181
|
+
rows: sorted.slice(0, maxRows),
|
|
182
|
+
observedRows: allRows.length,
|
|
183
|
+
displayedRows: Math.min(allRows.length, maxRows),
|
|
184
|
+
complete,
|
|
185
|
+
limitations,
|
|
186
|
+
});
|
|
187
|
+
if (name === "ga.report.landingPagePlusQueryString+sessionSource+eventName.organic")
|
|
188
|
+
add(
|
|
189
|
+
"organic-landing-events",
|
|
190
|
+
"info",
|
|
191
|
+
"These event occurrences are associated with organic sessions' landing pages, not necessarily the pages where the events occurred. Counts are not unique sessions, conversion rates or validated outcomes.",
|
|
192
|
+
[name, "ga.report.landingPagePlusQueryString+sessionSource.organic"],
|
|
193
|
+
"Inspect relevant event names alongside landing-page traffic. Preserve raw query strings and provider coverage; verify event meaning and instrumentation changes before prioritizing SEO work.",
|
|
194
|
+
);
|
|
195
|
+
if (normalized.dimensions[0] === "hostName") {
|
|
196
|
+
const other = allRows.filter(
|
|
197
|
+
(row) =>
|
|
198
|
+
row.keys[0] !== config.productionHostname && row.values.some((v) => (parseNumericValue(v) ?? 0) > 0),
|
|
199
|
+
);
|
|
200
|
+
if (other.length)
|
|
201
|
+
add(
|
|
202
|
+
"other-hostnames",
|
|
203
|
+
"info",
|
|
204
|
+
`The hostname census contains activity on ${other.length} other observed hostname(s): ${other
|
|
205
|
+
.slice(0, maxRows)
|
|
206
|
+
.map((r) => r.keys[0])
|
|
207
|
+
.join(
|
|
208
|
+
", ",
|
|
209
|
+
)}. This does not invalidate correctly production-filtered reports or identify internal visitors on production.`,
|
|
210
|
+
[name],
|
|
211
|
+
"Keep production reports filtered to the intended hostname; separately evaluate internal-traffic handling.",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
if (name === "ga.report.eventName") {
|
|
215
|
+
const keyIndex = normalized.metrics.indexOf("keyEvents");
|
|
216
|
+
const eventRows = allRows.filter((r) => (parseNumericValue(r.values[keyIndex]) ?? 0) > 0);
|
|
217
|
+
if (eventRows.length > maxRows)
|
|
218
|
+
add(
|
|
219
|
+
"event-findings-capped",
|
|
220
|
+
"info",
|
|
221
|
+
`Showing ${maxRows} of ${eventRows.length} observed event rows with positive key-event counts.`,
|
|
222
|
+
[name],
|
|
223
|
+
"Increase maxRows or inspect the original report for other event names.",
|
|
224
|
+
);
|
|
225
|
+
for (const row of eventRows.slice(0, maxRows)) {
|
|
226
|
+
const event = row.keys[0];
|
|
227
|
+
const excluded = config.context.excludedKeyEvents.includes(event);
|
|
228
|
+
add(
|
|
229
|
+
excluded ? "excluded-key-event" : "reported-key-event",
|
|
230
|
+
excluded ? "attention" : "info",
|
|
231
|
+
`${event}: ${row.values[keyIndex]} reported key events${excluded ? "; excluded from success metrics by site configuration" : "; business meaning is not validated by its name or count"}.`,
|
|
232
|
+
[name],
|
|
233
|
+
"Inspect the event definition and verify the real user action; do not infer its generation rule from the name.",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
for (const configured of configuredKeyEvents.slice(0, maxRows))
|
|
237
|
+
if (!allRows.some((r) => r.keys[0] === configured.eventName))
|
|
238
|
+
add(
|
|
239
|
+
"configured-event-not-observed",
|
|
240
|
+
"info",
|
|
241
|
+
`GA-configured key event ${configured.eventName} has no observed event row in this period and scope. This does not establish zero activity or broken tracking.`,
|
|
242
|
+
["ga.key-events", name],
|
|
243
|
+
"Check date, hostname and report coverage before investigating the event's instrumentation.",
|
|
244
|
+
);
|
|
245
|
+
for (const event of config.context.successEvents.slice(0, maxRows))
|
|
246
|
+
if (!allRows.some((r) => r.keys[0] === event))
|
|
247
|
+
add(
|
|
248
|
+
"success-event-not-observed",
|
|
249
|
+
"unknown",
|
|
250
|
+
`Configured success event ${event} has no observed row in this report; that is not proof of zero events or broken tracking.`,
|
|
251
|
+
[name, "context.config.context.successEvents"],
|
|
252
|
+
"Check report coverage, dates, filters and a real user flow.",
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
add(
|
|
257
|
+
"report-unusable",
|
|
258
|
+
"unknown",
|
|
259
|
+
`${name}: ${error instanceof Error && error.name !== "ZodError" ? error.message : "Request or response does not match the supported report shape."}`,
|
|
260
|
+
[name],
|
|
261
|
+
"Inspect the original report and recollect a compatible snapshot before drawing conclusions.",
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
for (const observation of observations.filter((o) => o.status !== "ok" || o.error))
|
|
266
|
+
add(
|
|
267
|
+
"provider-incomplete",
|
|
268
|
+
"unknown",
|
|
269
|
+
`${observation.name}: ${observation.status}${observation.error ? ` (${observation.error.code})` : ""}.`,
|
|
270
|
+
[observation.name],
|
|
271
|
+
"Inspect the retained provider error or pagination before relying on missing data.",
|
|
272
|
+
);
|
|
273
|
+
return {
|
|
274
|
+
assessmentVersion: 1,
|
|
275
|
+
site: config.site,
|
|
276
|
+
requestedDates: context.requestedDates,
|
|
277
|
+
collectedAt: snapshot.finishedAt,
|
|
278
|
+
basis: "saved-snapshot-not-reverified",
|
|
279
|
+
snapshotSha256: hash,
|
|
280
|
+
providerSelection: {
|
|
281
|
+
gsc: Boolean(config.gscSite),
|
|
282
|
+
ga: Boolean(config.gaProperty),
|
|
283
|
+
bing: Boolean(config.bingSite),
|
|
284
|
+
},
|
|
285
|
+
findings,
|
|
286
|
+
tables,
|
|
287
|
+
displayLimit: maxRows,
|
|
288
|
+
observations: observations.map((o) => ({
|
|
289
|
+
name: o.name,
|
|
290
|
+
provider: o.provider,
|
|
291
|
+
status: o.status,
|
|
292
|
+
warnings: o.warnings,
|
|
293
|
+
errorCode: o.error?.code ?? null,
|
|
294
|
+
})),
|
|
295
|
+
limitations: [
|
|
296
|
+
...new Set([
|
|
297
|
+
...snapshot.warnings,
|
|
298
|
+
"This assessment describes supplied snapshot evidence; it makes no new provider requests and does not authenticate imported claims. The hash identifies normalized snapshot JSON, not file bytes.",
|
|
299
|
+
"Configured success events are caller-designated, not independently validated. Aggregate reports cannot diagnose duplicate tags, prove a particular browser test arrived, or establish SEO causation.",
|
|
300
|
+
"Tables retain raw values for observed rows; capped lists are not exhaustive rankings. GSC property, page and query counts and GA sessions have different semantics and must not be reconciled as a funnel.",
|
|
301
|
+
]),
|
|
302
|
+
],
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
const assessment = result.pages[0]?.response as { findings: Finding[]; tables: AssessmentTable[] } | undefined;
|
|
306
|
+
if (
|
|
307
|
+
result.status !== "error" &&
|
|
308
|
+
(snapshot.status !== "ok" ||
|
|
309
|
+
snapshot.error ||
|
|
310
|
+
assessment?.findings.some((f) => f.level === "unknown") ||
|
|
311
|
+
assessment?.tables.some((t) => !t.complete))
|
|
312
|
+
)
|
|
313
|
+
result.status = "partial";
|
|
314
|
+
return result;
|
|
315
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { httpUrl } from "./http-url.js";
|
|
3
|
+
|
|
4
|
+
const contextChange = z.object({ at: z.string().datetime(), description: z.string().min(1).max(2000) }).strict();
|
|
5
|
+
export const changeRecordSchema = z
|
|
6
|
+
.object({
|
|
7
|
+
schemaVersion: z.literal(1),
|
|
8
|
+
id: z.string().min(1).max(200),
|
|
9
|
+
site: httpUrl,
|
|
10
|
+
affectedUrls: z.array(httpUrl).min(1).max(100),
|
|
11
|
+
description: z.string().min(1).max(4000),
|
|
12
|
+
hypothesis: z.string().min(1).max(4000),
|
|
13
|
+
deployedAt: z.string().datetime(),
|
|
14
|
+
expectedSignal: z.string().min(1).max(2000),
|
|
15
|
+
measurementChanges: z.array(contextChange).max(100),
|
|
16
|
+
overlappingChanges: z.array(contextChange).max(100),
|
|
17
|
+
})
|
|
18
|
+
.strict()
|
|
19
|
+
.superRefine((record, ctx) => {
|
|
20
|
+
for (const [index, url] of record.affectedUrls.entries()) {
|
|
21
|
+
if (new URL(url).origin !== new URL(record.site).origin)
|
|
22
|
+
ctx.addIssue({
|
|
23
|
+
code: "custom",
|
|
24
|
+
path: ["affectedUrls", index],
|
|
25
|
+
message: "Affected URL must share the site's origin",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
if (Date.parse(record.deployedAt) > Date.now())
|
|
29
|
+
ctx.addIssue({ code: "custom", path: ["deployedAt"], message: "Deployment must not be in the future" });
|
|
30
|
+
});
|
|
31
|
+
export type ChangeRecord = z.infer<typeof changeRecordSchema>;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { cloudflareGraphql, type CloudflareQuery } from "../providers/cloudflare.js";
|
|
3
|
+
import { aggregate, capture, type Evidence } from "./evidence.js";
|
|
4
|
+
|
|
5
|
+
const responseSchema = z
|
|
6
|
+
.object({
|
|
7
|
+
data: z
|
|
8
|
+
.object({ viewer: z.object({ zones: z.array(z.object({ zoneTag: z.string() }).passthrough()) }).nullable() })
|
|
9
|
+
.nullable()
|
|
10
|
+
.optional(),
|
|
11
|
+
errors: z.array(z.unknown()).nullable().optional(),
|
|
12
|
+
})
|
|
13
|
+
.passthrough();
|
|
14
|
+
type Input = { zone: string; hostname: string; startTime: string; endTime: string; limit: number };
|
|
15
|
+
// AIDEV-NOTE: Fixed selections exclude IPs, query strings and credentials. New fields change the privacy contract.
|
|
16
|
+
export function cloudflareRequests(input: Input): Array<{ name: string; dataset: string; request: CloudflareQuery }> {
|
|
17
|
+
const zoneVar = { zone: input.zone };
|
|
18
|
+
const filters = {
|
|
19
|
+
datetime_geq: input.startTime,
|
|
20
|
+
datetime_lt: input.endTime,
|
|
21
|
+
clientRequestHTTPHost: input.hostname.toLowerCase(),
|
|
22
|
+
};
|
|
23
|
+
return [
|
|
24
|
+
{
|
|
25
|
+
name: "settings",
|
|
26
|
+
dataset: "settings",
|
|
27
|
+
request: {
|
|
28
|
+
query: `query Settings($zone: string) { viewer { zones(filter: {zoneTag: $zone}) { zoneTag settings { httpRequestsAdaptiveGroups { enabled maxDuration maxPageSize notOlderThan availableFields } firewallEventsAdaptive { enabled maxDuration maxPageSize notOlderThan availableFields } } } } }`,
|
|
29
|
+
variables: zoneVar,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "http",
|
|
34
|
+
dataset: "httpRequestsAdaptiveGroups",
|
|
35
|
+
request: {
|
|
36
|
+
query: `query Http($zone: string, $filter: ZoneHttpRequestsAdaptiveGroupsFilter_InputObject, $limit: uint64) { viewer { zones(filter: {zoneTag: $zone}) { zoneTag httpRequestsAdaptiveGroups(filter: $filter, limit: $limit, orderBy: [count_DESC]) { count avg { sampleInterval } dimensions { clientRequestHTTPHost clientRequestPath edgeResponseStatus originResponseStatus cacheStatus userAgent verifiedBotCategory } } } } }`,
|
|
37
|
+
variables: { ...zoneVar, filter: filters, limit: input.limit },
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "security",
|
|
42
|
+
dataset: "firewallEventsAdaptive",
|
|
43
|
+
request: {
|
|
44
|
+
query: `query Security($zone: string, $filter: FirewallEventsAdaptiveFilter_InputObject, $limit: uint64) { viewer { zones(filter: {zoneTag: $zone}) { zoneTag firewallEventsAdaptive(filter: $filter, limit: $limit, orderBy: [datetime_DESC]) { datetime action source clientRequestHTTPHost clientRequestPath edgeResponseStatus userAgent verifiedBotCategory sampleInterval } } } }`,
|
|
45
|
+
variables: { ...zoneVar, filter: filters, limit: input.limit },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function cloudflareAudit(input: Input, query = cloudflareGraphql): Promise<Evidence> {
|
|
52
|
+
const observations: Evidence[] = [];
|
|
53
|
+
for (const planned of cloudflareRequests(input)) {
|
|
54
|
+
const observation = await capture("cloudflare", planned.name, input.zone, planned.request, () =>
|
|
55
|
+
query(planned.request),
|
|
56
|
+
);
|
|
57
|
+
observation.name = `cloudflare.${planned.name}`;
|
|
58
|
+
const raw = observation.pages[0]?.response;
|
|
59
|
+
if (raw !== undefined) {
|
|
60
|
+
const parsed = responseSchema.safeParse(raw);
|
|
61
|
+
const zones = parsed.success ? parsed.data.data?.viewer?.zones : undefined;
|
|
62
|
+
const zone = zones?.length === 1 && zones[0].zoneTag === input.zone ? zones[0] : undefined;
|
|
63
|
+
const usable = Boolean(zone && Object.hasOwn(zone, planned.dataset) && zone[planned.dataset] !== null);
|
|
64
|
+
if (!parsed.success || !usable || parsed.data.errors?.length) {
|
|
65
|
+
observation.status = usable ? "partial" : "error";
|
|
66
|
+
observation.error = {
|
|
67
|
+
code: parsed.success ? "graphql_error" : "invalid_response",
|
|
68
|
+
message: "Cloudflare returned unavailable or incomplete dataset evidence; inspect retained GraphQL response",
|
|
69
|
+
httpStatus: 200,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
observation.credential = { source: "CLOUDFLARE_API_TOKEN", type: "api_token", clientEmail: null };
|
|
74
|
+
observation.warnings.push(
|
|
75
|
+
"Adaptive datasets can contain estimated/sampled values. Retain sampleInterval; do not multiply already estimated counts again.",
|
|
76
|
+
"Dataset availability, retained history and maximum query duration depend on zone settings. Missing or denied data is unknown, not zero.",
|
|
77
|
+
);
|
|
78
|
+
if (planned.name !== "settings")
|
|
79
|
+
observation.warnings.push(
|
|
80
|
+
`At most ${input.limit} ${planned.name === "http" ? "groups ordered by count descending" : "security events ordered newest first"} are retained; this is not exhaustive traffic or crawler coverage.`,
|
|
81
|
+
);
|
|
82
|
+
observations.push(observation);
|
|
83
|
+
}
|
|
84
|
+
const diagnostics: Array<{
|
|
85
|
+
dataset: string;
|
|
86
|
+
observedRows: number | null;
|
|
87
|
+
possiblyTruncated: boolean;
|
|
88
|
+
effectiveRowLimit: number;
|
|
89
|
+
unusableRows: number;
|
|
90
|
+
collectedAt: string;
|
|
91
|
+
errorPaths: unknown[];
|
|
92
|
+
}> = [];
|
|
93
|
+
const unknowns: string[] = [];
|
|
94
|
+
const settingsSchema = z.object({
|
|
95
|
+
enabled: z.boolean(),
|
|
96
|
+
maxDuration: z.number().nonnegative(),
|
|
97
|
+
maxPageSize: z.number().int().positive().optional(),
|
|
98
|
+
notOlderThan: z.number().nonnegative(),
|
|
99
|
+
availableFields: z.array(z.string()),
|
|
100
|
+
});
|
|
101
|
+
const settingsObservation = observations[0];
|
|
102
|
+
const settingsRaw = responseSchema.safeParse(settingsObservation.pages[0]?.response);
|
|
103
|
+
const settingsZone = settingsRaw.success
|
|
104
|
+
? settingsRaw.data.data?.viewer?.zones?.find((z) => z.zoneTag === input.zone)
|
|
105
|
+
: undefined;
|
|
106
|
+
const settings = settingsZone?.settings as Record<string, unknown> | undefined;
|
|
107
|
+
for (let index = 1; index < observations.length; index++) {
|
|
108
|
+
const observation = observations[index];
|
|
109
|
+
const dataset = index === 1 ? "httpRequestsAdaptiveGroups" : "firewallEventsAdaptive";
|
|
110
|
+
const parsed = responseSchema.safeParse(observation.pages[0]?.response);
|
|
111
|
+
const zone = parsed.success ? parsed.data.data?.viewer?.zones?.find((z) => z.zoneTag === input.zone) : undefined;
|
|
112
|
+
const rows = Array.isArray(zone?.[dataset]) ? (zone[dataset] as unknown[]) : null;
|
|
113
|
+
const unusableRows =
|
|
114
|
+
rows?.filter((row) => {
|
|
115
|
+
if (!row || typeof row !== "object") return true;
|
|
116
|
+
const value = row as Record<string, unknown>;
|
|
117
|
+
const dimensions = value.dimensions as Record<string, unknown> | undefined;
|
|
118
|
+
const hostname = index === 1 ? dimensions?.clientRequestHTTPHost : value.clientRequestHTTPHost;
|
|
119
|
+
return typeof hostname !== "string" || hostname.toLowerCase() !== input.hostname.toLowerCase();
|
|
120
|
+
}).length ?? 0;
|
|
121
|
+
const declared = settingsObservation.status === "ok" ? settingsSchema.safeParse(settings?.[dataset]) : null;
|
|
122
|
+
if (!declared?.success)
|
|
123
|
+
unknowns.push(`${dataset}: settings unavailable or incomplete; retention/availability are unknown.`);
|
|
124
|
+
else {
|
|
125
|
+
if (!declared.data.enabled) unknowns.push(`${dataset}: dataset is currently disabled for this zone.`);
|
|
126
|
+
if (Date.parse(input.startTime) < Date.parse(settingsObservation.finishedAt) - declared.data.notOlderThan * 1000)
|
|
127
|
+
unknowns.push(`${dataset}: requested start is outside the currently declared retention window.`);
|
|
128
|
+
if (Date.parse(input.endTime) - Date.parse(input.startTime) > declared.data.maxDuration * 1000)
|
|
129
|
+
unknowns.push(`${dataset}: interval exceeds currently declared maximum duration.`);
|
|
130
|
+
}
|
|
131
|
+
const providerRowLimit = declared?.success ? declared.data.maxPageSize : undefined;
|
|
132
|
+
const effectiveRowLimit = Math.min(input.limit, providerRowLimit ?? input.limit);
|
|
133
|
+
if (providerRowLimit === undefined)
|
|
134
|
+
unknowns.push(`${dataset}: provider maximum page size unavailable; provider truncation is unknown.`);
|
|
135
|
+
if (rows && rows.length >= effectiveRowLimit)
|
|
136
|
+
unknowns.push(`${dataset}: retained row limit reached; additional rows may exist.`);
|
|
137
|
+
if (rows?.length === 0)
|
|
138
|
+
unknowns.push(`${dataset}: empty retained set, not proof of zero traffic or security actions.`);
|
|
139
|
+
if (unusableRows) {
|
|
140
|
+
unknowns.push(
|
|
141
|
+
`${dataset}: ${unusableRows} rows do not match the requested hostname/shape; raw data retained without association.`,
|
|
142
|
+
);
|
|
143
|
+
if (observation.status === "ok") observation.status = "partial";
|
|
144
|
+
}
|
|
145
|
+
if (!rows && observation.status === "ok") {
|
|
146
|
+
observation.status = "error";
|
|
147
|
+
observation.error = {
|
|
148
|
+
code: "invalid_response",
|
|
149
|
+
message: "Cloudflare dataset is not a row array",
|
|
150
|
+
httpStatus: 200,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const issues = unknowns.filter((u) => u.startsWith(`${dataset}:`));
|
|
154
|
+
observation.warnings.push(...issues);
|
|
155
|
+
diagnostics.push({
|
|
156
|
+
dataset,
|
|
157
|
+
observedRows: rows?.length ?? null,
|
|
158
|
+
possiblyTruncated: Boolean(rows && rows.length >= effectiveRowLimit),
|
|
159
|
+
effectiveRowLimit,
|
|
160
|
+
unusableRows,
|
|
161
|
+
collectedAt: observation.finishedAt,
|
|
162
|
+
errorPaths: parsed.success
|
|
163
|
+
? (parsed.data.errors ?? [])
|
|
164
|
+
.slice(0, 3)
|
|
165
|
+
.map((e) => (e && typeof e === "object" ? ("path" in e ? e.path : null) : null))
|
|
166
|
+
: [],
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const result = aggregate("cloudflare.audit", input.zone, input, observations);
|
|
170
|
+
Object.assign(result.pages[0].response as object, {
|
|
171
|
+
scope: {
|
|
172
|
+
zone: input.zone,
|
|
173
|
+
hostname: input.hostname,
|
|
174
|
+
startInclusive: input.startTime,
|
|
175
|
+
endExclusive: input.endTime,
|
|
176
|
+
timezone: "UTC",
|
|
177
|
+
limit: input.limit,
|
|
178
|
+
ordering: { http: "count_DESC", security: "datetime_DESC" },
|
|
179
|
+
querySignature: Bun.CryptoHasher.hash(
|
|
180
|
+
"sha256",
|
|
181
|
+
JSON.stringify(cloudflareRequests(input).map((p) => p.request.query)),
|
|
182
|
+
"hex",
|
|
183
|
+
),
|
|
184
|
+
settingsCollectedAt: settingsObservation.finishedAt,
|
|
185
|
+
settingsMeaning: "Current zone settings, not proof of settings during the historical reporting window",
|
|
186
|
+
},
|
|
187
|
+
diagnostics,
|
|
188
|
+
unknowns,
|
|
189
|
+
limitations: [
|
|
190
|
+
"No path joins to GSC/GA: Cloudflare paths omit query strings. Cloudflare is not supported by Pagesight report comparisons or conversion funnels.",
|
|
191
|
+
"HTTP traffic is not verified search-engine crawling. User-Agent can be spoofed; verifiedBotCategory is Cloudflare's classification, not proof of Google indexing or a particular crawler identity.",
|
|
192
|
+
"Security events describe observed actions, not all requests or all challenges. Do not reconcile sampled event counts with HTTP groups as a funnel.",
|
|
193
|
+
"Edge status and origin status differ; cached/edge-handled requests may have no origin response. Status zero does not mean successful origin access.",
|
|
194
|
+
"No client IP, request query string, cookies or authentication headers are requested. Paths and user agents may still be sensitive: keep reports private.",
|
|
195
|
+
"Only read-only GraphQL analytics queries execute; no Cloudflare security, cache, DNS or crawler settings are modified.",
|
|
196
|
+
],
|
|
197
|
+
});
|
|
198
|
+
result.credential = { source: "CLOUDFLARE_API_TOKEN", type: "api_token", clientEmail: null };
|
|
199
|
+
if (result.status === "ok" && unknowns.length) result.status = "partial";
|
|
200
|
+
return result;
|
|
201
|
+
}
|