@getquick/content 0.0.0-stage → 0.2.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.
Files changed (39) hide show
  1. package/README.md +179 -2
  2. package/consent-migrations/0001_consent_records.sql +34 -0
  3. package/dist/bearer-DMOCoSci.js +16 -0
  4. package/dist/client-config-B3LNSOxl.js +4 -0
  5. package/dist/configuration-C_JGygQI.d.ts +259 -0
  6. package/dist/consent-astro.d.ts +36 -0
  7. package/dist/consent-astro.js +94 -0
  8. package/dist/consent-client.d.ts +37 -0
  9. package/dist/consent-client.js +321 -0
  10. package/dist/consent-migrations.d.ts +8 -0
  11. package/dist/consent-migrations.js +10 -0
  12. package/dist/consent-server.d.ts +77 -0
  13. package/dist/consent-server.js +292 -0
  14. package/dist/consent-testing.d.ts +96 -0
  15. package/dist/consent-testing.js +188 -0
  16. package/dist/consent.d.ts +114 -0
  17. package/dist/consent.js +5 -0
  18. package/dist/embeds-p9SyGNZI.js +344 -0
  19. package/dist/formatting-wOk2jz6J.js +276 -0
  20. package/dist/index.d.ts +75 -0
  21. package/dist/index.js +39 -0
  22. package/dist/legal-C5Xiq59W.d.ts +48 -0
  23. package/dist/migrations.d.ts +4 -0
  24. package/dist/migrations.js +6 -0
  25. package/dist/paths-Cmzzof3v.js +5 -0
  26. package/dist/records-CACA2O0Q.d.ts +56 -0
  27. package/dist/records-Dumrz759.js +74 -0
  28. package/dist/server.d.ts +2 -0
  29. package/dist/server.js +6465 -0
  30. package/dist/state-BYWc5TUB.js +559 -0
  31. package/dist/state-Dzd52F3c.d.ts +77 -0
  32. package/dist/version-DyyNhsuU.js +6 -0
  33. package/dist/wordpress-CUFb6YZZ.d.ts +384 -0
  34. package/migrations/0001_publications.sql +31 -0
  35. package/migrations/0002_entries.sql +14 -0
  36. package/migrations/0003_publication_events.sql +36 -0
  37. package/migrations/0004_withdrawals.sql +30 -0
  38. package/migrations/0005_reconciliation.sql +45 -0
  39. package/package.json +56 -3
