@emplugins/emprivacy 3.0.0 → 3.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.
package/dist/index.mjs CHANGED
@@ -20,7 +20,8 @@ const CHROME = {
20
20
  embedBlocked: "This embed is blocked until you allow the matching cookie category.",
21
21
  embedNeedConsent: "To load this embed, allow the matching category in cookie settings.",
22
22
  vendorsHeading: "What this site uses",
23
- whatWeUse: "What we use"
23
+ whatWeUse: "What we use",
24
+ gpcNote: "Your browser sent Global Privacy Control, so marketing stays off."
24
25
  },
25
26
  de: {
26
27
  acceptAll: "Alle akzeptieren",
@@ -40,7 +41,8 @@ const CHROME = {
40
41
  embedBlocked: "Diese Einbettung ist gesperrt, bis Sie die passende Kategorie erlauben.",
41
42
  embedNeedConsent: "Um diese Einbettung zu laden, erlauben Sie die passende Kategorie in den Cookie-Einstellungen.",
42
43
  vendorsHeading: "Was diese Website verwendet",
43
- whatWeUse: "Was wir verwenden"
44
+ whatWeUse: "Was wir verwenden",
45
+ gpcNote: "Ihr Browser sendet Global Privacy Control, daher bleibt Marketing deaktiviert."
44
46
  },
45
47
  fr: {
46
48
  acceptAll: "Tout accepter",
@@ -60,7 +62,8 @@ const CHROME = {
60
62
  embedBlocked: "Ce contenu est bloqué tant que vous n’avez pas autorisé la catégorie correspondante.",
61
63
  embedNeedConsent: "Pour charger ce contenu, autorisez la catégorie correspondante dans les paramètres des cookies.",
62
64
  vendorsHeading: "Ce que ce site utilise",
63
- whatWeUse: "Ce que nous utilisons"
65
+ whatWeUse: "Ce que nous utilisons",
66
+ gpcNote: "Votre navigateur envoie Global Privacy Control, donc le marketing reste désactivé."
64
67
  },
65
68
  es: {
66
69
  acceptAll: "Aceptar todo",
@@ -80,7 +83,8 @@ const CHROME = {
80
83
  embedBlocked: "Este contenido está bloqueado hasta que permita la categoría correspondiente.",
81
84
  embedNeedConsent: "Para cargar este contenido, permita la categoría correspondiente en los ajustes de cookies.",
82
85
  vendorsHeading: "Qué usa este sitio",
83
- whatWeUse: "Qué usamos"
86
+ whatWeUse: "Qué usamos",
87
+ gpcNote: "Su navegador envía Global Privacy Control, así que el marketing permanece desactivado."
84
88
  },
85
89
  it: {
86
90
  acceptAll: "Accetta tutto",
@@ -100,7 +104,8 @@ const CHROME = {
100
104
  embedBlocked: "Questo contenuto è bloccato finché non consenti la categoria corrispondente.",
101
105
  embedNeedConsent: "Per caricare questo contenuto, consenti la categoria corrispondente nelle impostazioni cookie.",
102
106
  vendorsHeading: "Cosa usa questo sito",
103
- whatWeUse: "Cosa usiamo"
107
+ whatWeUse: "Cosa usiamo",
108
+ gpcNote: "Il browser invia Global Privacy Control, quindi il marketing resta disattivato."
104
109
  },
105
110
  nl: {
106
111
  acceptAll: "Alles accepteren",
@@ -120,7 +125,8 @@ const CHROME = {
120
125
  embedBlocked: "Deze embed is geblokkeerd tot u de bijbehorende categorie toestaat.",
121
126
  embedNeedConsent: "Om deze embed te laden, sta de bijbehorende categorie toe in de cookie-instellingen.",
122
127
  vendorsHeading: "Wat deze site gebruikt",
123
- whatWeUse: "Wat wij gebruiken"
128
+ whatWeUse: "Wat wij gebruiken",
129
+ gpcNote: "Uw browser stuurt Global Privacy Control, dus marketing blijft uit."
124
130
  },
125
131
  pt: {
126
132
  acceptAll: "Aceitar tudo",
@@ -140,7 +146,8 @@ const CHROME = {
140
146
  embedBlocked: "Este conteúdo está bloqueado até autorizar a categoria correspondente.",
141
147
  embedNeedConsent: "Para carregar este conteúdo, autorize a categoria correspondente nas definições de cookies.",
142
148
  vendorsHeading: "O que este site usa",
143
- whatWeUse: "O que usamos"
149
+ whatWeUse: "O que usamos",
150
+ gpcNote: "O seu navegador enviou Global Privacy Control, por isso o marketing permanece desligado."
144
151
  },
145
152
  pl: {
146
153
  acceptAll: "Zaakceptuj wszystkie",
@@ -160,7 +167,8 @@ const CHROME = {
160
167
  embedBlocked: "Ta treść jest zablokowana, dopóki nie zezwolisz na odpowiednią kategorię.",
161
168
  embedNeedConsent: "Aby wczytać tę treść, zezwól na odpowiednią kategorię w ustawieniach plików cookie.",
162
169
  vendorsHeading: "Czego używa ta witryna",
163
- whatWeUse: "Czego używamy"
170
+ whatWeUse: "Czego używamy",
171
+ gpcNote: "Przeglądarka wysłała Global Privacy Control, więc marketing pozostaje wyłączony."
164
172
  }
165
173
  };
166
174
  function isLocaleKey(s) {
@@ -235,6 +243,8 @@ const FATHOM_ID = /^[A-Za-z0-9]{4,32}$/;
235
243
  const UMAMI_ID = /^[A-Za-z0-9-]{8,64}$/;
236
244
  const GA4_ID = /^G-[A-Z0-9]{4,14}$/;
237
245
  const GTM_ID = /^GTM-[A-Z0-9]{4,12}$/;
246
+ const CLARITY_ID = /^[a-z0-9]{7,20}$/i;
247
+ const UET_ID = /^[0-9]{6,12}$/;
238
248
  function isHostname$1(s) {
239
249
  const t = s.trim().toLowerCase();
240
250
  if (!t || t.length > 253) return false;
@@ -253,8 +263,23 @@ function isFathomId(s) {
253
263
  function isUmamiId(s) {
254
264
  return UMAMI_ID.test(s.trim());
255
265
  }
266
+ function isClarityId(s) {
267
+ return CLARITY_ID.test(s.trim());
268
+ }
269
+ function isUetId(s) {
270
+ return UET_ID.test(s.trim());
271
+ }
256
272
  //#endregion
257
273
  //#region src/security.ts
274
+ /**
275
+ * Shared URL / request guards used by admin validation and (via mirrored
276
+ * logic) the public bootstrap. Keep client checks in sync with these helpers.
277
+ */
278
+ const YT_IFRAME = /^https:\/\/(?:www\.)?youtube-nocookie\.com\/embed\/[A-Za-z0-9_-]{11}\/?$/;
279
+ const VIMEO_IFRAME = /^https:\/\/player\.vimeo\.com\/video\/\d{6,12}\/?$/;
280
+ /** Sandbox and feature policy for YouTube/Vimeo iframes. Kept beside the src regexes. */
281
+ const EMBED_IFRAME_SANDBOX = "allow-scripts allow-same-origin allow-presentation";
282
+ const EMBED_IFRAME_ALLOW = "accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; web-share";
258
283
  /** Hosts EmPrivacy may load as <script src> for built-in presets. */
259
284
  const PRESET_SCRIPT_HOSTS = /* @__PURE__ */ new Set([
260
285
  "static.cloudflareinsights.com",
@@ -262,7 +287,10 @@ const PRESET_SCRIPT_HOSTS = /* @__PURE__ */ new Set([
262
287
  "cdn.usefathom.com",
263
288
  "scripts.simpleanalyticscdn.com",
264
289
  "www.googletagmanager.com",
265
- "googletagmanager.com"
290
+ "googletagmanager.com",
291
+ "www.clarity.ms",
292
+ "clarity.ms",
293
+ "bat.bing.com"
266
294
  ]);
267
295
  const HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
268
296
  function normalizeCookieMaxAgeDays(n, fallback = 180) {
@@ -283,6 +311,85 @@ function cookieMaxAgeSeconds(days) {
283
311
  function hasUnsafeUrlChars$1(s) {
284
312
  return /[\u0000-\u001F\u007F\s]/.test(s);
285
313
  }
314
+ /**
315
+ * Browser copy of the non-public host check. `parseStrictHttpsUrl` calls the
316
+ * same source so the inline bootstrap cannot drift from the server.
317
+ * Hostnames are whatever `new URL` already normalized (decimal, hex, and
318
+ * short IPv4 forms become dotted quads; IPv6 keeps brackets).
319
+ */
320
+ function hostnameBlockScript() {
321
+ return `function hostBlocked(raw){
322
+ var host=String(raw||"").trim().toLowerCase().replace(/^\\[|\\]$/g,"");
323
+ if(!host)return true;
324
+ if(host==="localhost"||endsWith(host,".localhost")||endsWith(host,".local")||endsWith(host,".internal")||endsWith(host,".arpa"))return true;
325
+ if(host.indexOf(":")>=0)return ipv6Blocked(host);
326
+ if(/^[0-9.]+$/.test(host)){
327
+ var v4=parseV4(host);
328
+ if(v4===null)return true;
329
+ return v4Blocked(v4);
330
+ }
331
+ return false;
332
+ }
333
+ function endsWith(h,s){return h.length>=s.length&&h.slice(h.length-s.length)===s;}
334
+ function parseV4(host){
335
+ var p=host.split(".");
336
+ if(p.length!==4)return null;
337
+ var n=0;
338
+ for(var i=0;i<p.length;i++){
339
+ var part=p[i];
340
+ if(!/^\\d{1,3}$/.test(part))return null;
341
+ if(part.length>1&&part.charAt(0)==="0")return null;
342
+ var v=+part;
343
+ if(v>255)return null;
344
+ n=n*256+v;
345
+ }
346
+ return n>>>0;
347
+ }
348
+ function v4Blocked(n){
349
+ var a=n>>>24;
350
+ var b=(n>>>16)&255;
351
+ if(a===0||a===10||a===127)return true;
352
+ if(a===100&&b>=64&&b<=127)return true;
353
+ if(a===169&&b===254)return true;
354
+ if(a===172&&b>=16&&b<=31)return true;
355
+ if(a===192&&b===168)return true;
356
+ if(a>=224)return true;
357
+ return false;
358
+ }
359
+ function ipv6Blocked(host){
360
+ if(host==="::"||host==="::1")return true;
361
+ var dotted=/^::ffff:(\\d{1,3}(?:\\.\\d{1,3}){3})$/.exec(host);
362
+ if(dotted){
363
+ var d4=parseV4(dotted[1]);
364
+ return d4===null||v4Blocked(d4);
365
+ }
366
+ var mapped=/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host);
367
+ if(mapped){
368
+ var hi=parseInt(mapped[1],16);
369
+ var lo=parseInt(mapped[2],16);
370
+ if(hi>65535||lo>65535)return true;
371
+ return v4Blocked(((hi<<16)|lo)>>>0);
372
+ }
373
+ if(/\\d{1,3}(?:\\.\\d{1,3}){3}$/.test(host))return true;
374
+ var first;
375
+ if(host.slice(0,2)==="::")first=0;
376
+ else{
377
+ var head=host.split(":")[0];
378
+ if(!/^[0-9a-f]{1,4}$/.test(head))return true;
379
+ first=parseInt(head,16);
380
+ }
381
+ if((first&0xfe00)===0xfc00)return true;
382
+ if((first&0xffc0)===0xfe80)return true;
383
+ if((first&0xffc0)===0xfec0)return true;
384
+ if((first&0xff00)===0xff00)return true;
385
+ return false;
386
+ }`;
387
+ }
388
+ const hostBlockedImpl = new Function(`${hostnameBlockScript()}\nreturn hostBlocked;`)();
389
+ /** True for loopback, private, link-local, shared (CGNAT), and non-global addresses. */
390
+ function isBlockedHostname(hostname) {
391
+ return hostBlockedImpl(hostname);
392
+ }
286
393
  function parseStrictHttpsUrl(raw) {
287
394
  const t = raw.trim();
288
395
  if (!t || t.length > 2048 || hasUnsafeUrlChars$1(t)) return null;
@@ -291,9 +398,7 @@ function parseStrictHttpsUrl(raw) {
291
398
  const u = new URL(t);
292
399
  if (u.protocol !== "https:") return null;
293
400
  if (u.username || u.password) return null;
294
- const host = u.hostname.toLowerCase();
295
- if (host === "localhost" || host.endsWith(".localhost")) return null;
296
- if (host === "127.0.0.1" || host === "::1" || host.endsWith(".local")) return null;
401
+ if (isBlockedHostname(u.hostname)) return null;
297
402
  return u;
298
403
  } catch {
299
404
  return null;
@@ -326,9 +431,18 @@ function isScriptHostAllowed(raw, opts) {
326
431
  if (!u) return false;
327
432
  const host = u.hostname.toLowerCase();
328
433
  if (opts.allowPresets && PRESET_SCRIPT_HOSTS.has(host)) return true;
329
- if (opts.allowlist.length === 0) return true;
330
434
  return opts.allowlist.includes(host);
331
435
  }
436
+ /** SRI required before a custom or marketing script may load. Empty is not valid. */
437
+ function hasSri(s) {
438
+ return typeof s === "string" && /^sha(?:256|384|512)-[A-Za-z0-9+/=]+$/.test(s);
439
+ }
440
+ function isPublishableCustomScript(src, integrity, allowlist) {
441
+ return hasSri(integrity) && isScriptHostAllowed(src, {
442
+ allowlist: [...allowlist],
443
+ allowPresets: false
444
+ });
445
+ }
332
446
  /** Optional SRI: `https://cdn.example/a.js sha384-...` or `... integrity=sha384-...`. */
333
447
  function parseScriptUrlWithIntegrity(line) {
334
448
  const t = line.trim();
@@ -368,6 +482,29 @@ function assertSameOriginMutation(request, expectedOrigin) {
368
482
  if ((request.headers.get("sec-fetch-site") ?? "").toLowerCase() === "same-origin") return null;
369
483
  return new Response("forbidden", { status: 403 });
370
484
  }
485
+ /**
486
+ * Compare-and-set increment. Returns false when the bucket is full, the store
487
+ * errors, or eight conflicts in a row fail to land a write.
488
+ */
489
+ async function tryConsumeRateSlot(kv, key, limit) {
490
+ for (let attempt = 0; attempt < 8; attempt++) {
491
+ let current;
492
+ try {
493
+ current = await kv.getVersioned(key);
494
+ } catch {
495
+ return false;
496
+ }
497
+ const raw = current?.value;
498
+ const n = typeof raw === "string" && /^\d{1,6}$/.test(raw) ? Number(raw) : 0;
499
+ if (n >= limit) return false;
500
+ try {
501
+ if ((await kv.compareAndSet(key, current ? current.revision : null, String(n + 1))).applied) return true;
502
+ } catch {
503
+ return false;
504
+ }
505
+ }
506
+ return false;
507
+ }
371
508
  /** Coarse hour bucket for rate limiting (UTC). */
372
509
  function rateHourKey(now = /* @__PURE__ */ new Date()) {
373
510
  return `${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, "0")}${String(now.getUTCDate()).padStart(2, "0")}${String(now.getUTCHours()).padStart(2, "0")}`;
@@ -376,12 +513,81 @@ function rateHourKey(now = /* @__PURE__ */ new Date()) {
376
513
  //#region src/theme.ts
377
514
  /** Hex colors only — rejects CSS injection via theme fields. */
378
515
  const HEX = /^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
516
+ /** Fixed banner palettes. Colors are constants, never taken from the admin form. */
517
+ const THEME_PROFILES = {
518
+ slate: {
519
+ bg: "#0f172a",
520
+ text: "#f8fafc",
521
+ accent: "#3b82f6",
522
+ radiusPx: 8
523
+ },
524
+ paper: {
525
+ bg: "#f7f6f3",
526
+ text: "#37352f",
527
+ accent: "#1d4ed8",
528
+ radiusPx: 6
529
+ },
530
+ ink: {
531
+ bg: "#000000",
532
+ text: "#ededed",
533
+ accent: "#0070f3",
534
+ radiusPx: 8
535
+ },
536
+ indigo: {
537
+ bg: "#ffffff",
538
+ text: "#0f172a",
539
+ accent: "#4f46e5",
540
+ radiusPx: 12
541
+ },
542
+ primer: {
543
+ bg: "#f6f8fa",
544
+ text: "#1f2328",
545
+ accent: "#0969da",
546
+ radiusPx: 6
547
+ }
548
+ };
549
+ const THEME_PROFILE_IDS = [
550
+ "slate",
551
+ "paper",
552
+ "ink",
553
+ "indigo",
554
+ "primer"
555
+ ];
556
+ /** First profile. New installs and configs with no theme choice use this. */
557
+ const DEFAULT_THEME_PROFILE = THEME_PROFILE_IDS[0];
379
558
  const DEFAULT_THEME = {
380
- bg: "#111111",
381
- text: "#eeeeee",
382
- accent: "#3b82f6",
383
- radiusPx: 6
559
+ bg: THEME_PROFILES[DEFAULT_THEME_PROFILE].bg,
560
+ text: THEME_PROFILES[DEFAULT_THEME_PROFILE].text,
561
+ accent: THEME_PROFILES[DEFAULT_THEME_PROFILE].accent,
562
+ radiusPx: THEME_PROFILES[DEFAULT_THEME_PROFILE].radiusPx
384
563
  };
564
+ function isThemeProfileId(value) {
565
+ return THEME_PROFILE_IDS.includes(value);
566
+ }
567
+ /** Stored JSON: a missing or unknown choice uses the first profile. Explicit `custom` is kept. */
568
+ function normalizeThemeChoice(value) {
569
+ if (value === "custom") return "custom";
570
+ if (typeof value === "string" && isThemeProfileId(value)) return value;
571
+ return DEFAULT_THEME_PROFILE;
572
+ }
573
+ /** Admin save: blank uses the first profile. `custom` is the manual option. */
574
+ function parseThemeChoice(value) {
575
+ const t = value.trim().toLowerCase();
576
+ if (!t) return DEFAULT_THEME_PROFILE;
577
+ if (t === "custom") return "custom";
578
+ if (isThemeProfileId(t)) return t;
579
+ throw new Error("Banner theme must be Custom, Slate, Paper, Ink, Indigo, or Primer.");
580
+ }
581
+ function themeForChoice(choice, custom) {
582
+ if (choice === "custom") return custom;
583
+ const profile = THEME_PROFILES[choice];
584
+ return {
585
+ bg: profile.bg,
586
+ text: profile.text,
587
+ accent: profile.accent,
588
+ radiusPx: profile.radiusPx
589
+ };
590
+ }
385
591
  function isSafeHexColor(s) {
386
592
  return HEX.test(s.trim());
387
593
  }
@@ -424,14 +630,17 @@ const DEFAULT_CONFIG = {
424
630
  marketingScriptUrls: [],
425
631
  scriptIntegrity: {},
426
632
  scriptHostAllowlist: [],
633
+ embedHostAllowlist: [],
427
634
  cookieMaxAgeDays: 180,
428
635
  googleConsentMode: false,
429
636
  logConsentToServer: false,
430
637
  embedCategory: "marketing",
431
638
  gateEmbeds: true,
432
639
  hideBannerOnPolicyPages: true,
640
+ bannerPosition: "bottom",
433
641
  defaultLocale: "en",
434
642
  localeOverrides: {},
643
+ themeProfile: "slate",
435
644
  theme: { ...DEFAULT_THEME }
436
645
  };
437
646
  const ANALYTICS_PROVIDERS = [
@@ -443,8 +652,13 @@ const ANALYTICS_PROVIDERS = [
443
652
  "umami",
444
653
  "simpleanalytics",
445
654
  "ga4",
446
- "gtm"
655
+ "gtm",
656
+ "clarity",
657
+ "uet"
447
658
  ];
659
+ function normalizeBannerPosition(value) {
660
+ return value === "top" ? "top" : "bottom";
661
+ }
448
662
  function hasUnsafeUrlChars(s) {
449
663
  return /[\u0000-\u001F\u007F\s]/.test(s);
450
664
  }
@@ -561,6 +775,7 @@ function normalizeConfig(raw) {
561
775
  if (Object.keys(scriptIntegrity).length >= 100) break;
562
776
  }
563
777
  const scriptHostAllowlist = (Array.isArray(o.scriptHostAllowlist) ? o.scriptHostAllowlist.filter((x) => typeof x === "string") : []).map((h) => h.trim().toLowerCase()).filter((h) => isHostname$1(h)).slice(0, 50);
778
+ const embedHostAllowlist = (Array.isArray(o.embedHostAllowlist) ? o.embedHostAllowlist.filter((x) => typeof x === "string") : []).map((h) => h.trim().toLowerCase()).filter((h) => isHostname$1(h)).slice(0, 50);
564
779
  return {
565
780
  bannerTitle: typeof o.bannerTitle === "string" ? o.bannerTitle : DEFAULT_CONFIG.bannerTitle,
566
781
  bannerMessage: typeof o.bannerMessage === "string" ? o.bannerMessage : DEFAULT_CONFIG.bannerMessage,
@@ -576,20 +791,23 @@ function normalizeConfig(raw) {
576
791
  marketingScriptUrls: marketing,
577
792
  scriptIntegrity,
578
793
  scriptHostAllowlist,
794
+ embedHostAllowlist,
579
795
  cookieMaxAgeDays: normalizeCookieMaxAgeDays(o.cookieMaxAgeDays, DEFAULT_CONFIG.cookieMaxAgeDays),
580
796
  googleConsentMode: typeof o.googleConsentMode === "boolean" ? o.googleConsentMode : DEFAULT_CONFIG.googleConsentMode,
581
797
  logConsentToServer: typeof o.logConsentToServer === "boolean" ? o.logConsentToServer : DEFAULT_CONFIG.logConsentToServer,
582
798
  embedCategory: isEmbedCategory(embedRaw) ? embedRaw : DEFAULT_CONFIG.embedCategory,
583
799
  gateEmbeds: typeof o.gateEmbeds === "boolean" ? o.gateEmbeds : DEFAULT_CONFIG.gateEmbeds,
584
800
  hideBannerOnPolicyPages: typeof o.hideBannerOnPolicyPages === "boolean" ? o.hideBannerOnPolicyPages : DEFAULT_CONFIG.hideBannerOnPolicyPages,
801
+ bannerPosition: normalizeBannerPosition(o.bannerPosition),
585
802
  defaultLocale: typeof o.defaultLocale === "string" && o.defaultLocale ? o.defaultLocale : DEFAULT_CONFIG.defaultLocale,
586
803
  localeOverrides: parseLocaleOverrides(o.localeOverrides),
587
- theme: {
804
+ themeProfile: normalizeThemeChoice(o.themeProfile),
805
+ theme: themeForChoice(normalizeThemeChoice(o.themeProfile), {
588
806
  bg: normalizeHexColor(typeof themeRaw.bg === "string" ? themeRaw.bg : "", DEFAULT_THEME.bg),
589
807
  text: normalizeHexColor(typeof themeRaw.text === "string" ? themeRaw.text : "", DEFAULT_THEME.text),
590
808
  accent: normalizeHexColor(typeof themeRaw.accent === "string" ? themeRaw.accent : "", DEFAULT_THEME.accent),
591
809
  radiusPx: normalizeRadiusPx(themeRaw.radiusPx, DEFAULT_THEME.radiusPx)
592
- }
810
+ })
593
811
  };
594
812
  }
595
813
  function assertAnalyticsId(provider, id, umamiScriptUrl) {
@@ -604,7 +822,7 @@ function assertAnalyticsId(provider, id, umamiScriptUrl) {
604
822
  }
605
823
  if (provider === "umami") {
606
824
  if (!isUmamiId(t)) throw new Error("Umami website ID looks invalid.");
607
- if (!isHttpsUrl(umamiScriptUrl)) throw new Error("Umami requires the https script URL (Cloud or your self-hosted tracker).");
825
+ if (!isSafeHttpsScriptUrl(umamiScriptUrl)) throw new Error("Umami requires an https script URL on a public host (Cloud or your self-hosted tracker).");
608
826
  return;
609
827
  }
610
828
  if (provider === "ga4") {
@@ -615,6 +833,14 @@ function assertAnalyticsId(provider, id, umamiScriptUrl) {
615
833
  if (!isGtmId(t)) throw new Error("Google Tag Manager ID must look like GTM-XXXX.");
616
834
  return;
617
835
  }
836
+ if (provider === "clarity") {
837
+ if (!isClarityId(t)) throw new Error("Clarity project ID must be 7–20 letters or digits.");
838
+ return;
839
+ }
840
+ if (provider === "uet") {
841
+ if (!isUetId(t)) throw new Error("UET tag ID must be 6–12 digits.");
842
+ return;
843
+ }
618
844
  if (provider === "simpleanalytics" && t && !isHostname$1(t)) throw new Error("Simple Analytics hostname must be a domain like example.com, or empty.");
619
845
  }
620
846
  /** Validate admin-submitted fields; throws an Error with a short message on failure */
@@ -634,22 +860,23 @@ function assertValidSavedConfig(input) {
634
860
  const tokenTrim = input.cloudflareToken.trim();
635
861
  if (ap === "cloudflare") assertValidCloudflareToken(tokenTrim);
636
862
  const umamiScriptUrl = input.umamiScriptUrl.trim();
637
- if (ap === "umami" && umamiScriptUrl && !isHttpsUrl(umamiScriptUrl)) throw new Error(invalidScriptUrlError("umami", umamiScriptUrl));
863
+ if (ap === "umami" && umamiScriptUrl && !isSafeHttpsScriptUrl(umamiScriptUrl)) throw new Error(invalidScriptUrlError("umami", umamiScriptUrl));
638
864
  if (ap !== "none" && ap !== "custom" && ap !== "cloudflare" && ap !== "simpleanalytics" && ap !== "gtm") assertAnalyticsId(ap, input.analyticsId, umamiScriptUrl);
639
865
  else if (ap === "simpleanalytics" && input.analyticsId.trim()) assertAnalyticsId(ap, input.analyticsId, umamiScriptUrl);
640
866
  else if (ap === "gtm") assertAnalyticsId(ap, input.analyticsId, umamiScriptUrl);
641
867
  const scriptHostAllowlist = parseHostnameAllowlist(input.scriptHostAllowlistText);
868
+ const embedHostAllowlist = parseHostnameAllowlist(input.embedHostAllowlistText);
642
869
  const cookieMaxAgeDays = parseCookieMaxAgeDays(input.cookieMaxAgeDays, 180);
643
870
  const scriptIntegrity = {};
644
871
  const assertScriptLine = (kind, line) => {
645
872
  const { src, integrity } = parseScriptUrlWithIntegrity(line);
646
873
  if (!src || src.length > 2048) throw new Error(invalidScriptUrlError(kind, line));
647
874
  if (!isSafeHttpsScriptUrl(src)) throw new Error(invalidScriptUrlError(kind, src));
648
- if (integrity && !isValidIntegrity(integrity)) throw new Error(`Invalid SRI for ${kind} script (expected sha256-|sha384-|sha512-…).`);
875
+ if (!hasSri(integrity)) throw new Error(`${kind} scripts require Subresource Integrity (sha256-, sha384-, or sha512-) after the URL.`);
649
876
  if (!isScriptHostAllowed(src, {
650
877
  allowlist: scriptHostAllowlist,
651
878
  allowPresets: false
652
- })) throw new Error(`${kind} script host is not on the allowlist. Add the hostname or clear the allowlist.`);
879
+ })) throw new Error(`${kind} script host is not on the allowlist. Add the hostname under Script host allowlist.`);
653
880
  if (integrity) scriptIntegrity[src] = integrity;
654
881
  return src;
655
882
  };
@@ -657,7 +884,7 @@ function assertValidSavedConfig(input) {
657
884
  if (!isScriptHostAllowed(umamiScriptUrl, {
658
885
  allowlist: scriptHostAllowlist,
659
886
  allowPresets: false
660
- })) throw new Error("Umami script host is not on the allowlist. Add the hostname or clear the allowlist.");
887
+ })) throw new Error("Umami script host is not on the allowlist. Add the hostname under Script host allowlist.");
661
888
  }
662
889
  const analyticsScriptUrls = [];
663
890
  if (ap === "custom") {
@@ -673,17 +900,22 @@ function assertValidSavedConfig(input) {
673
900
  if (!isEmbedCategory(embedCategory)) throw new Error("Embeds must require Functional or Marketing consent.");
674
901
  const defaultLocale = input.defaultLocale.trim().toLowerCase() || "en";
675
902
  if (!/^[a-z]{2,3}(?:-[a-z]{2})?$/.test(defaultLocale)) throw new Error("Default locale must look like en or pt-BR.");
903
+ const bannerPosition = input.bannerPosition.trim().toLowerCase();
904
+ if (bannerPosition !== "top" && bannerPosition !== "bottom") throw new Error("Banner position must be top or bottom.");
676
905
  const localeOverrides = parseLocaleOverridesText(input.localeOverridesText);
677
906
  for (const pack of Object.values(localeOverrides)) {
678
907
  if (pack.bannerTitle !== void 0) assertOptionalLength("Translated banner title", pack.bannerTitle, 120);
679
908
  if (pack.bannerMessage !== void 0) assertOptionalLength("Translated short notice", pack.bannerMessage, 600);
680
909
  }
910
+ const themeProfile = parseThemeChoice(input.themeProfile);
681
911
  const themeBg = input.themeBg.trim() || DEFAULT_THEME.bg;
682
912
  const themeText = input.themeText.trim() || DEFAULT_THEME.text;
683
913
  const themeAccent = input.themeAccent.trim() || DEFAULT_THEME.accent;
684
- if (input.themeBg.trim() && !/^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(themeBg)) throw new Error("Banner background must be a hex color like #111111.");
685
- if (input.themeText.trim() && !/^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(themeText)) throw new Error("Banner text color must be a hex color like #eeeeee.");
686
- if (input.themeAccent.trim() && !/^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(themeAccent)) throw new Error("Banner accent color must be a hex color like #3b82f6.");
914
+ if (themeProfile === "custom") {
915
+ if (input.themeBg.trim() && !/^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(themeBg)) throw new Error("Banner background must be a hex color like #111111.");
916
+ if (input.themeText.trim() && !/^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(themeText)) throw new Error("Banner text color must be a hex color like #eeeeee.");
917
+ if (input.themeAccent.trim() && !/^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(themeAccent)) throw new Error("Banner accent color must be a hex color like #3b82f6.");
918
+ }
687
919
  return normalizeConfig({
688
920
  bannerTitle: input.bannerTitle.trim() || DEFAULT_CONFIG.bannerTitle,
689
921
  bannerMessage: input.bannerMessage.trim() || DEFAULT_CONFIG.bannerMessage,
@@ -699,20 +931,23 @@ function assertValidSavedConfig(input) {
699
931
  marketingScriptUrls,
700
932
  scriptIntegrity,
701
933
  scriptHostAllowlist,
934
+ embedHostAllowlist,
702
935
  cookieMaxAgeDays,
703
936
  googleConsentMode: input.googleConsentMode,
704
937
  logConsentToServer: input.logConsentToServer,
705
938
  embedCategory,
706
939
  gateEmbeds: input.gateEmbeds,
707
940
  hideBannerOnPolicyPages: input.hideBannerOnPolicyPages,
941
+ bannerPosition,
708
942
  defaultLocale,
709
943
  localeOverrides,
710
- theme: {
944
+ themeProfile,
945
+ theme: themeForChoice(themeProfile, themeProfile === "custom" ? {
711
946
  bg: themeBg,
712
947
  text: themeText,
713
948
  accent: themeAccent,
714
949
  radiusPx: parseRadiusInput(input.themeRadius, DEFAULT_THEME.radiusPx)
715
- }
950
+ } : { ...DEFAULT_THEME })
716
951
  });
717
952
  }
718
953
  /** Safe to embed in `<script type="application/json">` (breakout-safe) */
@@ -830,19 +1065,83 @@ async function fetchLatestPublishedVersion(options) {
830
1065
  }
831
1066
  }
832
1067
  //#endregion
1068
+ //#region src/consent-csv.ts
1069
+ const FORMULA_PREFIX = /^[\t\r ]*[=+\-@]/;
1070
+ /** Quote a cell and neutralize spreadsheet formula injection. */
1071
+ function csvCell(value) {
1072
+ let s = value.replace(/\r\n|\n|\r/g, " ");
1073
+ if (FORMULA_PREFIX.test(s)) s = `'${s}`;
1074
+ return `"${s.replace(/"/g, "\"\"")}"`;
1075
+ }
1076
+ function boolCell(value) {
1077
+ return value === true ? "true" : value === false ? "false" : "";
1078
+ }
1079
+ function buildConsentCsv(rows) {
1080
+ const lines = [[
1081
+ "createdAt",
1082
+ "policyVersion",
1083
+ "functional",
1084
+ "analytics",
1085
+ "marketing",
1086
+ "gpc"
1087
+ ].map(csvCell).join(",")];
1088
+ for (const row of rows) lines.push([
1089
+ csvCell(typeof row.createdAt === "string" ? row.createdAt : ""),
1090
+ csvCell(typeof row.policyVersion === "string" ? row.policyVersion : ""),
1091
+ csvCell(boolCell(row.functional)),
1092
+ csvCell(boolCell(row.analytics)),
1093
+ csvCell(boolCell(row.marketing)),
1094
+ csvCell(row.gpc === true ? "true" : "")
1095
+ ].join(","));
1096
+ return `\uFEFF${lines.join("\r\n")}\r\n`;
1097
+ }
1098
+ //#endregion
1099
+ //#region src/cookie-purge.ts
1100
+ const ANALYTICS_COOKIE_EXACT = [
1101
+ "_ga",
1102
+ "_gid",
1103
+ "_gat"
1104
+ ];
1105
+ const ANALYTICS_COOKIE_PREFIX = ["_ga_", "_gat_"];
1106
+ const MARKETING_COOKIE_EXACT = [
1107
+ "_gcl_au",
1108
+ "_gcl_aw",
1109
+ "_gcl_dc",
1110
+ "_clck",
1111
+ "_clsk",
1112
+ "CLID",
1113
+ "_uetsid",
1114
+ "_uetvid"
1115
+ ];
1116
+ const MARKETING_COOKIE_PREFIX = ["_gcl_"];
1117
+ const PURGE_RULES = {
1118
+ analytics: {
1119
+ exact: [...ANALYTICS_COOKIE_EXACT],
1120
+ prefix: [...ANALYTICS_COOKIE_PREFIX]
1121
+ },
1122
+ marketing: {
1123
+ exact: [...MARKETING_COOKIE_EXACT],
1124
+ prefix: [...MARKETING_COOKIE_PREFIX]
1125
+ }
1126
+ };
1127
+ //#endregion
833
1128
  //#region src/public-bootstrap.ts
834
1129
  /**
835
1130
  * Public-site bootstrap. Banner copy is applied with textContent / createTextNode only.
836
1131
  * Embed/script URLs are re-validated in the browser (never trust DOM data-src alone).
837
1132
  */
838
1133
  function buildBodyBootstrap(pr) {
1134
+ const jsonLiteral = jsonForHtmlScript(pr);
1135
+ const purgeLiteral = jsonForHtmlScript(PURGE_RULES);
839
1136
  return `(function(){
840
- var C=${jsonForHtmlScript(pr)};
1137
+ var C=${jsonLiteral};
841
1138
  var CN="${COOKIE_NAME}";
1139
+ var PURGE=${purgeLiteral};
842
1140
  var listeners=[];
843
- var PRESET_HOSTS={"static.cloudflareinsights.com":1,"plausible.io":1,"cdn.usefathom.com":1,"scripts.simpleanalyticscdn.com":1,"www.googletagmanager.com":1,"googletagmanager.com":1};
844
- var YT_IFRAME=/^https:\\/\\/(?:www\\.)?youtube-nocookie\\.com\\/embed\\/[A-Za-z0-9_-]{11}\\/?$/;
845
- var VIMEO_IFRAME=/^https:\\/\\/player\\.vimeo\\.com\\/video\\/\\d{6,12}\\/?$/;
1141
+ var PRESET_HOSTS=${jsonForHtmlScript(Object.fromEntries([...PRESET_SCRIPT_HOSTS].map((host) => [host, 1])))};
1142
+ var YT_IFRAME=${YT_IFRAME.toString()};
1143
+ var VIMEO_IFRAME=${VIMEO_IFRAME.toString()};
1144
+ ${hostnameBlockScript()}
846
1145
  function readCookie(){
847
1146
  try{
848
1147
  var m=document.cookie.match(new RegExp("(?:^|;\\\\s*)"+CN+"=([^;]*)"));
@@ -867,7 +1166,64 @@ if(!flagOk(o.a)||!flagOk(o.m)||!flagOk(o.f))return null;
867
1166
  return {v:o.v,essential:true,functional:!!o.f,analytics:!!o.a,marketing:!!o.m};
868
1167
  }catch(e){return null;}
869
1168
  }
870
- function currentState(){return parseState(readCookie());}
1169
+ function gpcOn(){
1170
+ try{return navigator.globalPrivacyControl===true;}catch(e){return false;}
1171
+ }
1172
+ function effective(st){
1173
+ if(!st)return null;
1174
+ var g=gpcOn();
1175
+ return {v:st.v,essential:true,functional:!!st.functional,analytics:!!st.analytics,marketing:g?false:!!st.marketing,gpc:g};
1176
+ }
1177
+ function currentState(){return effective(parseState(readCookie()));}
1178
+ function cookieNameOk(name){return /^[A-Za-z0-9_]{1,64}$/.test(name);}
1179
+ function expireCookie(name){
1180
+ if(!cookieNameOk(name)||name===CN)return;
1181
+ var secure=document.location.protocol==="https:"?"; Secure":"";
1182
+ var base=name+"=; Max-Age=0; Path=/; SameSite=Lax"+secure;
1183
+ document.cookie=base;
1184
+ var host=String(document.location.hostname||"");
1185
+ if(!/^[A-Za-z0-9.-]{1,253}$/.test(host)||host.indexOf("..")>=0)return;
1186
+ var hosts=[host];
1187
+ var labels=host.split(".");
1188
+ if(labels.length>=3)hosts.push(labels.slice(1).join("."));
1189
+ for(var i=0;i<hosts.length;i++){
1190
+ var h=hosts[i];
1191
+ if(!/^[A-Za-z0-9.-]{1,253}$/.test(h))continue;
1192
+ document.cookie=base+"; Domain="+h;
1193
+ document.cookie=base+"; Domain=."+h;
1194
+ }
1195
+ }
1196
+ function purgeSet(exact,prefixes){
1197
+ var raw="";
1198
+ try{raw=document.cookie;}catch(e){return;}
1199
+ if(!raw)return;
1200
+ if(raw.length>8192)raw=raw.slice(0,8192);
1201
+ raw.split(";").forEach(function(part){
1202
+ var name=(part.split("=")[0]||"").replace(/^\\s+/,"");
1203
+ if(!cookieNameOk(name)||name===CN)return;
1204
+ var hit=exact.indexOf(name)>=0;
1205
+ if(!hit){
1206
+ for(var i=0;i<prefixes.length;i++){
1207
+ if(prefixes[i]&&name.indexOf(prefixes[i])===0){hit=true;break;}
1208
+ }
1209
+ }
1210
+ if(hit)expireCookie(name);
1211
+ });
1212
+ }
1213
+ function purgeDenied(st){
1214
+ var rules=PURGE||{};
1215
+ if(!st.analytics){
1216
+ var a=rules.analytics||{};
1217
+ purgeSet(a.exact||[],a.prefix||[]);
1218
+ }
1219
+ if(!st.marketing){
1220
+ var m=rules.marketing||{};
1221
+ purgeSet(m.exact||[],m.prefix||[]);
1222
+ }
1223
+ }
1224
+ function deniedState(){
1225
+ return {v:"",essential:true,functional:false,analytics:false,marketing:false,gpc:gpcOn()};
1226
+ }
871
1227
  function emit(st){
872
1228
  listeners.slice().forEach(function(fn){try{fn(st);}catch(e){}});
873
1229
  try{document.dispatchEvent(new CustomEvent("emprivacy:change",{detail:st}));}catch(e){}
@@ -884,8 +1240,7 @@ if(/%0d|%0a|%09|%0b|%0c|%20/i.test(raw))return null;
884
1240
  var u=new URL(raw);
885
1241
  if(u.protocol!=="https:")return null;
886
1242
  if(u.username||u.password)return null;
887
- var h=u.hostname.toLowerCase();
888
- if(h==="localhost"||h.endsWith(".localhost")||h==="127.0.0.1"||h==="::1")return null;
1243
+ if(hostBlocked(u.hostname))return null;
889
1244
  return u;
890
1245
  }catch(e){return null;}
891
1246
  }
@@ -893,7 +1248,6 @@ function hostAllowed(u,allowPresets){
893
1248
  var h=u.hostname.toLowerCase();
894
1249
  if(allowPresets&&PRESET_HOSTS[h])return true;
895
1250
  var list=C.scriptHostAllowlist||[];
896
- if(!list.length)return true;
897
1251
  return list.indexOf(h)>=0;
898
1252
  }
899
1253
  function safeScriptUrl(raw,allowPresets){
@@ -903,7 +1257,7 @@ if(!hostAllowed(u,!!allowPresets))return null;
903
1257
  return u.href;
904
1258
  }
905
1259
  function allowedIframe(src){return YT_IFRAME.test(src)||VIMEO_IFRAME.test(src);}
906
- function allowedLink(src,kind){
1260
+ function allowedLink(src){
907
1261
  var u=parseHttps(src);
908
1262
  if(!u)return false;
909
1263
  var h=u.hostname.toLowerCase();
@@ -916,20 +1270,22 @@ return /^\\/profile\\/[^/]+\\/post\\/[^/]+\\/?$/.test(u.pathname);
916
1270
  if(h==="gist.github.com"){
917
1271
  return /^\\/[A-Za-z0-9-]{1,39}\\/[a-f0-9]{8,64}\\/?$/i.test(u.pathname);
918
1272
  }
919
- if(/^\\/@[^/]+\\/\\d+\\/?$/.test(u.pathname)||/^\\/users\\/[^/]+\\/statuses\\/\\d+\\/?$/.test(u.pathname))return true;
920
- return kind==="linkPreview";
1273
+ var list=C.embedHostAllowlist||[];
1274
+ return list.indexOf(h)>=0;
921
1275
  }
922
1276
  function alreadyScriptSrc(u){
923
1277
  try{
924
1278
  return Array.prototype.some.call(document.getElementsByTagName("script"),function(s){return s.src===u;});
925
1279
  }catch(e){return false;}
926
1280
  }
927
- function loadScript(src, attrs, integrity, allowPresets){
1281
+ function loadScript(src, attrs, integrity, allowPresets, requireIntegrity){
928
1282
  var safe=safeScriptUrl(src,allowPresets);
929
1283
  if(!safe||alreadyScriptSrc(safe))return;
1284
+ var sriOk=integrity&&/^sha(?:256|384|512)-[A-Za-z0-9+/=]+$/.test(integrity);
1285
+ if(requireIntegrity&&!sriOk)return;
930
1286
  var e=document.createElement("script");
931
1287
  e.src=safe;e.async=true;e.referrerPolicy="no-referrer-when-downgrade";
932
- if(integrity&&/^sha(?:256|384|512)-[A-Za-z0-9+/=]+$/.test(integrity)){
1288
+ if(sriOk){
933
1289
  e.integrity=integrity;
934
1290
  e.crossOrigin="anonymous";
935
1291
  }
@@ -951,10 +1307,10 @@ e.setAttribute("data-cf-beacon",JSON.stringify({token:token}));
951
1307
  e.referrerPolicy="no-referrer-when-downgrade";
952
1308
  document.head.appendChild(e);
953
1309
  }
954
- function applyAnalytics(){
1310
+ function applyAnalytics(st){
955
1311
  var L=C.loader||{type:"none"};
956
1312
  if(L.type==="cloudflare") loadCloudflareBeacon(L.token);
957
- else if(L.type==="custom") (L.scripts||[]).forEach(function(s){loadScript(s.src,null,s.integrity,false);});
1313
+ else if(L.type==="custom") (L.scripts||[]).forEach(function(s){loadScript(s.src,null,s.integrity,false,true);});
958
1314
  else if(L.type==="plausible") loadScript(L.src,{"data-domain":L.domain},null,true);
959
1315
  else if(L.type==="fathom") loadScript(L.src,{"data-site":L.siteId},null,true);
960
1316
  else if(L.type==="umami") loadScript(L.src,{"data-website-id":L.websiteId},null,false);
@@ -966,20 +1322,48 @@ loadScript("https://www.googletagmanager.com/gtag/js?id="+encodeURIComponent(L.m
966
1322
  window.gtag("js",new Date());
967
1323
  window.gtag("config",L.measurementId);
968
1324
  }
1325
+ else if(L.type==="clarity"){
1326
+ var id=String(L.projectId||"");
1327
+ if(!/^[a-z0-9]{7,20}$/i.test(id))return;
1328
+ window.clarity=window.clarity||function(){(window.clarity.q=window.clarity.q||[]).push(arguments);};
1329
+ window.clarity("consentv2",{ad_Storage:st.marketing?"granted":"denied",analytics_Storage:"granted"});
1330
+ loadScript("https://www.clarity.ms/tag/"+encodeURIComponent(id),null,null,true);
1331
+ }
969
1332
  }
970
1333
  function applyMarketing(){
971
1334
  var urls=C.marketingScripts||[];
972
- urls.forEach(function(s){loadScript(s.src,null,s.integrity,false);});
1335
+ urls.forEach(function(s){loadScript(s.src,null,s.integrity,false,true);});
973
1336
  var L=C.loader||{type:"none"};
974
1337
  if(L.type==="gtm"){
975
1338
  window.dataLayer=window.dataLayer||[];
976
1339
  window.dataLayer.push({"gtm.start":Date.now(),event:"gtm.js"});
977
1340
  loadScript("https://www.googletagmanager.com/gtm.js?id="+encodeURIComponent(L.containerId),null,null,true);
978
1341
  }
1342
+ else if(L.type==="uet"){
1343
+ var id=String(L.tagId||"");
1344
+ if(!/^[0-9]{6,12}$/.test(id))return;
1345
+ window.uetq=window.uetq||[];
1346
+ window.uetq.push("consent","update",{"ad_storage":"granted"});
1347
+ var src="https://bat.bing.com/bat.js";
1348
+ if(alreadyScriptSrc(src))return;
1349
+ var n=document.createElement("script");
1350
+ n.src=src;
1351
+ n.async=true;
1352
+ n.onload=function(){
1353
+ try{
1354
+ var q=window.uetq;
1355
+ window.uetq=new window.UET({ti:id,enableAutoSpaTracking:false,q:q});
1356
+ window.uetq.push("pageLoad");
1357
+ }catch(e){}
1358
+ };
1359
+ document.head.appendChild(n);
1360
+ }
979
1361
  }
980
1362
  function applyScripts(st){
981
- if(st.analytics) applyAnalytics();
982
- if(st.marketing) applyMarketing();
1363
+ var eff=effective(st)||st;
1364
+ purgeDenied(eff);
1365
+ if(eff.analytics) applyAnalytics(eff);
1366
+ if(eff.marketing) applyMarketing();
983
1367
  }
984
1368
  function gtagUpdate(st){
985
1369
  if(!C.googleConsentMode||!window.gtag)return;
@@ -999,7 +1383,7 @@ fetch(C.recordPath,{
999
1383
  method:"POST",
1000
1384
  credentials:"same-origin",
1001
1385
  headers:{"Content-Type":"application/json"},
1002
- body:JSON.stringify({policyVersion:C.policyVersion,functional:!!st.functional,analytics:!!st.analytics,marketing:!!st.marketing})
1386
+ body:JSON.stringify({policyVersion:C.policyVersion,functional:!!st.functional,analytics:!!st.analytics,marketing:!!st.marketing,gpc:!!st.gpc})
1003
1387
  }).catch(function(){});
1004
1388
  }
1005
1389
  function hide(el){if(el)el.setAttribute("hidden","");el&&(el.style.display="none");}
@@ -1014,17 +1398,17 @@ var f=document.createElement("iframe");
1014
1398
  f.src=src;
1015
1399
  f.setAttribute("loading","lazy");
1016
1400
  f.setAttribute("referrerpolicy","strict-origin-when-cross-origin");
1017
- f.setAttribute("allow","accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share");
1401
+ f.setAttribute("allow",${JSON.stringify(EMBED_IFRAME_ALLOW)});
1018
1402
  f.setAttribute("allowfullscreen","");
1019
- f.setAttribute("sandbox","allow-scripts allow-same-origin allow-presentation");
1403
+ f.setAttribute("sandbox",${JSON.stringify(EMBED_IFRAME_SANDBOX)});
1020
1404
  f.setAttribute("title",box.getAttribute("data-label")||"Embed");
1021
1405
  f.style.width="100%";
1022
1406
  f.style.aspectRatio="16/9";
1023
1407
  f.style.border="0";
1024
1408
  box.replaceWith(f);
1025
1409
  }
1026
- function mountLink(box,src,label,kind){
1027
- if(!allowedLink(src,kind))return;
1410
+ function mountLink(box,src,label){
1411
+ if(!allowedLink(src))return;
1028
1412
  var a=document.createElement("a");
1029
1413
  a.href=src;
1030
1414
  a.rel="noopener noreferrer nofollow";
@@ -1043,13 +1427,13 @@ if(loadBtn) loadBtn.textContent=C.ui.loadEmbed;
1043
1427
  if(!embedAllowed(st))return;
1044
1428
  var src=box.getAttribute("data-src")||"";
1045
1429
  var mode=box.getAttribute("data-mode")||"";
1046
- var kind=box.getAttribute("data-emprivacy-embed")||"";
1047
1430
  if(mode==="iframe") mountIframe(box,src);
1048
- else mountLink(box,src,box.getAttribute("data-label")||"",kind);
1431
+ else mountLink(box,src,box.getAttribute("data-label")||"");
1049
1432
  });
1050
1433
  }
1051
1434
  function persist(a,m,f,opts){
1052
- var st={v:C.policyVersion,essential:true,functional:!!f,analytics:!!a,marketing:!!m};
1435
+ if(gpcOn())m=false;
1436
+ var st={v:C.policyVersion,essential:true,functional:!!f,analytics:!!a,marketing:!!m,gpc:gpcOn()};
1053
1437
  writeCookie(JSON.stringify({v:st.v,a:st.analytics?1:0,m:st.marketing?1:0,f:st.functional?1:0}));
1054
1438
  logServer(st);
1055
1439
  gtagUpdate(st);
@@ -1163,18 +1547,52 @@ ic.appendChild(document.createTextNode("🍪"));
1163
1547
  trigger.appendChild(ic);
1164
1548
  function showTrigger(){trigger.removeAttribute("hidden");}
1165
1549
  function hideTrigger(){trigger.setAttribute("hidden","");}
1550
+ function focusables(bar){
1551
+ return Array.prototype.filter.call(bar.querySelectorAll("a[href],button:not([disabled]),input:not([disabled]),summary"),function(el){
1552
+ return !el.closest("[hidden]");
1553
+ });
1554
+ }
1555
+ function bindDialog(bar,isReopen){
1556
+ bar.tabIndex=-1;
1557
+ function closeReopen(){
1558
+ if(bar.parentNode)bar.parentNode.removeChild(bar);
1559
+ showTrigger();
1560
+ try{trigger.focus();}catch(e){}
1561
+ }
1562
+ bar.addEventListener("keydown",function(ev){
1563
+ if(ev.key==="Escape"){
1564
+ if(!isReopen)return;
1565
+ ev.preventDefault();
1566
+ closeReopen();
1567
+ return;
1568
+ }
1569
+ if(ev.key!=="Tab")return;
1570
+ var nodes=focusables(bar);
1571
+ if(!nodes.length){ev.preventDefault();return;}
1572
+ var first=nodes[0],last=nodes[nodes.length-1],active=document.activeElement;
1573
+ if(ev.shiftKey){
1574
+ if(active===first||active===bar){ev.preventDefault();last.focus();}
1575
+ }else if(active===last){
1576
+ ev.preventDefault();first.focus();
1577
+ }
1578
+ });
1579
+ if(isReopen) bar.__emprivacyClose=closeReopen;
1580
+ try{bar.focus();}catch(e){}
1581
+ }
1166
1582
  function buildPanel(isReopen,hint){
1167
1583
  var st=parseState(readCookie());
1168
1584
  var aOn=st?!!st.analytics:(C.strictDefaults?false:true);
1169
- var mOn=st?!!st.marketing:(C.strictDefaults?false:true);
1585
+ var mOn=gpcOn()?false:(st?!!st.marketing:(C.strictDefaults?false:true));
1170
1586
  var fOn=st?!!st.functional:(C.strictDefaults?false:true);
1171
1587
  if(isReopen) hideTrigger();
1172
1588
  var bar=document.createElement("div");
1173
- bar.className="emprivacy-bar"+(isReopen?" emprivacy-bar--reopen":"");
1589
+ bar.className="emprivacy-bar"+(C.bannerPosition==="top"?" emprivacy-bar--top":"")+(isReopen?" emprivacy-bar--reopen":"");
1174
1590
  bar.setAttribute("role","dialog");
1175
1591
  bar.setAttribute("aria-modal",isReopen?"true":"false");
1176
1592
  var title=document.createElement("h2");
1593
+ title.id=isReopen?"emprivacy-reopen-title":"emprivacy-banner-title";
1177
1594
  title.className="emprivacy-title";
1595
+ bar.setAttribute("aria-labelledby",title.id);
1178
1596
  title.appendChild(document.createTextNode(C.bannerTitle));
1179
1597
  var msg=document.createElement("p");
1180
1598
  msg.className="emprivacy-msg";
@@ -1199,6 +1617,7 @@ note.appendChild(document.createTextNode(C.ui.essentialNote));
1199
1617
  var fr=mkRow(C.ui.functional,isReopen?"emprivacy-rf":"emprivacy-f",fOn,false);
1200
1618
  var er=mkRow(C.ui.analytics,isReopen?"emprivacy-ra":"emprivacy-a",aOn,false);
1201
1619
  var mr=mkRow(C.ui.marketing,isReopen?"emprivacy-rm":"emprivacy-m",mOn,false);
1620
+ if(gpcOn()){mr.box.checked=false;mr.box.disabled=true;}
1202
1621
  opts.appendChild(ess.wrap);
1203
1622
  opts.appendChild(note);
1204
1623
  opts.appendChild(fr.wrap);
@@ -1238,14 +1657,21 @@ btnClose.type="button";
1238
1657
  btnClose.className="emprivacy-btn";
1239
1658
  btnClose.appendChild(document.createTextNode(C.ui.close));
1240
1659
  btnClose.addEventListener("click",function(){
1241
- if(bar.parentNode)bar.parentNode.removeChild(bar);
1242
- showTrigger();
1660
+ if(typeof bar.__emprivacyClose==="function")bar.__emprivacyClose();
1243
1661
  });
1244
1662
  actions.appendChild(btnClose);
1245
1663
  }
1246
1664
  actions.appendChild(btnSave);
1247
1665
  bar.appendChild(title);
1248
1666
  bar.appendChild(msg);
1667
+ if(gpcOn()){
1668
+ var gpcEl=document.createElement("p");
1669
+ gpcEl.className="emprivacy-note";
1670
+ gpcEl.id=isReopen?"emprivacy-gpc-reopen":"emprivacy-gpc-note";
1671
+ gpcEl.appendChild(document.createTextNode(C.ui.gpcNote||""));
1672
+ bar.appendChild(gpcEl);
1673
+ bar.setAttribute("aria-describedby",gpcEl.id);
1674
+ }
1249
1675
  bar.appendChild(links);
1250
1676
  bar.appendChild(opts);
1251
1677
  bar.appendChild(actions);
@@ -1255,7 +1681,9 @@ function openReopenPanel(hint){
1255
1681
  if(root.querySelector(".emprivacy-bar--reopen"))return;
1256
1682
  var st=parseState(readCookie());
1257
1683
  if(!st)return;
1258
- root.appendChild(buildPanel(true,hint||""));
1684
+ var panel=buildPanel(true,hint||"");
1685
+ root.appendChild(panel);
1686
+ bindDialog(panel,true);
1259
1687
  }
1260
1688
  openPanel=function(hint){
1261
1689
  var st=parseState(readCookie());
@@ -1270,7 +1698,7 @@ note.appendChild(document.createTextNode(hint));
1270
1698
  var opts=existing.querySelector(".emprivacy-opts");
1271
1699
  if(opts){opts.insertBefore(note,opts.firstChild);show(opts);}
1272
1700
  }
1273
- try{existing.scrollIntoView({block:"end"});}catch(e){}
1701
+ try{existing.scrollIntoView({block:C.bannerPosition==="top"?"start":"end"});}catch(e){}
1274
1702
  }
1275
1703
  }
1276
1704
  };
@@ -1294,18 +1722,21 @@ openPanel(C.ui.embedNeedConsent);
1294
1722
  }
1295
1723
  wireEmbedButtons();
1296
1724
  if(!needBanner()){
1297
- var st=parseState(readCookie());
1725
+ var st=currentState();
1298
1726
  if(st){
1299
1727
  applyScripts(st);
1300
1728
  gtagUpdate(st);
1301
1729
  hydrateEmbeds(st);
1302
- }
1730
+ }else purgeDenied(deniedState());
1303
1731
  showTrigger();
1304
1732
  trigger.addEventListener("click",function(){openReopenPanel();});
1305
1733
  installApi();
1306
1734
  return;
1307
1735
  }
1308
- root.appendChild(buildPanel(false,""));
1736
+ purgeDenied(deniedState());
1737
+ var first=buildPanel(false,"");
1738
+ root.appendChild(first);
1739
+ bindDialog(first,false);
1309
1740
  trigger.addEventListener("click",function(){openReopenPanel();});
1310
1741
  installApi();
1311
1742
  }
@@ -1316,6 +1747,11 @@ else mount();
1316
1747
  function buildGoogleConsentHeadScript() {
1317
1748
  return `(function(){window.dataLayer=window.dataLayer||[];function g(){window.dataLayer.push(arguments);}window.gtag=g;g("consent","default",{"analytics_storage":"denied","ad_storage":"denied","ad_user_data":"denied","ad_personalization":"denied","functionality_storage":"denied","security_storage":"granted","personalization_storage":"denied"});})();`;
1318
1749
  }
1750
+ /** Denied-by-default Microsoft signals. The tag itself loads only after the matching category is allowed. */
1751
+ function buildMicrosoftConsentHeadScript(kind) {
1752
+ if (kind === "clarity") return `(function(){window.clarity=window.clarity||function(){(window.clarity.q=window.clarity.q||[]).push(arguments);};window.clarity("consentv2",{"ad_Storage":"denied","analytics_Storage":"denied"});})();`;
1753
+ return `(function(){window.uetq=window.uetq||[];window.uetq.push("consent","default",{"ad_storage":"denied"});})();`;
1754
+ }
1319
1755
  //#endregion
1320
1756
  //#region src/vendors.ts
1321
1757
  const PLAUSIBLE_SCRIPT = "https://plausible.io/js/script.js";
@@ -1328,13 +1764,16 @@ function buildAnalyticsLoader(cfg) {
1328
1764
  type: "cloudflare",
1329
1765
  token: cfg.cloudflareWebAnalyticsToken
1330
1766
  } : { type: "none" };
1331
- case "custom": return cfg.analyticsScriptUrls.length ? {
1332
- type: "custom",
1333
- scripts: cfg.analyticsScriptUrls.map((src) => ({
1767
+ case "custom": {
1768
+ const scripts = cfg.analyticsScriptUrls.filter((src) => isPublishableCustomScript(src, cfg.scriptIntegrity[src], cfg.scriptHostAllowlist)).map((src) => ({
1334
1769
  src,
1335
1770
  integrity: cfg.scriptIntegrity[src] ?? null
1336
- }))
1337
- } : { type: "none" };
1771
+ }));
1772
+ return scripts.length ? {
1773
+ type: "custom",
1774
+ scripts
1775
+ } : { type: "none" };
1776
+ }
1338
1777
  case "plausible": return isHostname$1(cfg.analyticsId) ? {
1339
1778
  type: "plausible",
1340
1779
  src: PLAUSIBLE_SCRIPT,
@@ -1345,7 +1784,10 @@ function buildAnalyticsLoader(cfg) {
1345
1784
  src: FATHOM_SCRIPT,
1346
1785
  siteId: cfg.analyticsId.trim()
1347
1786
  } : { type: "none" };
1348
- case "umami": return isUmamiId(cfg.analyticsId) && cfg.umamiScriptUrl ? {
1787
+ case "umami": return isUmamiId(cfg.analyticsId) && cfg.umamiScriptUrl && isScriptHostAllowed(cfg.umamiScriptUrl, {
1788
+ allowlist: cfg.scriptHostAllowlist,
1789
+ allowPresets: false
1790
+ }) ? {
1349
1791
  type: "umami",
1350
1792
  src: cfg.umamiScriptUrl,
1351
1793
  websiteId: cfg.analyticsId.trim()
@@ -1362,6 +1804,14 @@ function buildAnalyticsLoader(cfg) {
1362
1804
  type: "gtm",
1363
1805
  containerId: cfg.analyticsId.trim()
1364
1806
  } : { type: "none" };
1807
+ case "clarity": return isClarityId(cfg.analyticsId) ? {
1808
+ type: "clarity",
1809
+ projectId: cfg.analyticsId.trim()
1810
+ } : { type: "none" };
1811
+ case "uet": return isUetId(cfg.analyticsId) ? {
1812
+ type: "uet",
1813
+ tagId: cfg.analyticsId.trim()
1814
+ } : { type: "none" };
1365
1815
  default: return { type: "none" };
1366
1816
  }
1367
1817
  }
@@ -1428,6 +1878,20 @@ function buildVendorList(cfg) {
1428
1878
  purpose: "Loads GTM only after Marketing consent (containers often fire ads/remarketing). You remain responsible for tags inside the container.",
1429
1879
  policyUrl: "https://policies.google.com/privacy"
1430
1880
  });
1881
+ else if (loader.type === "clarity") rows.push({
1882
+ id: "clarity",
1883
+ name: "Microsoft Clarity",
1884
+ category: "analytics",
1885
+ purpose: "Clarity loads only after Analytics consent. Advertising storage stays denied until Marketing is also allowed. The project ID is not shown here.",
1886
+ policyUrl: "https://www.microsoft.com/privacy"
1887
+ });
1888
+ else if (loader.type === "uet") rows.push({
1889
+ id: "uet",
1890
+ name: "Microsoft Advertising (UET)",
1891
+ category: "marketing",
1892
+ purpose: "The UET tag loads only after Marketing consent. Until then, advertising storage stays denied and the tag is not injected.",
1893
+ policyUrl: "https://www.microsoft.com/privacy"
1894
+ });
1431
1895
  else if (loader.type === "custom") for (const [i, script] of loader.scripts.entries()) {
1432
1896
  let host = "custom script";
1433
1897
  try {
@@ -1463,11 +1927,13 @@ function buildVendorList(cfg) {
1463
1927
  }
1464
1928
  //#endregion
1465
1929
  //#region src/version.ts
1466
- const VERSION = "3.0.0";
1930
+ const VERSION = "3.2.0";
1467
1931
  //#endregion
1468
1932
  //#region src/runtime.ts
1469
- /** Soft cap for anonymous consent POSTs per client fingerprint per UTC hour. */
1933
+ /** Soft cap for anonymous consent POSTs per trusted client address per UTC hour. */
1470
1934
  const RECORD_RATE_LIMIT = 30;
1935
+ /** Site-wide cap so one client cannot fill the consent log in a single hour. */
1936
+ const RECORD_GLOBAL_LIMIT = 60;
1471
1937
  const RECORD_LOG_CAP = 500;
1472
1938
  const ADMIN_SETTINGS_PATH = "/settings";
1473
1939
  const SAVE_ACTION_ID = "emprivacy-save";
@@ -1475,8 +1941,10 @@ const recordInput = z.object({
1475
1941
  policyVersion: z.string().trim().min(1).max(64),
1476
1942
  functional: z.boolean(),
1477
1943
  analytics: z.boolean(),
1478
- marketing: z.boolean()
1944
+ marketing: z.boolean(),
1945
+ gpc: z.boolean().optional()
1479
1946
  });
1947
+ const EXPORT_PATH = `/_emdash/api/plugins/${PLUGIN_ID}/consent-export`;
1480
1948
  async function loadConfig(ctx) {
1481
1949
  const raw = await ctx.kv.get(KV_KEY);
1482
1950
  if (!raw) return normalizeConfig({});
@@ -1502,28 +1970,17 @@ function absolutePolicyHref(stored, ctx) {
1502
1970
  return null;
1503
1971
  }
1504
1972
  }
1505
- function clientFingerKey(request) {
1506
- const cf = request.headers.get("cf-connecting-ip")?.trim();
1507
- const xff = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
1508
- const ua = (request.headers.get("user-agent") ?? "").slice(0, 120);
1509
- return `${cf || xff || "unknown"}|${ua}`;
1510
- }
1511
1973
  async function hashFinger(s) {
1512
1974
  const data = new TextEncoder().encode(`emprivacy-rl:${s}`);
1513
1975
  const dig = await crypto.subtle.digest("SHA-256", data);
1514
1976
  return Array.from(new Uint8Array(dig)).map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 32);
1515
1977
  }
1516
- async function allowRecordWrite(ctx, request) {
1517
- const key = `consent:rl:${rateHourKey()}:${await hashFinger(clientFingerKey(request))}`;
1518
- try {
1519
- const raw = await ctx.kv.get(key);
1520
- const n = raw && /^\d{1,6}$/.test(raw) ? Number(raw) : 0;
1521
- if (n >= RECORD_RATE_LIMIT) return false;
1522
- await ctx.kv.set(key, String(n + 1));
1523
- return true;
1524
- } catch {
1525
- return false;
1526
- }
1978
+ async function allowRecordWrite(ctx) {
1979
+ const hour = rateHourKey();
1980
+ const ip = ctx.requestMeta?.ip?.trim() ?? "";
1981
+ const bucket = ip ? `ip:${await hashFinger(ip)}` : "untrusted";
1982
+ if (!await tryConsumeRateSlot(ctx.kv, `consent:rl:${hour}:global`, RECORD_GLOBAL_LIMIT)) return false;
1983
+ return tryConsumeRateSlot(ctx.kv, `consent:rl:${hour}:${bucket}`, RECORD_RATE_LIMIT);
1527
1984
  }
1528
1985
  function publicRuntime(cfg, ctx, page) {
1529
1986
  const copy = resolvePublicCopy(cfg, page.locale);
@@ -1541,14 +1998,16 @@ function publicRuntime(cfg, ctx, page) {
1541
1998
  embedCategory: cfg.embedCategory,
1542
1999
  gateEmbeds: cfg.gateEmbeds,
1543
2000
  hideBanner,
2001
+ bannerPosition: cfg.bannerPosition,
1544
2002
  theme: cfg.theme,
1545
2003
  ui: copy.ui,
1546
2004
  loader: buildAnalyticsLoader(cfg),
1547
- marketingScripts: cfg.marketingScriptUrls.map((src) => ({
2005
+ marketingScripts: cfg.marketingScriptUrls.filter((src) => isPublishableCustomScript(src, cfg.scriptIntegrity[src], cfg.scriptHostAllowlist)).map((src) => ({
1548
2006
  src,
1549
2007
  integrity: cfg.scriptIntegrity[src] ?? null
1550
2008
  })),
1551
2009
  scriptHostAllowlist: cfg.scriptHostAllowlist,
2010
+ embedHostAllowlist: cfg.embedHostAllowlist,
1552
2011
  scriptIntegrity: cfg.scriptIntegrity,
1553
2012
  cookieMaxAge: cookieMaxAgeSeconds(cfg.cookieMaxAgeDays),
1554
2013
  vendors: buildVendorList(cfg)
@@ -1556,8 +2015,9 @@ function publicRuntime(cfg, ctx, page) {
1556
2015
  }
1557
2016
  function themeStyle() {
1558
2017
  return `<style id="emprivacy-style">
1559
- #emprivacy-root{font-family:system-ui,sans-serif;font-size:14px;--emprivacy-bg:#111111;--emprivacy-text:#eeeeee;--emprivacy-accent:#3b82f6;--emprivacy-radius:6px}
1560
- .emprivacy-bar{position:fixed;z-index:99999;left:0;right:0;bottom:0;background:var(--emprivacy-bg);color:var(--emprivacy-text);padding:16px 20px 20px;box-shadow:0 -4px 24px rgba(0,0,0,.25);max-height:45vh;overflow:auto}
2018
+ #emprivacy-root{font-family:system-ui,sans-serif;font-size:14px;--emprivacy-bg:#0f172a;--emprivacy-text:#f8fafc;--emprivacy-accent:#3b82f6;--emprivacy-radius:8px}
2019
+ .emprivacy-bar{position:fixed;z-index:99999;inset-inline:0;bottom:0;background:var(--emprivacy-bg);color:var(--emprivacy-text);padding:16px 20px max(20px, env(safe-area-inset-bottom));box-shadow:0 -4px 24px rgba(0,0,0,.25);max-height:min(45vh, 100dvh);overflow:auto}
2020
+ .emprivacy-bar--top{top:0;bottom:auto;padding:max(16px, env(safe-area-inset-top)) 20px 16px;box-shadow:0 4px 24px rgba(0,0,0,.25)}
1561
2021
  .emprivacy-title{margin:0 0 8px;font-size:1.1rem}
1562
2022
  .emprivacy-msg,.emprivacy-note{margin:0 0 10px;line-height:1.4;opacity:.95}
1563
2023
  .emprivacy-note{font-size:.85rem}
@@ -1566,6 +2026,8 @@ function themeStyle() {
1566
2026
  .emprivacy-switch{display:block;margin:6px 0}
1567
2027
  .emprivacy-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
1568
2028
  .emprivacy-btn{border-radius:var(--emprivacy-radius);border:1px solid color-mix(in srgb,var(--emprivacy-text) 35%,transparent);background:transparent;color:var(--emprivacy-text);padding:8px 12px;cursor:pointer}
2029
+ .emprivacy-btn:focus-visible,.emprivacy-switch input:focus-visible,.emprivacy-links a:focus-visible,.emprivacy-vendors summary:focus-visible,.emprivacy-bar:focus-visible{outline:2px solid var(--emprivacy-accent);outline-offset:2px}
2030
+ .emprivacy-btn-primary:focus-visible{outline-color:#fff}
1569
2031
  .emprivacy-btn-primary{background:var(--emprivacy-accent);border-color:var(--emprivacy-accent);color:#fff}
1570
2032
  .emprivacy-cookie-trigger{position:fixed;z-index:99998;left:16px;bottom:16px;width:48px;height:48px;border-radius:50%;border:1px solid color-mix(in srgb,var(--emprivacy-text) 35%,transparent);background:var(--emprivacy-bg);color:var(--emprivacy-text);box-shadow:0 2px 12px rgba(0,0,0,.2);cursor:pointer;padding:0;display:flex;align-items:center;justify-content:center}
1571
2033
  .emprivacy-cookie-trigger:hover{filter:brightness(1.1)}
@@ -1636,6 +2098,12 @@ function createPlugin() {
1636
2098
  key: "emprivacy:gcm-default",
1637
2099
  code: buildGoogleConsentHeadScript()
1638
2100
  });
2101
+ if (cfg.analyticsProvider === "clarity" || cfg.analyticsProvider === "uet") headScripts.push({
2102
+ kind: "inline-script",
2103
+ placement: "head",
2104
+ key: "emprivacy:ms-consent-default",
2105
+ code: buildMicrosoftConsentHeadScript(cfg.analyticsProvider)
2106
+ });
1639
2107
  const boot = {
1640
2108
  kind: "inline-script",
1641
2109
  placement: "body:end",
@@ -1652,6 +2120,7 @@ function createPlugin() {
1652
2120
  },
1653
2121
  routes: {
1654
2122
  admin: { handler: async (ctx) => {
2123
+ if (!ctx.user) return new Response("unauthorized", { status: 401 });
1655
2124
  const interaction = ctx.input;
1656
2125
  if (interaction?.type === "page_load" && interaction.page === ADMIN_SETTINGS_PATH) return buildSettingsPage(ctx);
1657
2126
  if (interaction?.type === "form_submit" && interaction.action_id === SAVE_ACTION_ID) {
@@ -1677,12 +2146,15 @@ function createPlugin() {
1677
2146
  analyticsUrlsText: String(v.analytics_urls ?? ""),
1678
2147
  marketingUrlsText: String(v.marketing_urls ?? ""),
1679
2148
  scriptHostAllowlistText: String(v.script_host_allowlist ?? ""),
2149
+ embedHostAllowlistText: String(v.embed_host_allowlist ?? ""),
1680
2150
  cookieMaxAgeDays: String(v.cookie_max_age_days ?? "180"),
1681
2151
  googleConsentMode: asBool(v.google_cm),
1682
2152
  logConsentToServer: asBool(v.log_server),
1683
2153
  embedCategory: String(v.embed_category ?? "marketing"),
1684
2154
  gateEmbeds: asBool(v.gate_embeds),
1685
2155
  hideBannerOnPolicyPages: asBool(v.hide_on_policy),
2156
+ bannerPosition: String(v.banner_position ?? "bottom"),
2157
+ themeProfile: String(v.theme_profile ?? "slate"),
1686
2158
  defaultLocale: String(v.default_locale ?? "en"),
1687
2159
  localeOverridesText: String(v.locale_overrides ?? ""),
1688
2160
  themeBg: String(v.theme_bg ?? ""),
@@ -1732,13 +2204,15 @@ function createPlugin() {
1732
2204
  };
1733
2205
  const input = ctx.input;
1734
2206
  if (input.policyVersion !== cfg.policyVersion) return new Response("policy_version_mismatch", { status: 400 });
1735
- if (!await allowRecordWrite(ctx, ctx.request)) return new Response("rate_limited", { status: 429 });
2207
+ if (!await allowRecordWrite(ctx)) return new Response("rate_limited", { status: 429 });
2208
+ const gpc = input.gpc === true;
1736
2209
  const row = {
1737
2210
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1738
2211
  policyVersion: input.policyVersion,
1739
2212
  functional: input.functional,
1740
2213
  analytics: input.analytics,
1741
- marketing: input.marketing
2214
+ marketing: gpc ? false : input.marketing,
2215
+ ...gpc ? { gpc: true } : {}
1742
2216
  };
1743
2217
  try {
1744
2218
  if ((await ctx.storage.consentEvents.query({
@@ -1758,6 +2232,29 @@ function createPlugin() {
1758
2232
  return { ok: true };
1759
2233
  }
1760
2234
  },
2235
+ "consent-export": { handler: async (ctx) => {
2236
+ if (ctx.request.method !== "GET") return new Response("method_not_allowed", { status: 405 });
2237
+ if (!ctx.user) return new Response("unauthorized", { status: 401 });
2238
+ let rows = [];
2239
+ try {
2240
+ rows = (await ctx.storage.consentEvents.query({
2241
+ orderBy: { createdAt: "desc" },
2242
+ limit: RECORD_LOG_CAP
2243
+ })).items.map((item) => item.data);
2244
+ } catch {
2245
+ return new Response("export_unavailable", { status: 503 });
2246
+ }
2247
+ const body = buildConsentCsv(rows);
2248
+ return new Response(body, {
2249
+ status: 200,
2250
+ headers: {
2251
+ "Content-Type": "text/csv; charset=utf-8",
2252
+ "Content-Disposition": "attachment; filename=\"emprivacy-consent.csv\"",
2253
+ "Cache-Control": "private, no-store",
2254
+ "X-Content-Type-Options": "nosniff"
2255
+ }
2256
+ });
2257
+ } },
1761
2258
  vendors: {
1762
2259
  public: true,
1763
2260
  handler: async (ctx) => {
@@ -1770,7 +2267,7 @@ function createPlugin() {
1770
2267
  }
1771
2268
  async function buildSettingsPage(ctx) {
1772
2269
  const [cfg, latestPublished] = await Promise.all([loadConfig(ctx), fetchLatestPublishedVersion({ fetch: ctx.http?.fetch ?? globalThis.fetch })]);
1773
- const installedVersion = ctx.plugin?.version ?? "3.0.0";
2270
+ const installedVersion = ctx.plugin?.version ?? "3.2.0";
1774
2271
  const analyticsText = cfg.analyticsScriptUrls.map((src) => {
1775
2272
  const i = cfg.scriptIntegrity[src];
1776
2273
  return i ? `${src} ${i}` : src;
@@ -1794,9 +2291,10 @@ async function buildSettingsPage(ctx) {
1794
2291
  })).items.map((item) => {
1795
2292
  const d = item.data;
1796
2293
  const functional = typeof d.functional === "boolean" ? ` · functional ${d.functional ? "on" : "off"}` : "";
2294
+ const gpc = d.gpc === true ? " · GPC" : "";
1797
2295
  return {
1798
2296
  label: d.createdAt,
1799
- value: `policy ${d.policyVersion}${functional} · analytics ${d.analytics ? "on" : "off"} · marketing ${d.marketing ? "on" : "off"}`
2297
+ value: `policy ${d.policyVersion}${functional} · analytics ${d.analytics ? "on" : "off"} · marketing ${d.marketing ? "on" : "off"}${gpc}`
1800
2298
  };
1801
2299
  });
1802
2300
  } catch {
@@ -1893,6 +2391,19 @@ async function buildSettingsPage(ctx) {
1893
2391
  label: "Hide the first-visit banner on privacy and cookie policy pages",
1894
2392
  initial_value: cfg.hideBannerOnPolicyPages
1895
2393
  },
2394
+ {
2395
+ type: "radio",
2396
+ action_id: "banner_position",
2397
+ label: "Banner position",
2398
+ options: [{
2399
+ value: "bottom",
2400
+ label: "Bottom of the viewport"
2401
+ }, {
2402
+ value: "top",
2403
+ label: "Top of the viewport"
2404
+ }],
2405
+ initial_value: cfg.bannerPosition
2406
+ },
1896
2407
  {
1897
2408
  type: "radio",
1898
2409
  action_id: "analytics_platform",
@@ -1926,6 +2437,14 @@ async function buildSettingsPage(ctx) {
1926
2437
  value: "gtm",
1927
2438
  label: "Google Tag Manager (requires Marketing consent)"
1928
2439
  },
2440
+ {
2441
+ value: "clarity",
2442
+ label: "Microsoft Clarity (requires Analytics consent)"
2443
+ },
2444
+ {
2445
+ value: "uet",
2446
+ label: "Microsoft UET (requires Marketing consent)"
2447
+ },
1929
2448
  {
1930
2449
  value: "none",
1931
2450
  label: "None"
@@ -1951,8 +2470,8 @@ async function buildSettingsPage(ctx) {
1951
2470
  {
1952
2471
  type: "text_input",
1953
2472
  action_id: "analytics_id",
1954
- label: "Analytics ID (domain, site ID, G-…, or GTM-…)",
1955
- placeholder: "example.com / G-XXXX / GTM-XXXX",
2473
+ label: "Analytics ID (domain, site ID, G-…, GTM-…, Clarity project ID, or UET tag ID)",
2474
+ placeholder: "example.com / G-XXXX / GTM-XXXX / Clarity ID / UET digits",
1956
2475
  initial_value: cfg.analyticsId
1957
2476
  },
1958
2477
  {
@@ -1969,7 +2488,7 @@ async function buildSettingsPage(ctx) {
1969
2488
  {
1970
2489
  type: "text_input",
1971
2490
  action_id: "analytics_urls",
1972
- label: "Custom analytics script URLs (one https URL per line; optional trailing SRI: … sha384-…)",
2491
+ label: "Custom analytics script URLs (one https URL per line, SRI required: … sha384-…)",
1973
2492
  placeholder: "https://cdn.example/a.js sha384-…",
1974
2493
  multiline: true,
1975
2494
  initial_value: analyticsText,
@@ -1981,7 +2500,7 @@ async function buildSettingsPage(ctx) {
1981
2500
  {
1982
2501
  type: "text_input",
1983
2502
  action_id: "marketing_urls",
1984
- label: "Marketing script URLs (one https URL per line; optional trailing SRI: … sha384-…)",
2503
+ label: "Marketing script URLs (one https URL per line, SRI required: … sha384-…)",
1985
2504
  placeholder: "https://… (one per line)",
1986
2505
  multiline: true,
1987
2506
  initial_value: marketingText
@@ -1989,11 +2508,19 @@ async function buildSettingsPage(ctx) {
1989
2508
  {
1990
2509
  type: "text_input",
1991
2510
  action_id: "script_host_allowlist",
1992
- label: "Script host allowlist (optional; one hostname per line). When set, Custom analytics and marketing URLs must match.",
2511
+ label: "Script host allowlist (required for Custom, Umami, and marketing URLs; one hostname per line)",
1993
2512
  placeholder: "cdn.example.com\njs.stripe.com",
1994
2513
  multiline: true,
1995
2514
  initial_value: cfg.scriptHostAllowlist.join("\n")
1996
2515
  },
2516
+ {
2517
+ type: "text_input",
2518
+ action_id: "embed_host_allowlist",
2519
+ label: "Embed link hosts (one hostname per line). Mastodon and link previews become links only when listed. X, Bluesky, Gist, YouTube, and Vimeo stay built in.",
2520
+ placeholder: "mastodon.social\nexample.com",
2521
+ multiline: true,
2522
+ initial_value: cfg.embedHostAllowlist.join("\n")
2523
+ },
1997
2524
  {
1998
2525
  type: "text_input",
1999
2526
  action_id: "cookie_max_age_days",
@@ -2026,32 +2553,80 @@ async function buildSettingsPage(ctx) {
2026
2553
  }],
2027
2554
  initial_value: cfg.embedCategory
2028
2555
  },
2556
+ {
2557
+ type: "radio",
2558
+ action_id: "theme_profile",
2559
+ label: "Banner theme",
2560
+ options: [
2561
+ {
2562
+ value: "slate",
2563
+ label: "Slate"
2564
+ },
2565
+ {
2566
+ value: "paper",
2567
+ label: "Paper"
2568
+ },
2569
+ {
2570
+ value: "ink",
2571
+ label: "Ink"
2572
+ },
2573
+ {
2574
+ value: "indigo",
2575
+ label: "Indigo"
2576
+ },
2577
+ {
2578
+ value: "primer",
2579
+ label: "Primer"
2580
+ },
2581
+ {
2582
+ value: "custom",
2583
+ label: "Custom colors"
2584
+ }
2585
+ ],
2586
+ initial_value: cfg.themeProfile
2587
+ },
2029
2588
  {
2030
2589
  type: "text_input",
2031
2590
  action_id: "theme_bg",
2032
2591
  label: "Banner background (hex)",
2033
- placeholder: "#111111",
2034
- initial_value: cfg.theme.bg
2592
+ placeholder: "#0f172a",
2593
+ initial_value: cfg.theme.bg,
2594
+ condition: {
2595
+ field: "theme_profile",
2596
+ eq: "custom"
2597
+ }
2035
2598
  },
2036
2599
  {
2037
2600
  type: "text_input",
2038
2601
  action_id: "theme_text",
2039
2602
  label: "Banner text (hex)",
2040
- placeholder: "#eeeeee",
2041
- initial_value: cfg.theme.text
2603
+ placeholder: "#f8fafc",
2604
+ initial_value: cfg.theme.text,
2605
+ condition: {
2606
+ field: "theme_profile",
2607
+ eq: "custom"
2608
+ }
2042
2609
  },
2043
2610
  {
2044
2611
  type: "text_input",
2045
2612
  action_id: "theme_accent",
2046
2613
  label: "Banner accent (hex)",
2047
2614
  placeholder: "#3b82f6",
2048
- initial_value: cfg.theme.accent
2615
+ initial_value: cfg.theme.accent,
2616
+ condition: {
2617
+ field: "theme_profile",
2618
+ eq: "custom"
2619
+ }
2049
2620
  },
2050
2621
  {
2051
2622
  type: "text_input",
2052
2623
  action_id: "theme_radius",
2053
2624
  label: "Banner corner radius (0–24 px)",
2054
- placeholder: "6",
2625
+ condition: {
2626
+ field: "theme_profile",
2627
+ eq: "custom"
2628
+ },
2629
+ placeholder: "8",
2055
2630
  initial_value: String(cfg.theme.radiusPx)
2056
2631
  },
2057
2632
  {
@@ -2079,16 +2654,20 @@ async function buildSettingsPage(ctx) {
2079
2654
  type: "fields",
2080
2655
  fields: vendorFields
2081
2656
  },
2082
- ...cfg.logConsentToServer && consentFields.length > 0 ? [
2657
+ ...cfg.logConsentToServer ? [
2083
2658
  { type: "divider" },
2084
2659
  {
2085
2660
  type: "header",
2086
2661
  text: "Recent consent records"
2087
2662
  },
2088
2663
  {
2664
+ type: "context",
2665
+ text: `Download the log (up to ${RECORD_LOG_CAP} rows, no IP): ${EXPORT_PATH}. Signed-in admins only. Cells that start with = + - or @ are prefixed so a spreadsheet does not treat them as formulas.`
2666
+ },
2667
+ ...consentFields.length > 0 ? [{
2089
2668
  type: "fields",
2090
2669
  fields: consentFields
2091
- }
2670
+ }] : []
2092
2671
  ] : []
2093
2672
  ] };
2094
2673
  }
@@ -2141,22 +2720,9 @@ const POSTER_HOSTS = /* @__PURE__ */ new Set([
2141
2720
  "img.youtube.com",
2142
2721
  "i.vimeocdn.com"
2143
2722
  ]);
2144
- function hasUnsafeChars(s) {
2145
- return /[\u0000-\u001F\u007F\s]/.test(s);
2146
- }
2723
+ /** Same non-public host rules as script URLs. */
2147
2724
  function parseHttpsUrl(raw) {
2148
- const t = raw.trim();
2149
- if (!t || t.length > 2048 || hasUnsafeChars(t)) return null;
2150
- if (/%0d|%0a|%09|%0b|%0c|%20/i.test(t)) return null;
2151
- try {
2152
- const u = new URL(t);
2153
- if (u.protocol !== "https:") return null;
2154
- if (u.username || u.password) return null;
2155
- if (u.hostname === "localhost" || u.hostname.endsWith(".localhost")) return null;
2156
- return u;
2157
- } catch {
2158
- return null;
2159
- }
2725
+ return parseStrictHttpsUrl(raw);
2160
2726
  }
2161
2727
  function hostOf(u) {
2162
2728
  return u.hostname.toLowerCase();