@getquick/content 0.0.0-stage → 0.1.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 +142 -2
- package/consent-migrations/0001_consent_records.sql +34 -0
- package/dist/bearer-DMOCoSci.js +16 -0
- package/dist/client-config-B3LNSOxl.js +4 -0
- package/dist/configuration-C_JGygQI.d.ts +259 -0
- package/dist/consent-astro.d.ts +36 -0
- package/dist/consent-astro.js +94 -0
- package/dist/consent-client.d.ts +37 -0
- package/dist/consent-client.js +321 -0
- package/dist/consent-migrations.d.ts +8 -0
- package/dist/consent-migrations.js +10 -0
- package/dist/consent-server.d.ts +77 -0
- package/dist/consent-server.js +292 -0
- package/dist/consent-testing.d.ts +96 -0
- package/dist/consent-testing.js +188 -0
- package/dist/consent.d.ts +114 -0
- package/dist/consent.js +5 -0
- package/dist/embeds-p9SyGNZI.js +344 -0
- package/dist/formatting-wOk2jz6J.js +276 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +20 -0
- package/dist/legal-C5Xiq59W.d.ts +48 -0
- package/dist/migrations.d.ts +4 -0
- package/dist/migrations.js +6 -0
- package/dist/paths-Cmzzof3v.js +5 -0
- package/dist/records-CACA2O0Q.d.ts +56 -0
- package/dist/records-Dumrz759.js +74 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +6368 -0
- package/dist/state-BYWc5TUB.js +559 -0
- package/dist/state-Dzd52F3c.d.ts +77 -0
- package/dist/version-Dn6VLZJO.js +6 -0
- package/dist/wordpress-CPL40-vq.d.ts +318 -0
- package/migrations/0001_publications.sql +31 -0
- package/migrations/0002_entries.sql +14 -0
- package/migrations/0003_publication_events.sql +36 -0
- package/migrations/0004_withdrawals.sql +30 -0
- package/migrations/0005_reconciliation.sql +45 -0
- package/package.json +56 -3
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
//#region frontend/src/consent/catalog.ts
|
|
2
|
+
/**
|
|
3
|
+
* CONSENT-1's fixed purpose categories (CN-R2) and the Runtime's closed service
|
|
4
|
+
* catalog (CN-R3). Sites choose services from here; they cannot add categories
|
|
5
|
+
* or services. Adding a service is a Runtime release.
|
|
6
|
+
*/
|
|
7
|
+
const CONSENT_CATEGORIES = [
|
|
8
|
+
"necessary",
|
|
9
|
+
"preferences",
|
|
10
|
+
"analytics",
|
|
11
|
+
"marketing",
|
|
12
|
+
"external_media"
|
|
13
|
+
];
|
|
14
|
+
/** The categories a visitor decides on: every one but `necessary`, which is never a choice. */
|
|
15
|
+
const OPTIONAL_CATEGORIES = [
|
|
16
|
+
"preferences",
|
|
17
|
+
"analytics",
|
|
18
|
+
"marketing",
|
|
19
|
+
"external_media"
|
|
20
|
+
];
|
|
21
|
+
/** The languages Runtime-owned copy ships in (CN-C3). */
|
|
22
|
+
const COPY_LANGUAGES = ["en", "pt-PT"];
|
|
23
|
+
/** The copy language for a page's `lang` (BCP 47): Portuguese pages get pt-PT, others en. */
|
|
24
|
+
function copyLanguage(lang) {
|
|
25
|
+
return /^pt(?:-|$)/i.test(lang ?? "") ? "pt-PT" : "en";
|
|
26
|
+
}
|
|
27
|
+
const SERVICE_CATALOG = {
|
|
28
|
+
consent: {
|
|
29
|
+
id: "consent",
|
|
30
|
+
category: "necessary",
|
|
31
|
+
provider: "This website",
|
|
32
|
+
name: "Consent choices",
|
|
33
|
+
purpose: {
|
|
34
|
+
en: "Remembers your consent choices and the consent ID that identifies them.",
|
|
35
|
+
"pt-PT": "Memoriza as suas escolhas de consentimento e o ID que as identifica."
|
|
36
|
+
},
|
|
37
|
+
storage: [{
|
|
38
|
+
name: "gq_consent",
|
|
39
|
+
kind: "cookie",
|
|
40
|
+
party: "first",
|
|
41
|
+
duration: {
|
|
42
|
+
en: "13 months",
|
|
43
|
+
"pt-PT": "13 meses"
|
|
44
|
+
}
|
|
45
|
+
}],
|
|
46
|
+
retention: {
|
|
47
|
+
en: "Records: 5 years after the choice stops being in force.",
|
|
48
|
+
"pt-PT": "Registos: 5 anos após a escolha deixar de estar em vigor."
|
|
49
|
+
},
|
|
50
|
+
privacyPolicy: "",
|
|
51
|
+
hosts: []
|
|
52
|
+
},
|
|
53
|
+
turnstile: {
|
|
54
|
+
id: "turnstile",
|
|
55
|
+
category: "necessary",
|
|
56
|
+
provider: "Cloudflare, Inc.",
|
|
57
|
+
name: "Cloudflare Turnstile",
|
|
58
|
+
purpose: {
|
|
59
|
+
en: "Protects sign-in, registration and checkout against abuse, only on those actions.",
|
|
60
|
+
"pt-PT": "Protege o início de sessão, o registo e a finalização de compra contra abusos, apenas nessas ações."
|
|
61
|
+
},
|
|
62
|
+
storage: [],
|
|
63
|
+
retention: {
|
|
64
|
+
en: "Not stored by this website.",
|
|
65
|
+
"pt-PT": "Não guardado por este website."
|
|
66
|
+
},
|
|
67
|
+
privacyPolicy: "https://www.cloudflare.com/privacypolicy/",
|
|
68
|
+
hosts: ["challenges.cloudflare.com"]
|
|
69
|
+
},
|
|
70
|
+
ga4: {
|
|
71
|
+
id: "ga4",
|
|
72
|
+
category: "analytics",
|
|
73
|
+
provider: "Google Ireland Limited",
|
|
74
|
+
name: "Google Analytics 4",
|
|
75
|
+
purpose: {
|
|
76
|
+
en: "Measures how visitors use the website, in aggregate (Consent Mode v2, basic mode).",
|
|
77
|
+
"pt-PT": "Mede, de forma agregada, como os visitantes usam o website (Consent Mode v2, modo básico)."
|
|
78
|
+
},
|
|
79
|
+
storage: [{
|
|
80
|
+
name: "_ga",
|
|
81
|
+
kind: "cookie",
|
|
82
|
+
match: "prefix",
|
|
83
|
+
party: "first",
|
|
84
|
+
duration: {
|
|
85
|
+
en: "Up to 2 years",
|
|
86
|
+
"pt-PT": "Até 2 anos"
|
|
87
|
+
}
|
|
88
|
+
}],
|
|
89
|
+
retention: {
|
|
90
|
+
en: "14 months at Google (default data retention).",
|
|
91
|
+
"pt-PT": "14 meses na Google (retenção de dados predefinida)."
|
|
92
|
+
},
|
|
93
|
+
privacyPolicy: "https://policies.google.com/privacy",
|
|
94
|
+
hosts: [
|
|
95
|
+
"googletagmanager.com",
|
|
96
|
+
"google-analytics.com",
|
|
97
|
+
"analytics.google.com"
|
|
98
|
+
]
|
|
99
|
+
},
|
|
100
|
+
youtube: {
|
|
101
|
+
id: "youtube",
|
|
102
|
+
category: "external_media",
|
|
103
|
+
provider: "Google Ireland Limited",
|
|
104
|
+
name: "YouTube",
|
|
105
|
+
purpose: {
|
|
106
|
+
en: "Plays embedded YouTube videos. Loading one sends your IP address to YouTube.",
|
|
107
|
+
"pt-PT": "Reproduz vídeos do YouTube incorporados. Carregar um envia o seu endereço IP ao YouTube."
|
|
108
|
+
},
|
|
109
|
+
storage: [{
|
|
110
|
+
name: "VISITOR_INFO1_LIVE, YSC",
|
|
111
|
+
kind: "cookie",
|
|
112
|
+
party: "third",
|
|
113
|
+
duration: {
|
|
114
|
+
en: "Session to 6 months",
|
|
115
|
+
"pt-PT": "Sessão a 6 meses"
|
|
116
|
+
}
|
|
117
|
+
}],
|
|
118
|
+
retention: {
|
|
119
|
+
en: "Set by Google; see its privacy policy.",
|
|
120
|
+
"pt-PT": "Definida pela Google; consulte a sua política de privacidade."
|
|
121
|
+
},
|
|
122
|
+
privacyPolicy: "https://policies.google.com/privacy",
|
|
123
|
+
hosts: [
|
|
124
|
+
"youtube.com",
|
|
125
|
+
"youtube-nocookie.com",
|
|
126
|
+
"ytimg.com",
|
|
127
|
+
"googlevideo.com",
|
|
128
|
+
"ggpht.com"
|
|
129
|
+
],
|
|
130
|
+
embeds: [{ host: "youtube.com" }, { host: "youtube-nocookie.com" }]
|
|
131
|
+
},
|
|
132
|
+
vimeo: {
|
|
133
|
+
id: "vimeo",
|
|
134
|
+
category: "external_media",
|
|
135
|
+
provider: "Vimeo.com, Inc.",
|
|
136
|
+
name: "Vimeo",
|
|
137
|
+
purpose: {
|
|
138
|
+
en: "Plays embedded Vimeo videos. Loading one sends your IP address to Vimeo.",
|
|
139
|
+
"pt-PT": "Reproduz vídeos do Vimeo incorporados. Carregar um envia o seu endereço IP ao Vimeo."
|
|
140
|
+
},
|
|
141
|
+
storage: [{
|
|
142
|
+
name: "vuid",
|
|
143
|
+
kind: "cookie",
|
|
144
|
+
party: "third",
|
|
145
|
+
duration: {
|
|
146
|
+
en: "2 years",
|
|
147
|
+
"pt-PT": "2 anos"
|
|
148
|
+
}
|
|
149
|
+
}],
|
|
150
|
+
retention: {
|
|
151
|
+
en: "Set by Vimeo; see its privacy policy.",
|
|
152
|
+
"pt-PT": "Definida pela Vimeo; consulte a sua política de privacidade."
|
|
153
|
+
},
|
|
154
|
+
privacyPolicy: "https://vimeo.com/privacy",
|
|
155
|
+
hosts: ["vimeo.com", "vimeocdn.com"],
|
|
156
|
+
embeds: [{ host: "player.vimeo.com" }]
|
|
157
|
+
},
|
|
158
|
+
google_maps: {
|
|
159
|
+
id: "google_maps",
|
|
160
|
+
category: "external_media",
|
|
161
|
+
provider: "Google Ireland Limited",
|
|
162
|
+
name: "Google Maps",
|
|
163
|
+
purpose: {
|
|
164
|
+
en: "Shows an embedded map. Loading it sends your IP address to Google.",
|
|
165
|
+
"pt-PT": "Mostra um mapa incorporado. Carregá-lo envia o seu endereço IP à Google."
|
|
166
|
+
},
|
|
167
|
+
storage: [{
|
|
168
|
+
name: "NID",
|
|
169
|
+
kind: "cookie",
|
|
170
|
+
party: "third",
|
|
171
|
+
duration: {
|
|
172
|
+
en: "6 months",
|
|
173
|
+
"pt-PT": "6 meses"
|
|
174
|
+
}
|
|
175
|
+
}],
|
|
176
|
+
retention: {
|
|
177
|
+
en: "Set by Google; see its privacy policy.",
|
|
178
|
+
"pt-PT": "Definida pela Google; consulte a sua política de privacidade."
|
|
179
|
+
},
|
|
180
|
+
privacyPolicy: "https://policies.google.com/privacy",
|
|
181
|
+
hosts: [
|
|
182
|
+
"google.com",
|
|
183
|
+
"googleapis.com",
|
|
184
|
+
"gstatic.com"
|
|
185
|
+
],
|
|
186
|
+
embeds: [
|
|
187
|
+
{
|
|
188
|
+
host: "www.google.com",
|
|
189
|
+
path: "/maps"
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
host: "google.com",
|
|
193
|
+
path: "/maps"
|
|
194
|
+
},
|
|
195
|
+
{ host: "maps.google.com" }
|
|
196
|
+
]
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const SERVICE_IDS = Object.keys(SERVICE_CATALOG);
|
|
200
|
+
function isServiceId(value) {
|
|
201
|
+
return typeof value === "string" && Object.hasOwn(SERVICE_CATALOG, value);
|
|
202
|
+
}
|
|
203
|
+
function catalogService(id) {
|
|
204
|
+
return SERVICE_CATALOG[id];
|
|
205
|
+
}
|
|
206
|
+
/** Whether a host is a domain or one of its subdomains. */
|
|
207
|
+
function hostMatches(host, domain) {
|
|
208
|
+
const h = host.toLowerCase().replace(/\.$/, "");
|
|
209
|
+
return h === domain || h.endsWith(`.${domain}`);
|
|
210
|
+
}
|
|
211
|
+
/** The catalog service an embed URL belongs to, if any. */
|
|
212
|
+
function embedService(url) {
|
|
213
|
+
for (const id of SERVICE_IDS) {
|
|
214
|
+
const service = SERVICE_CATALOG[id];
|
|
215
|
+
for (const embed of service.embeds ?? []) {
|
|
216
|
+
if (!hostMatches(url.hostname, embed.host)) continue;
|
|
217
|
+
if (embed.path && !url.pathname.startsWith(embed.path)) continue;
|
|
218
|
+
return id;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
/** The catalog service whose hosts include a host, if any. */
|
|
224
|
+
function hostService(host) {
|
|
225
|
+
for (const id of SERVICE_IDS) if (SERVICE_CATALOG[id].hosts.some((domain) => hostMatches(host, domain))) return id;
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
//#endregion
|
|
229
|
+
//#region frontend/src/consent/hash.ts
|
|
230
|
+
const K = new Uint32Array([
|
|
231
|
+
1116352408,
|
|
232
|
+
1899447441,
|
|
233
|
+
3049323471,
|
|
234
|
+
3921009573,
|
|
235
|
+
961987163,
|
|
236
|
+
1508970993,
|
|
237
|
+
2453635748,
|
|
238
|
+
2870763221,
|
|
239
|
+
3624381080,
|
|
240
|
+
310598401,
|
|
241
|
+
607225278,
|
|
242
|
+
1426881987,
|
|
243
|
+
1925078388,
|
|
244
|
+
2162078206,
|
|
245
|
+
2614888103,
|
|
246
|
+
3248222580,
|
|
247
|
+
3835390401,
|
|
248
|
+
4022224774,
|
|
249
|
+
264347078,
|
|
250
|
+
604807628,
|
|
251
|
+
770255983,
|
|
252
|
+
1249150122,
|
|
253
|
+
1555081692,
|
|
254
|
+
1996064986,
|
|
255
|
+
2554220882,
|
|
256
|
+
2821834349,
|
|
257
|
+
2952996808,
|
|
258
|
+
3210313671,
|
|
259
|
+
3336571891,
|
|
260
|
+
3584528711,
|
|
261
|
+
113926993,
|
|
262
|
+
338241895,
|
|
263
|
+
666307205,
|
|
264
|
+
773529912,
|
|
265
|
+
1294757372,
|
|
266
|
+
1396182291,
|
|
267
|
+
1695183700,
|
|
268
|
+
1986661051,
|
|
269
|
+
2177026350,
|
|
270
|
+
2456956037,
|
|
271
|
+
2730485921,
|
|
272
|
+
2820302411,
|
|
273
|
+
3259730800,
|
|
274
|
+
3345764771,
|
|
275
|
+
3516065817,
|
|
276
|
+
3600352804,
|
|
277
|
+
4094571909,
|
|
278
|
+
275423344,
|
|
279
|
+
430227734,
|
|
280
|
+
506948616,
|
|
281
|
+
659060556,
|
|
282
|
+
883997877,
|
|
283
|
+
958139571,
|
|
284
|
+
1322822218,
|
|
285
|
+
1537002063,
|
|
286
|
+
1747873779,
|
|
287
|
+
1955562222,
|
|
288
|
+
2024104815,
|
|
289
|
+
2227730452,
|
|
290
|
+
2361852424,
|
|
291
|
+
2428436474,
|
|
292
|
+
2756734187,
|
|
293
|
+
3204031479,
|
|
294
|
+
3329325298
|
|
295
|
+
]);
|
|
296
|
+
function sha256Hex(text) {
|
|
297
|
+
const bytes = new TextEncoder().encode(text);
|
|
298
|
+
const length = bytes.length + 9 + 63 >> 6 << 6;
|
|
299
|
+
const padded = new Uint8Array(length);
|
|
300
|
+
padded.set(bytes);
|
|
301
|
+
padded[bytes.length] = 128;
|
|
302
|
+
const bits = bytes.length * 8;
|
|
303
|
+
const view = new DataView(padded.buffer);
|
|
304
|
+
view.setUint32(length - 8, Math.floor(bits / 4294967296));
|
|
305
|
+
view.setUint32(length - 4, bits >>> 0);
|
|
306
|
+
const h = new Uint32Array([
|
|
307
|
+
1779033703,
|
|
308
|
+
3144134277,
|
|
309
|
+
1013904242,
|
|
310
|
+
2773480762,
|
|
311
|
+
1359893119,
|
|
312
|
+
2600822924,
|
|
313
|
+
528734635,
|
|
314
|
+
1541459225
|
|
315
|
+
]);
|
|
316
|
+
const w = /* @__PURE__ */ new Uint32Array(64);
|
|
317
|
+
const rotr = (x, n) => x >>> n | x << 32 - n;
|
|
318
|
+
for (let offset = 0; offset < length; offset += 64) {
|
|
319
|
+
for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);
|
|
320
|
+
for (let i = 16; i < 64; i++) {
|
|
321
|
+
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ w[i - 15] >>> 3;
|
|
322
|
+
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ w[i - 2] >>> 10;
|
|
323
|
+
w[i] = w[i - 16] + s0 + w[i - 7] + s1 >>> 0;
|
|
324
|
+
}
|
|
325
|
+
let [a, b, c, d, e, f, g, hh] = h;
|
|
326
|
+
for (let i = 0; i < 64; i++) {
|
|
327
|
+
const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
|
328
|
+
const ch = e & f ^ ~e & g;
|
|
329
|
+
const t1 = hh + s1 + ch + K[i] + w[i] >>> 0;
|
|
330
|
+
const t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + (a & b ^ a & c ^ b & c) >>> 0;
|
|
331
|
+
hh = g;
|
|
332
|
+
g = f;
|
|
333
|
+
f = e;
|
|
334
|
+
e = d + t1 >>> 0;
|
|
335
|
+
d = c;
|
|
336
|
+
c = b;
|
|
337
|
+
b = a;
|
|
338
|
+
a = t1 + t2 >>> 0;
|
|
339
|
+
}
|
|
340
|
+
h[0] = h[0] + a >>> 0;
|
|
341
|
+
h[1] = h[1] + b >>> 0;
|
|
342
|
+
h[2] = h[2] + c >>> 0;
|
|
343
|
+
h[3] = h[3] + d >>> 0;
|
|
344
|
+
h[4] = h[4] + e >>> 0;
|
|
345
|
+
h[5] = h[5] + f >>> 0;
|
|
346
|
+
h[6] = h[6] + g >>> 0;
|
|
347
|
+
h[7] = h[7] + hh >>> 0;
|
|
348
|
+
}
|
|
349
|
+
return Array.from(h, (word) => word.toString(16).padStart(8, "0")).join("");
|
|
350
|
+
}
|
|
351
|
+
//#endregion
|
|
352
|
+
//#region frontend/src/consent/configuration.ts
|
|
353
|
+
/**
|
|
354
|
+
* The revision of the Runtime's banner copy that counts as material. Copy edits
|
|
355
|
+
* keep it (CN-R5); only a reviewed change of what the visitor is told about
|
|
356
|
+
* processing bumps it.
|
|
357
|
+
*/
|
|
358
|
+
const CONSENT_COPY_REVISION = 1;
|
|
359
|
+
/** A GA4 measurement ID. Anything else leaves GA4 inactive. */
|
|
360
|
+
const MEASUREMENT_ID = /^G-[A-Z0-9]{4,16}$/;
|
|
361
|
+
/**
|
|
362
|
+
* A Site's consent configuration: the union of services it declares in code and
|
|
363
|
+
* those its settings switch on. Unknown services are refused: the set is closed.
|
|
364
|
+
*/
|
|
365
|
+
function consentConfiguration(input = {}) {
|
|
366
|
+
const active = /* @__PURE__ */ new Set(["consent"]);
|
|
367
|
+
for (const id of input.services ?? []) {
|
|
368
|
+
if (!isServiceId(id)) throw new Error(`"${id}" isn't a consent catalog service`);
|
|
369
|
+
if (id === "ga4") throw new Error("GA4 is switched on by gq-config's analytics settings");
|
|
370
|
+
active.add(id);
|
|
371
|
+
}
|
|
372
|
+
const measurementId = input.analytics?.enabled && MEASUREMENT_ID.test(input.analytics.measurementId ?? "") ? input.analytics.measurementId : null;
|
|
373
|
+
if (measurementId) active.add("ga4");
|
|
374
|
+
const services = [...active].sort();
|
|
375
|
+
const categories = OPTIONAL_CATEGORIES.filter((category) => services.some((id) => SERVICE_CATALOG[id].category === category));
|
|
376
|
+
const name = input.controller?.trim() ?? "";
|
|
377
|
+
const controller = name ? sha256Hex(`controller:${name}`).slice(0, 12) : "";
|
|
378
|
+
return {
|
|
379
|
+
schemaVersion: 1,
|
|
380
|
+
services,
|
|
381
|
+
categories,
|
|
382
|
+
measurementId,
|
|
383
|
+
controller,
|
|
384
|
+
version: sha256Hex(JSON.stringify({
|
|
385
|
+
categories,
|
|
386
|
+
services,
|
|
387
|
+
controller,
|
|
388
|
+
copy: 1
|
|
389
|
+
})).slice(0, 16)
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
/** CN-C1: a Site with no active service outside `necessary` shows no banner and records nothing. */
|
|
393
|
+
function needsBanner(configuration) {
|
|
394
|
+
return configuration.categories.length > 0;
|
|
395
|
+
}
|
|
396
|
+
//#endregion
|
|
397
|
+
//#region frontend/src/consent/state.ts
|
|
398
|
+
/** The first-party consent cookie: necessary, and the only storage written before a decision. */
|
|
399
|
+
const CONSENT_COOKIE = "gq_consent";
|
|
400
|
+
/** A decision, acceptance or rejection, is valid for 6 months (CN-R5). */
|
|
401
|
+
const DECISION_VALIDITY_MS = Math.round(157788e5);
|
|
402
|
+
/** The consent cookie lives at most 13 months (CN-R5): kept so a renewal keeps its consent ID. */
|
|
403
|
+
const CONSENT_COOKIE_MAX_AGE_SECONDS = 34128e3;
|
|
404
|
+
/** The record actions of CN-R7. */
|
|
405
|
+
const CONSENT_ACTIONS = [
|
|
406
|
+
"accept_all",
|
|
407
|
+
"reject_all",
|
|
408
|
+
"custom",
|
|
409
|
+
"withdraw",
|
|
410
|
+
"renew"
|
|
411
|
+
];
|
|
412
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
413
|
+
function isConsentId(value) {
|
|
414
|
+
return typeof value === "string" && UUID.test(value);
|
|
415
|
+
}
|
|
416
|
+
const denyAll = () => ({
|
|
417
|
+
preferences: false,
|
|
418
|
+
analytics: false,
|
|
419
|
+
marketing: false,
|
|
420
|
+
external_media: false
|
|
421
|
+
});
|
|
422
|
+
function base64url(text) {
|
|
423
|
+
const bytes = new TextEncoder().encode(text);
|
|
424
|
+
let binary = "";
|
|
425
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
426
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
427
|
+
}
|
|
428
|
+
function unbase64url(text) {
|
|
429
|
+
const binary = atob(text.replaceAll("-", "+").replaceAll("_", "/"));
|
|
430
|
+
return new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
|
|
431
|
+
}
|
|
432
|
+
/** The cookie value of a state: base64url JSON, short keys, no characters cookies forbid. */
|
|
433
|
+
function serializeConsentState(state) {
|
|
434
|
+
const bits = OPTIONAL_CATEGORIES.map((category) => state.decisions[category] ? "1" : "0");
|
|
435
|
+
return base64url(JSON.stringify({
|
|
436
|
+
v: 1,
|
|
437
|
+
i: state.consentId,
|
|
438
|
+
t: state.decidedAt,
|
|
439
|
+
d: bits.join(""),
|
|
440
|
+
s: state.services,
|
|
441
|
+
c: state.controller,
|
|
442
|
+
h: state.version,
|
|
443
|
+
a: state.action,
|
|
444
|
+
r: state.recorded ? 1 : 0
|
|
445
|
+
}));
|
|
446
|
+
}
|
|
447
|
+
/** A cookie value's state, or null for anything malformed: an unreadable cookie is no decision. */
|
|
448
|
+
function parseConsentState(value) {
|
|
449
|
+
if (!value || value.length > 2048) return null;
|
|
450
|
+
try {
|
|
451
|
+
const raw = JSON.parse(unbase64url(value));
|
|
452
|
+
if (raw.v !== 1 || !isConsentId(raw.i)) return null;
|
|
453
|
+
if (typeof raw.t !== "number" || !Number.isFinite(raw.t) || raw.t <= 0) return null;
|
|
454
|
+
if (typeof raw.d !== "string" || !/^[01]{4}$/.test(raw.d)) return null;
|
|
455
|
+
if (!Array.isArray(raw.s) || typeof raw.c !== "string" || typeof raw.h !== "string") return null;
|
|
456
|
+
if (!CONSENT_ACTIONS.includes(raw.a)) return null;
|
|
457
|
+
const decisions = denyAll();
|
|
458
|
+
OPTIONAL_CATEGORIES.forEach((category, index) => {
|
|
459
|
+
decisions[category] = raw.d[index] === "1";
|
|
460
|
+
});
|
|
461
|
+
return {
|
|
462
|
+
consentId: raw.i,
|
|
463
|
+
decidedAt: raw.t,
|
|
464
|
+
decisions,
|
|
465
|
+
services: raw.s.filter(isServiceId),
|
|
466
|
+
controller: raw.c,
|
|
467
|
+
version: raw.h,
|
|
468
|
+
action: raw.a,
|
|
469
|
+
recorded: raw.r === 1
|
|
470
|
+
};
|
|
471
|
+
} catch {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/** The consent state in a Cookie header (server side) or `document.cookie` (browser). */
|
|
476
|
+
function readConsentCookie(cookieHeader) {
|
|
477
|
+
for (const part of (cookieHeader ?? "").split(";")) {
|
|
478
|
+
const [name, ...rest] = part.trim().split("=");
|
|
479
|
+
if (name === "gq_consent") return parseConsentState(rest.join("="));
|
|
480
|
+
}
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
/** The cookie string for a state: first party, the whole Site, Lax, Secure on HTTPS. */
|
|
484
|
+
function consentCookie(state, { secure = true } = {}) {
|
|
485
|
+
return `${CONSENT_COOKIE}=${serializeConsentState(state)}; Path=/; Max-Age=${CONSENT_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax` + (secure ? "; Secure" : "");
|
|
486
|
+
}
|
|
487
|
+
/** Whether the controller changed: both known and different (an unknown one isn't a change). */
|
|
488
|
+
function otherController(state, configuration) {
|
|
489
|
+
return Boolean(state.controller && configuration.controller) && state.controller !== configuration.controller;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Why the visitor must decide now, or null: no decision (`first`), one older
|
|
493
|
+
* than 6 months (`expired`), or a material change since (`material`): a new
|
|
494
|
+
* category, a new service in a category they granted, or another controller.
|
|
495
|
+
* A Site without optional services never prompts (CN-C1).
|
|
496
|
+
*/
|
|
497
|
+
function promptReason(configuration, state, now = Date.now()) {
|
|
498
|
+
if (!needsBanner(configuration)) return null;
|
|
499
|
+
if (!state) return "first";
|
|
500
|
+
if (now - state.decidedAt >= DECISION_VALIDITY_MS || state.decidedAt > now + 6e4) return "expired";
|
|
501
|
+
if (otherController(state, configuration)) return "material";
|
|
502
|
+
const known = new Set(state.services);
|
|
503
|
+
const knownCategories = new Set(state.services.map((id) => SERVICE_CATALOG[id].category));
|
|
504
|
+
for (const category of configuration.categories) if (!knownCategories.has(category)) return "material";
|
|
505
|
+
for (const id of configuration.services) {
|
|
506
|
+
const category = SERVICE_CATALOG[id].category;
|
|
507
|
+
if (category !== "necessary" && state.decisions[category] && !known.has(id)) return "material";
|
|
508
|
+
}
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Whether a service may load now: necessary ones always; others only on a
|
|
513
|
+
* valid, unexpired grant of their category that already knew the service.
|
|
514
|
+
* Anything else (no decision, an expired one, a newer service) is denied.
|
|
515
|
+
*/
|
|
516
|
+
function serviceGranted(configuration, state, id, now = Date.now()) {
|
|
517
|
+
if (!configuration.services.includes(id)) return false;
|
|
518
|
+
const category = SERVICE_CATALOG[id].category;
|
|
519
|
+
if (category === "necessary") return true;
|
|
520
|
+
if (!state) return false;
|
|
521
|
+
if (now - state.decidedAt >= DECISION_VALIDITY_MS || state.decidedAt > now + 6e4) return false;
|
|
522
|
+
if (otherController(state, configuration)) return false;
|
|
523
|
+
return state.decisions[category] && state.services.includes(id);
|
|
524
|
+
}
|
|
525
|
+
/** The optional categories in force now (see serviceGranted). */
|
|
526
|
+
function grantedCategories(configuration, state, now = Date.now()) {
|
|
527
|
+
return configuration.categories.filter((category) => configuration.services.some((id) => SERVICE_CATALOG[id].category === category && serviceGranted(configuration, state, id, now)));
|
|
528
|
+
}
|
|
529
|
+
function randomId() {
|
|
530
|
+
return globalThis.crypto.randomUUID();
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* A new decision. It keeps the consent ID of an earlier one. Its action: a
|
|
534
|
+
* renewal when the visitor was asked again (expired or material change), a
|
|
535
|
+
* withdrawal when a category in force is no longer granted, else the button.
|
|
536
|
+
* Only active categories can be granted; nothing is granted by default.
|
|
537
|
+
*/
|
|
538
|
+
function decide(configuration, previous, choice, now = Date.now()) {
|
|
539
|
+
const decisions = denyAll();
|
|
540
|
+
for (const category of configuration.categories) decisions[category] = choice.kind === "accept_all" || choice.kind === "custom" && choice.decisions[category] === true;
|
|
541
|
+
const inForce = grantedCategories(configuration, previous, now);
|
|
542
|
+
const reason = previous ? promptReason(configuration, previous, now) : null;
|
|
543
|
+
const action = reason === "expired" || reason === "material" ? "renew" : inForce.some((category) => !decisions[category]) ? "withdraw" : choice.kind;
|
|
544
|
+
return {
|
|
545
|
+
action,
|
|
546
|
+
state: {
|
|
547
|
+
consentId: previous?.consentId ?? randomId(),
|
|
548
|
+
decidedAt: now,
|
|
549
|
+
decisions,
|
|
550
|
+
services: [...configuration.services],
|
|
551
|
+
controller: configuration.controller,
|
|
552
|
+
version: configuration.version,
|
|
553
|
+
action,
|
|
554
|
+
recorded: false
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
//#endregion
|
|
559
|
+
export { SERVICE_IDS as C, hostMatches as D, embedService as E, hostService as O, SERVICE_CATALOG as S, copyLanguage as T, consentConfiguration as _, consentCookie as a, COPY_LANGUAGES as b, grantedCategories as c, promptReason as d, readConsentCookie as f, MEASUREMENT_ID as g, CONSENT_COPY_REVISION as h, DECISION_VALIDITY_MS as i, isServiceId as k, isConsentId as l, serviceGranted as m, CONSENT_COOKIE as n, decide as o, serializeConsentState as p, CONSENT_COOKIE_MAX_AGE_SECONDS as r, denyAll as s, CONSENT_ACTIONS as t, parseConsentState as u, needsBanner as v, catalogService as w, OPTIONAL_CATEGORIES as x, CONSENT_CATEGORIES as y };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { g as OptionalCategory, r as ConsentConfiguration, y as ServiceId } from "./configuration-C_JGygQI.js";
|
|
2
|
+
//#region frontend/src/consent/state.d.ts
|
|
3
|
+
/** The first-party consent cookie: necessary, and the only storage written before a decision. */
|
|
4
|
+
declare const CONSENT_COOKIE = "gq_consent";
|
|
5
|
+
/** A decision, acceptance or rejection, is valid for 6 months (CN-R5). */
|
|
6
|
+
declare const DECISION_VALIDITY_MS: number;
|
|
7
|
+
/** The consent cookie lives at most 13 months (CN-R5): kept so a renewal keeps its consent ID. */
|
|
8
|
+
declare const CONSENT_COOKIE_MAX_AGE_SECONDS: number;
|
|
9
|
+
type ConsentDecisions = Record<OptionalCategory, boolean>;
|
|
10
|
+
/** The record actions of CN-R7. */
|
|
11
|
+
declare const CONSENT_ACTIONS: readonly ["accept_all", "reject_all", "custom", "withdraw", "renew"];
|
|
12
|
+
type ConsentAction = (typeof CONSENT_ACTIONS)[number];
|
|
13
|
+
/** A visitor's decision as the consent cookie holds it. */
|
|
14
|
+
interface ConsentState {
|
|
15
|
+
/** Random (UUID v4); also the record's consent ID, quoted in data-subject requests. */
|
|
16
|
+
consentId: string;
|
|
17
|
+
/** When the visitor decided (ms since the epoch, the browser's clock). */
|
|
18
|
+
decidedAt: number;
|
|
19
|
+
decisions: ConsentDecisions;
|
|
20
|
+
/** The active services when the visitor decided: a later one in a granted category is new. */
|
|
21
|
+
services: ServiceId[];
|
|
22
|
+
/** The controller hash when the visitor decided. */
|
|
23
|
+
controller: string;
|
|
24
|
+
/** The configuration version decided on. */
|
|
25
|
+
version: string;
|
|
26
|
+
/** The decision's record action, resent until the Worker confirms it. */
|
|
27
|
+
action: ConsentAction;
|
|
28
|
+
/** Whether the Worker has confirmed the decision's record. */
|
|
29
|
+
recorded: boolean;
|
|
30
|
+
}
|
|
31
|
+
type ConsentChoice = {
|
|
32
|
+
kind: "accept_all";
|
|
33
|
+
} | {
|
|
34
|
+
kind: "reject_all";
|
|
35
|
+
} | {
|
|
36
|
+
kind: "custom";
|
|
37
|
+
decisions: Partial<ConsentDecisions>;
|
|
38
|
+
};
|
|
39
|
+
declare function isConsentId(value: unknown): value is string;
|
|
40
|
+
declare const denyAll: () => ConsentDecisions;
|
|
41
|
+
/** The cookie value of a state: base64url JSON, short keys, no characters cookies forbid. */
|
|
42
|
+
declare function serializeConsentState(state: ConsentState): string;
|
|
43
|
+
/** A cookie value's state, or null for anything malformed: an unreadable cookie is no decision. */
|
|
44
|
+
declare function parseConsentState(value: string | null | undefined): ConsentState | null;
|
|
45
|
+
/** The consent state in a Cookie header (server side) or `document.cookie` (browser). */
|
|
46
|
+
declare function readConsentCookie(cookieHeader: string | null | undefined): ConsentState | null;
|
|
47
|
+
/** The cookie string for a state: first party, the whole Site, Lax, Secure on HTTPS. */
|
|
48
|
+
declare function consentCookie(state: ConsentState, { secure }?: {
|
|
49
|
+
secure?: boolean | undefined;
|
|
50
|
+
}): string;
|
|
51
|
+
/**
|
|
52
|
+
* Why the visitor must decide now, or null: no decision (`first`), one older
|
|
53
|
+
* than 6 months (`expired`), or a material change since (`material`): a new
|
|
54
|
+
* category, a new service in a category they granted, or another controller.
|
|
55
|
+
* A Site without optional services never prompts (CN-C1).
|
|
56
|
+
*/
|
|
57
|
+
declare function promptReason(configuration: ConsentConfiguration, state: ConsentState | null, now?: number): "first" | "expired" | "material" | null;
|
|
58
|
+
/**
|
|
59
|
+
* Whether a service may load now: necessary ones always; others only on a
|
|
60
|
+
* valid, unexpired grant of their category that already knew the service.
|
|
61
|
+
* Anything else (no decision, an expired one, a newer service) is denied.
|
|
62
|
+
*/
|
|
63
|
+
declare function serviceGranted(configuration: ConsentConfiguration, state: ConsentState | null, id: ServiceId, now?: number): boolean;
|
|
64
|
+
/** The optional categories in force now (see serviceGranted). */
|
|
65
|
+
declare function grantedCategories(configuration: ConsentConfiguration, state: ConsentState | null, now?: number): OptionalCategory[];
|
|
66
|
+
/**
|
|
67
|
+
* A new decision. It keeps the consent ID of an earlier one. Its action: a
|
|
68
|
+
* renewal when the visitor was asked again (expired or material change), a
|
|
69
|
+
* withdrawal when a category in force is no longer granted, else the button.
|
|
70
|
+
* Only active categories can be granted; nothing is granted by default.
|
|
71
|
+
*/
|
|
72
|
+
declare function decide(configuration: ConsentConfiguration, previous: ConsentState | null, choice: ConsentChoice, now?: number): {
|
|
73
|
+
state: ConsentState;
|
|
74
|
+
action: ConsentAction;
|
|
75
|
+
};
|
|
76
|
+
//#endregion
|
|
77
|
+
export { serializeConsentState as _, ConsentChoice as a, DECISION_VALIDITY_MS as c, denyAll as d, grantedCategories as f, readConsentCookie as g, promptReason as h, ConsentAction as i, consentCookie as l, parseConsentState as m, CONSENT_COOKIE as n, ConsentDecisions as o, isConsentId as p, CONSENT_COOKIE_MAX_AGE_SECONDS as r, ConsentState as s, CONSENT_ACTIONS as t, decide as u, serviceGranted as v };
|