@@ -0,0 +1,292 @@
1
+ import { t as bearerMatches } from "./bearer-DMOCoSci.js";
2
+ import { i as DECISION_VALIDITY_MS, l as isConsentId, t as CONSENT_ACTIONS, x as OPTIONAL_CATEGORIES } from "./state-BYWc5TUB.js";
3
+ import { a as parseConsentRecordRequest } from "./records-Dumrz759.js";
4
+ import { t as CONSENT_PATH } from "./paths-Cmzzof3v.js";
5
+ //#region frontend/src/consent/server.ts
6
+ const MAX_RETENTION_DAYS = 1826;
7
+ const DAY_MS = 864e5;
8
+ const MAX_RANGE_DAYS = 366;
9
+ /** The CSV export's columns, in order (documented; stable within the schema version). */
10
+ const CONSENT_EXPORT_COLUMNS = [
11
+ "recorded_at",
12
+ "consent_id",
13
+ "site_id",
14
+ "action",
15
+ "config_version",
16
+ "language",
17
+ "ui_version",
18
+ "preferences",
19
+ "analytics",
20
+ "marketing",
21
+ "external_media",
22
+ "legal_revisions"
23
+ ];
24
+ function recordOf(row) {
25
+ let legalRevisions = {};
26
+ try {
27
+ legalRevisions = JSON.parse(row.legal_revisions);
28
+ } catch {}
29
+ return {
30
+ consentId: row.consent_id,
31
+ siteId: row.site_id,
32
+ recordedAt: row.recorded_at,
33
+ configVersion: row.config_version,
34
+ legalRevisions,
35
+ decisions: {
36
+ preferences: row.preferences === 1,
37
+ analytics: row.analytics === 1,
38
+ marketing: row.marketing === 1,
39
+ external_media: row.external_media === 1
40
+ },
41
+ action: row.action,
42
+ language: row.language,
43
+ uiVersion: row.ui_version
44
+ };
45
+ }
46
+ const json = (status, body, headers = {}) => Response.json(body, {
47
+ status,
48
+ headers: {
49
+ "Cache-Control": "no-store",
50
+ ...headers
51
+ }
52
+ });
53
+ /** A UTC day (YYYY-MM-DD) as its first instant, or null. */
54
+ function day(value) {
55
+ if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
56
+ const time = Date.parse(`${value}T00:00:00.000Z`);
57
+ return Number.isNaN(time) || new Date(time).toISOString().slice(0, 10) !== value ? null : time;
58
+ }
59
+ function csvField(value) {
60
+ return /[",\r\n]/.test(value) ? `"${value.replaceAll("\"", "\"\"")}"` : value;
61
+ }
62
+ /**
63
+ * The consent endpoints. `handle` routes `/gq/consent` (record writes) and
64
+ * `/gq/consent/records/<id>`, `/gq/consent/export`, `/gq/consent/stats`
65
+ * (authenticated reads), and answers null for any other path. `purge` is the
66
+ * scheduled retention job.
67
+ */
68
+ function createConsentServer(config) {
69
+ const siteId = config.siteId.trim();
70
+ if (!siteId) throw new Error("A nonempty Site identity is required");
71
+ const retentionDays = config.retentionDays ?? 1826;
72
+ if (!Number.isInteger(retentionDays) || retentionDays < 1 || retentionDays > 1826) throw new Error(`retentionDays must be a whole number of days from 1 to ${MAX_RETENTION_DAYS}`);
73
+ const now = config.now ?? Date.now;
74
+ const origins = new Set((config.siteOrigins ?? []).map((origin) => new URL(origin).origin));
75
+ /** CN-R9: same-origin only. A browser's cross-site post carries its Origin. */
76
+ function sameOrigin(request) {
77
+ const origin = request.headers.get("Origin");
78
+ const site = request.headers.get("Sec-Fetch-Site");
79
+ if (site && site !== "same-origin") return false;
80
+ if (!origin) return false;
81
+ if (origin === new URL(request.url).origin || origins.has(origin)) return true;
82
+ try {
83
+ return new URL(origin).host === request.headers.get("Host");
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+ async function readBody(request) {
89
+ if (Number(request.headers.get("Content-Length") ?? "0") > 2048) return null;
90
+ if (!request.body) return "";
91
+ const reader = request.body.getReader();
92
+ const chunks = [];
93
+ let size = 0;
94
+ for (;;) {
95
+ const { done, value } = await reader.read();
96
+ if (done) break;
97
+ size += value.byteLength;
98
+ if (size > 2048) {
99
+ await reader.cancel();
100
+ return null;
101
+ }
102
+ chunks.push(value);
103
+ }
104
+ const bytes = new Uint8Array(size);
105
+ let offset = 0;
106
+ for (const chunk of chunks) {
107
+ bytes.set(chunk, offset);
108
+ offset += chunk.byteLength;
109
+ }
110
+ return new TextDecoder().decode(bytes);
111
+ }
112
+ async function insert(db, request) {
113
+ const recordedAt = now();
114
+ return (await db.prepare(`INSERT OR IGNORE INTO consent_records (consent_id, site_id, recorded_at, decided_at, expires_at,
115
+ config_version, legal_revisions, preferences, analytics, marketing, external_media, action,
116
+ language, ui_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).bind(request.consentId, siteId, new Date(recordedAt).toISOString(), request.decidedAt, new Date(recordedAt + DECISION_VALIDITY_MS).toISOString(), request.configVersion, JSON.stringify(request.legalRevisions), ...OPTIONAL_CATEGORIES.map((category) => request.decisions[category] ? 1 : 0), request.action, request.language, request.uiVersion).run()).meta.changes > 0;
117
+ }
118
+ /** `POST /gq/consent`: appends one decision (CN-R7–R9). */
119
+ async function handleRecord(request) {
120
+ if (request.method !== "POST") return json(405, { error: "Record decisions with POST." }, { Allow: "POST" });
121
+ if (!sameOrigin(request)) return json(403, { error: "Consent records are accepted from this Site only." });
122
+ if (!/^application\/json\b/i.test(request.headers.get("Content-Type") ?? "")) return json(415, { error: "Send the record as application/json." });
123
+ const bindings = await config.bindings();
124
+ const limiter = bindings.CONSENT_RATE_LIMITER;
125
+ if (limiter) {
126
+ const key = request.headers.get("CF-Connecting-IP") ?? "unknown";
127
+ const { success } = await limiter.limit({ key });
128
+ if (!success) return json(429, { error: "Too many consent records; try again later." });
129
+ }
130
+ const text = await readBody(request);
131
+ if (text === null) return json(413, { error: "The record is too large." });
132
+ let value;
133
+ try {
134
+ value = JSON.parse(text);
135
+ } catch {
136
+ return json(400, { error: "The record is not JSON." });
137
+ }
138
+ const parsed = parseConsentRecordRequest(value);
139
+ if (!parsed.ok) return json(400, { error: `Invalid record: ${parsed.error}.` });
140
+ const db = bindings.CONSENT_DB;
141
+ if (!db) return json(503, { error: "Consent records are not configured (CONSENT_DB)." });
142
+ try {
143
+ const appended = await insert(db, parsed.request);
144
+ return json(appended ? 201 : 200, {
145
+ recorded: true,
146
+ duplicate: !appended
147
+ });
148
+ } catch (error) {
149
+ console.error(`Consent: a record couldn't be written: ${error.message}`);
150
+ return json(503, { error: "The consent record couldn't be stored; try again." });
151
+ }
152
+ }
153
+ async function authorizedDb(request) {
154
+ const bindings = await config.bindings();
155
+ const token = bindings.CONSENT_READ_TOKEN?.trim() ?? "";
156
+ if (token.length < 32 || !bindings.CONSENT_DB) return { response: json(503, { error: "Consent reads are not configured (CONSENT_DB, CONSENT_READ_TOKEN)." }) };
157
+ if (!await bearerMatches(request, token)) return { response: json(401, { error: "A valid bearer token is required." }, { "WWW-Authenticate": "Bearer" }) };
158
+ return { db: bindings.CONSENT_DB };
159
+ }
160
+ function range(url) {
161
+ const from = day(url.searchParams.get("from"));
162
+ const to = day(url.searchParams.get("to"));
163
+ if (from === null || to === null || to < from || (to - from) / DAY_MS >= MAX_RANGE_DAYS) return null;
164
+ return {
165
+ from: url.searchParams.get("from"),
166
+ to: url.searchParams.get("to"),
167
+ start: new Date(from).toISOString(),
168
+ end: new Date(to + DAY_MS).toISOString()
169
+ };
170
+ }
171
+ const columns = `consent_id, site_id, recorded_at, config_version, legal_revisions, preferences,
172
+ analytics, marketing, external_media, action, language, ui_version`;
173
+ /** Authenticated reads (CN-R11). */
174
+ async function handleRead(request) {
175
+ if (request.method !== "GET") return json(405, { error: "Read consent records with GET." }, { Allow: "GET" });
176
+ const authorized = await authorizedDb(request);
177
+ if ("response" in authorized) return authorized.response;
178
+ const { db } = authorized;
179
+ const url = new URL(request.url);
180
+ const path = url.pathname.replace(/\/+$/, "");
181
+ try {
182
+ if (path.startsWith(`/gq/consent/records/`)) {
183
+ const consentId = path.slice(`${CONSENT_PATH}/records/`.length);
184
+ if (!isConsentId(consentId)) return json(400, { error: "Not a consent ID." });
185
+ const { results } = await db.prepare(`SELECT ${columns} FROM consent_records WHERE consent_id = ? ORDER BY id`).bind(consentId).all();
186
+ if (!results.length) return json(404, { error: "No records for this consent ID." });
187
+ return json(200, {
188
+ consentId,
189
+ records: results.map(recordOf)
190
+ });
191
+ }
192
+ if (path === `/gq/consent/export` || path === `/gq/consent/stats`) {
193
+ const window = range(url);
194
+ if (!window) return json(400, { error: `from and to must be UTC days (YYYY-MM-DD), from ≤ to, at most ${MAX_RANGE_DAYS} days.` });
195
+ if (path.endsWith("/export")) {
196
+ const { results } = await db.prepare(`SELECT ${columns} FROM consent_records WHERE recorded_at >= ? AND recorded_at < ? ORDER BY recorded_at, id`).bind(window.start, window.end).all();
197
+ const lines = [CONSENT_EXPORT_COLUMNS.join(",")];
198
+ for (const row of results) {
199
+ const record = recordOf(row);
200
+ lines.push([
201
+ record.recordedAt,
202
+ record.consentId,
203
+ record.siteId,
204
+ record.action,
205
+ record.configVersion,
206
+ record.language,
207
+ record.uiVersion,
208
+ ...OPTIONAL_CATEGORIES.map((category) => String(record.decisions[category])),
209
+ JSON.stringify(record.legalRevisions)
210
+ ].map(csvField).join(","));
211
+ }
212
+ return new Response(lines.join("\r\n") + "\r\n", { headers: {
213
+ "Content-Type": "text/csv; charset=utf-8",
214
+ "Content-Disposition": `attachment; filename="consent-${window.from}-${window.to}.csv"`,
215
+ "Cache-Control": "no-store"
216
+ } });
217
+ }
218
+ const counts = await db.prepare(`SELECT action, COUNT(*) AS total, SUM(preferences) AS preferences, SUM(analytics) AS analytics,
219
+ SUM(marketing) AS marketing, SUM(external_media) AS external_media
220
+ FROM consent_records WHERE recorded_at >= ? AND recorded_at < ? GROUP BY action`).bind(window.start, window.end).all();
221
+ const byAction = Object.fromEntries(CONSENT_ACTIONS.map((action) => [action, 0]));
222
+ const byCategory = Object.fromEntries(OPTIONAL_CATEGORIES.map((category) => [category, {
223
+ granted: 0,
224
+ denied: 0
225
+ }]));
226
+ let total = 0;
227
+ for (const row of counts.results) {
228
+ const count = Number(row.total ?? 0);
229
+ total += count;
230
+ byAction[row.action] = count;
231
+ for (const category of OPTIONAL_CATEGORIES) {
232
+ const granted = Number(row[category] ?? 0);
233
+ byCategory[category].granted += granted;
234
+ byCategory[category].denied += count - granted;
235
+ }
236
+ }
237
+ return json(200, {
238
+ from: window.from,
239
+ to: window.to,
240
+ total,
241
+ byAction,
242
+ byCategory
243
+ });
244
+ }
245
+ return json(404, { error: "Not a consent endpoint." });
246
+ } catch (error) {
247
+ console.error(`Consent: a read failed: ${error.message}`);
248
+ return json(503, { error: "Consent records couldn't be read; try again." });
249
+ }
250
+ }
251
+ /**
252
+ * CN-R10: deletes records whose decision stopped being in force (superseded
253
+ * by a later one, or expired) more than the retention period ago.
254
+ */
255
+ async function purge() {
256
+ const db = (await config.bindings()).CONSENT_DB;
257
+ if (!db) throw new Error("Consent records are not configured (CONSENT_DB)");
258
+ const cutoff = (/* @__PURE__ */ new Date(now() - retentionDays * DAY_MS)).toISOString();
259
+ return { deleted: (await db.prepare(`DELETE FROM consent_records WHERE id IN (
260
+ SELECT r.id FROM consent_records r WHERE MIN(
261
+ r.expires_at,
262
+ COALESCE((SELECT MIN(n.recorded_at) FROM consent_records n
263
+ WHERE n.consent_id = r.consent_id AND n.id > r.id), r.expires_at)
264
+ ) < ?)`).bind(cutoff).run()).meta.changes };
265
+ }
266
+ /** What is configured, for readiness: never values. */
267
+ async function check() {
268
+ const bindings = await config.bindings();
269
+ return {
270
+ records: Boolean(bindings.CONSENT_DB),
271
+ reads: Boolean(bindings.CONSENT_DB) && (bindings.CONSENT_READ_TOKEN?.trim().length ?? 0) >= 32,
272
+ rateLimit: Boolean(bindings.CONSENT_RATE_LIMITER),
273
+ retentionDays
274
+ };
275
+ }
276
+ /** Routes a consent request; null when the path isn't a consent endpoint. */
277
+ async function handle(request) {
278
+ const path = new URL(request.url).pathname.replace(/\/+$/, "");
279
+ if (path === "/gq/consent") return handleRecord(request);
280
+ if (path.startsWith(`/gq/consent/`)) return handleRead(request);
281
+ return null;
282
+ }
283
+ return {
284
+ handle,
285
+ handleRecord,
286
+ handleRead,
287
+ purge,
288
+ check
289
+ };
290
+ }
291
+ //#endregion
292
+ export { CONSENT_EXPORT_COLUMNS, CONSENT_PATH, MAX_RETENTION_DAYS, createConsentServer };
@@ -0,0 +1,96 @@
1
+ import { s as ConsentState } from "./state-Dzd52F3c.js";
2
+ //#region frontend/src/consent/testing.d.ts
3
+ export interface ScanOptions {
4
+ /** The Site's origin; relative URLs and this host are first party. */
5
+ siteOrigin: string;
6
+ /** Further first-party hosts (the Site's own CMS/media origin). */
7
+ firstPartyHosts?: readonly string[];
8
+ }
9
+ export interface LiveSource {
10
+ tag: string;
11
+ attribute: string;
12
+ url: string;
13
+ }
14
+ export interface HtmlScan {
15
+ /** Third-party URLs the HTML would load without any script running. */
16
+ liveThirdParty: LiveSource[];
17
+ /** Placeholders blocked for good (unknown hosts, inactive services, scripts): the diagnostic report. */
18
+ blocked: Array<{
19
+ host: string;
20
+ tag: string;
21
+ }>;
22
+ /** Consent placeholders by service. */
23
+ placeholders: string[];
24
+ }
25
+ /** Whether a request URL leaves the Site (data:, blob: and the Site's own hosts don't). */
26
+ export declare function isThirdPartyUrl(value: string, options: ScanOptions): boolean;
27
+ /**
28
+ * Raw HTML, as fetched without JavaScript (CN-R1, CN-R13): every third-party
29
+ * URL an element would load, the blocked-embed diagnostics, and placeholders.
30
+ * `<link>` counts only for stylesheets, preloads, icons and fonts.
31
+ */
32
+ export declare function scanConsentHtml(html: string, options: ScanOptions): HtmlScan;
33
+ /** The Playwright surface the suite uses. */
34
+ export interface BrowserContextLike {
35
+ newPage(): Promise<PageLike>;
36
+ cookies(): Promise<Array<{
37
+ name: string;
38
+ value: string;
39
+ }>>;
40
+ close(): Promise<void>;
41
+ }
42
+ export interface BrowserLike {
43
+ newContext(options?: {
44
+ javaScriptEnabled?: boolean;
45
+ }): Promise<BrowserContextLike>;
46
+ }
47
+ export interface PageLike {
48
+ goto(url: string, options?: {
49
+ waitUntil?: "load" | "networkidle";
50
+ }): Promise<unknown>;
51
+ on(event: "request", listener: (request: {
52
+ url(): string;
53
+ }) => void): unknown;
54
+ evaluate<T>(script: () => T | Promise<T>): Promise<T>;
55
+ click(selector: string): Promise<void>;
56
+ waitForTimeout(ms: number): Promise<void>;
57
+ keyboard: {
58
+ press(key: string): Promise<void>;
59
+ };
60
+ }
61
+ export interface Capture {
62
+ route: string;
63
+ requests: string[];
64
+ thirdParty: string[];
65
+ cookies: string[];
66
+ localStorage: string[];
67
+ sessionStorage: string[];
68
+ consent: ConsentState | null;
69
+ }
70
+ /** Records every request and the storage a page holds after it settles. */
71
+ export declare function capture(context: BrowserContextLike, page: PageLike, route: string, options: ScanOptions, act?: (page: PageLike) => Promise<void>): Promise<Capture>;
72
+ export interface AcceptanceCheck {
73
+ criterion: string;
74
+ route: string;
75
+ passed: boolean;
76
+ detail: string;
77
+ }
78
+ export interface AcceptanceOptions extends ScanOptions {
79
+ routes: readonly string[];
80
+ /** Third-party hosts allowed without consent (necessary services on this route); none by default. */
81
+ necessaryHosts?: readonly string[];
82
+ /** Cookies allowed without consent besides gq_consent (necessary storage the Site declares). */
83
+ necessaryCookies?: readonly string[];
84
+ /** Fetches raw HTML (defaults to global fetch). */
85
+ fetch?: typeof globalThis.fetch;
86
+ }
87
+ /**
88
+ * The provider/adapter acceptance run (CONSENT-1 acceptance evidence 1 and 2):
89
+ * for each route, raw HTML without JavaScript, a visit with no interaction,
90
+ * and a visit after Reject all, each captured; plus first-layer presentation.
91
+ * Returns every check; a compliant Site has none failing.
92
+ */
93
+ export declare function runConsentAcceptance(browser: BrowserLike, options: AcceptanceOptions): Promise<AcceptanceCheck[]>;
94
+ /** The checks that failed. */
95
+ export declare function failures(checks: readonly AcceptanceCheck[]): AcceptanceCheck[];
96
+ //#endregion
@@ -0,0 +1,188 @@
1
+ import { D as hostMatches, f as readConsentCookie, n as CONSENT_COOKIE } from "./state-BYWc5TUB.js";
2
+ //#region frontend/src/consent/testing.ts
3
+ function firstParty(url, options) {
4
+ const site = new URL(options.siteOrigin);
5
+ return url.host === site.host || (options.firstPartyHosts ?? []).some((host) => hostMatches(url.hostname, host));
6
+ }
7
+ /** Whether a request URL leaves the Site (data:, blob: and the Site's own hosts don't). */
8
+ function isThirdPartyUrl(value, options) {
9
+ let url;
10
+ try {
11
+ url = new URL(value, options.siteOrigin);
12
+ } catch {
13
+ return false;
14
+ }
15
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
16
+ return !firstParty(url, options);
17
+ }
18
+ const LOADING = [
19
+ ["iframe", "src"],
20
+ ["script", "src"],
21
+ ["img", "src"],
22
+ ["img", "srcset"],
23
+ ["source", "src"],
24
+ ["source", "srcset"],
25
+ ["video", "src"],
26
+ ["video", "poster"],
27
+ ["audio", "src"],
28
+ ["embed", "src"],
29
+ ["object", "data"],
30
+ ["link", "href"],
31
+ ["track", "src"]
32
+ ];
33
+ /**
34
+ * Raw HTML, as fetched without JavaScript (CN-R1, CN-R13): every third-party
35
+ * URL an element would load, the blocked-embed diagnostics, and placeholders.
36
+ * `<link>` counts only for stylesheets, preloads, icons and fonts.
37
+ */
38
+ function scanConsentHtml(html, options) {
39
+ const liveThirdParty = [];
40
+ for (const match of html.matchAll(/<([a-z][a-z0-9-]*)\b([^>]*)>/gi)) {
41
+ const tag = match[1].toLowerCase();
42
+ const attributes = match[2];
43
+ if (tag === "link" && !/\brel\s*=\s*["']?[^"'>]*(stylesheet|preload|icon|modulepreload|preconnect|dns-prefetch)/i.test(attributes)) continue;
44
+ for (const [element, attribute] of LOADING) {
45
+ if (element !== tag) continue;
46
+ const value = new RegExp(`(?:^|\\s)${attribute}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(attributes);
47
+ if (!value) continue;
48
+ const raw = (value[1] ?? value[2] ?? value[3] ?? "").replaceAll("&amp;", "&");
49
+ const urls = attribute === "srcset" ? raw.split(",").map((each) => each.trim().split(/\s+/)[0] ?? "") : [raw];
50
+ for (const url of urls) if (url && isThirdPartyUrl(url, options)) liveThirdParty.push({
51
+ tag,
52
+ attribute,
53
+ url
54
+ });
55
+ }
56
+ }
57
+ return {
58
+ liveThirdParty,
59
+ blocked: [...html.matchAll(/data-gq-consent-blocked="([^"]*)"[^>]*?data-gq-tag="([^"]*)"/g)].map((match) => ({
60
+ host: match[1],
61
+ tag: match[2]
62
+ })),
63
+ placeholders: [...html.matchAll(/data-gq-consent-embed="([^"]+)"/g)].map((match) => match[1])
64
+ };
65
+ }
66
+ /** Records every request and the storage a page holds after it settles. */
67
+ async function capture(context, page, route, options, act) {
68
+ const requests = [];
69
+ page.on("request", (request) => requests.push(request.url()));
70
+ await page.goto(new URL(route, options.siteOrigin).href, { waitUntil: "networkidle" });
71
+ if (act) {
72
+ await act(page);
73
+ await page.waitForTimeout(500);
74
+ }
75
+ const storage = await page.evaluate(() => ({
76
+ local: Object.keys(localStorage),
77
+ session: Object.keys(sessionStorage)
78
+ }));
79
+ const cookies = await context.cookies();
80
+ const consent = cookies.find((cookie) => cookie.name === CONSENT_COOKIE);
81
+ return {
82
+ route,
83
+ requests,
84
+ thirdParty: requests.filter((url) => isThirdPartyUrl(url, options)),
85
+ cookies: cookies.map((cookie) => cookie.name),
86
+ localStorage: storage.local,
87
+ sessionStorage: storage.session,
88
+ consent: consent ? readConsentCookie(`${CONSENT_COOKIE}=${consent.value}`) : null
89
+ };
90
+ }
91
+ function allowed(url, hosts) {
92
+ try {
93
+ const host = new URL(url).hostname;
94
+ return hosts.some((each) => hostMatches(host, each));
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+ /**
100
+ * The provider/adapter acceptance run (CONSENT-1 acceptance evidence 1 and 2):
101
+ * for each route, raw HTML without JavaScript, a visit with no interaction,
102
+ * and a visit after Reject all, each captured; plus first-layer presentation.
103
+ * Returns every check; a compliant Site has none failing.
104
+ */
105
+ async function runConsentAcceptance(browser, options) {
106
+ const checks = [];
107
+ const record = (criterion, route, passed, detail) => checks.push({
108
+ criterion,
109
+ route,
110
+ passed,
111
+ detail
112
+ });
113
+ const necessaryHosts = options.necessaryHosts ?? [];
114
+ const necessaryCookies = [CONSENT_COOKIE, ...options.necessaryCookies ?? []];
115
+ const violations = (result) => [
116
+ ...result.thirdParty.filter((url) => !allowed(url, necessaryHosts)).map((url) => `request ${url}`),
117
+ ...result.cookies.filter((name) => !necessaryCookies.includes(name)).map((name) => `cookie ${name}`),
118
+ ...result.localStorage.map((key) => `localStorage ${key}`),
119
+ ...result.sessionStorage.map((key) => `sessionStorage ${key}`)
120
+ ];
121
+ for (const route of options.routes) {
122
+ const scan = scanConsentHtml(await (await (options.fetch ?? fetch)(new URL(route, options.siteOrigin))).text(), options);
123
+ const live = scan.liveThirdParty.filter((source) => !allowed(source.url, necessaryHosts));
124
+ record("CN-R13", route, live.length === 0, live.length ? `live third-party sources: ${live.map((each) => each.url).join(", ")}` : `no live third-party src; ${scan.placeholders.length} placeholder(s), ${scan.blocked.length} blocked`);
125
+ const fresh = await browser.newContext();
126
+ try {
127
+ const page = await fresh.newPage();
128
+ const result = await capture(fresh, page, route, options);
129
+ const found = violations(result);
130
+ if (result.cookies.includes("gq_consent")) found.push("consent cookie written without a decision");
131
+ record("CN-R1", route, found.length === 0, found.join("; ") || "no third-party request or optional storage without interaction");
132
+ const presentation = await page.evaluate(() => {
133
+ const banner = document.getElementById("gq-consent-banner");
134
+ if (!banner || banner.hidden) return { banner: false };
135
+ const accept = banner.querySelector("[data-gq-consent-accept-all]");
136
+ const reject = banner.querySelector("[data-gq-consent-reject-all]");
137
+ const style = (element) => {
138
+ if (!element) return null;
139
+ const computed = getComputedStyle(element);
140
+ const box = element.getBoundingClientRect();
141
+ return {
142
+ name: (element.getAttribute("aria-label") ?? element.textContent ?? "").trim(),
143
+ font: `${computed.fontSize} ${computed.fontWeight}`,
144
+ colors: `${computed.color} ${computed.backgroundColor} ${computed.borderColor}`,
145
+ height: Math.round(box.height),
146
+ visible: box.width > 0 && box.height > 0
147
+ };
148
+ };
149
+ return {
150
+ banner: true,
151
+ accept: style(accept),
152
+ reject: style(reject),
153
+ customise: Boolean(banner.querySelector("[data-gq-consent-customise]")),
154
+ policy: Boolean(banner.querySelector("a[href]")),
155
+ checked: [...document.querySelectorAll("input[data-gq-consent-category]")].filter((input) => input.checked).length,
156
+ settings: Boolean(document.querySelector("[data-gq-consent-open]")),
157
+ label: banner.getAttribute("aria-labelledby") ? document.getElementById(banner.getAttribute("aria-labelledby"))?.textContent ?? "" : ""
158
+ };
159
+ });
160
+ if (presentation.banner) {
161
+ const { accept, reject } = presentation;
162
+ const equal = Boolean(accept && reject && accept.visible && reject.visible) && accept.font === reject.font && accept.colors === reject.colors && Math.abs(accept.height - reject.height) <= 1;
163
+ record("CN-R4", route, equal && Boolean(accept.name) && Boolean(reject.name), `Accept all and Reject all ${equal ? "have" : "lack"} equal prominence`);
164
+ record("CN-R4", route, presentation.customise && presentation.policy, "a route to per-category choices and the cookie policy link are on the first layer");
165
+ record("CN-R4", route, presentation.checked === 0, `${presentation.checked} pre-ticked category control(s)`);
166
+ record("CN-R4", route, Boolean(presentation.label.trim()), "the banner has an accessible name");
167
+ }
168
+ record("CN-R6", route, presentation.banner ? presentation.settings : await page.evaluate(() => Boolean(document.querySelector("[data-gq-consent-open]"))), "a persistent Cookie settings control is on the page");
169
+ if (presentation.banner) {
170
+ await page.click("#gq-consent-banner [data-gq-consent-reject-all]");
171
+ await page.waitForTimeout(300);
172
+ const rejected = await capture(fresh, await fresh.newPage(), route, options);
173
+ const after = violations(rejected);
174
+ const denied = rejected.consent !== null && Object.values(rejected.consent.decisions).every((value) => !value);
175
+ record("CN-R1", route, after.length === 0 && denied, after.join("; ") || (denied ? "after Reject all: nothing loads, the consent cookie records the rejection" : "the consent cookie doesn't record the rejection"));
176
+ }
177
+ } finally {
178
+ await fresh.close();
179
+ }
180
+ }
181
+ return checks;
182
+ }
183
+ /** The checks that failed. */
184
+ function failures(checks) {
185
+ return checks.filter((check) => !check.passed);
186
+ }
187
+ //#endregion
188
+ export { capture, failures, isThirdPartyUrl, runConsentAcceptance, scanConsentHtml };