@owlmeans/consent 0.1.18-rc.8 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +229 -0
  2. package/build/consts.d.ts +84 -23
  3. package/build/consts.d.ts.map +1 -1
  4. package/build/consts.js +110 -29
  5. package/build/consts.js.map +1 -1
  6. package/build/consts.local.d.ts +12 -0
  7. package/build/consts.local.d.ts.map +1 -0
  8. package/build/consts.local.js +12 -0
  9. package/build/consts.local.js.map +1 -0
  10. package/build/geo/types.d.ts +47 -0
  11. package/build/geo/types.d.ts.map +1 -0
  12. package/build/geo/types.js +2 -0
  13. package/build/geo/types.js.map +1 -0
  14. package/build/geo.d.ts +4 -0
  15. package/build/geo.d.ts.map +1 -0
  16. package/build/geo.js +96 -0
  17. package/build/geo.js.map +1 -0
  18. package/build/gtobserver/types.d.ts +75 -0
  19. package/build/gtobserver/types.d.ts.map +1 -0
  20. package/build/gtobserver/types.js +2 -0
  21. package/build/gtobserver/types.js.map +1 -0
  22. package/build/gtobserver.d.ts +7 -0
  23. package/build/gtobserver.d.ts.map +1 -0
  24. package/build/gtobserver.js +142 -0
  25. package/build/gtobserver.js.map +1 -0
  26. package/build/i18n/be.json +9 -4
  27. package/build/i18n/de.json +9 -4
  28. package/build/i18n/en.json +9 -4
  29. package/build/i18n/es.json +9 -4
  30. package/build/i18n/fr.json +9 -4
  31. package/build/i18n/pl.json +9 -4
  32. package/build/i18n/ru.json +9 -4
  33. package/build/i18n/types.d.ts +11 -0
  34. package/build/i18n/types.d.ts.map +1 -0
  35. package/build/i18n/types.js +2 -0
  36. package/build/i18n/types.js.map +1 -0
  37. package/build/i18n/uk.json +9 -4
  38. package/build/i18n.d.ts +4 -14
  39. package/build/i18n.d.ts.map +1 -1
  40. package/build/i18n.js +12 -27
  41. package/build/i18n.js.map +1 -1
  42. package/build/index.d.ts +13 -6
  43. package/build/index.d.ts.map +1 -1
  44. package/build/index.js +5 -4
  45. package/build/index.js.map +1 -1
  46. package/build/linker/types.d.ts +59 -0
  47. package/build/linker/types.d.ts.map +1 -0
  48. package/build/linker/types.js +2 -0
  49. package/build/linker/types.js.map +1 -0
  50. package/build/linker.d.ts +5 -76
  51. package/build/linker.d.ts.map +1 -1
  52. package/build/linker.js +290 -339
  53. package/build/linker.js.map +1 -1
  54. package/build/plugins/types.d.ts +42 -0
  55. package/build/plugins/types.d.ts.map +1 -0
  56. package/build/plugins/types.js +2 -0
  57. package/build/plugins/types.js.map +1 -0
  58. package/build/plugins.d.ts +4 -69
  59. package/build/plugins.d.ts.map +1 -1
  60. package/build/plugins.js +73 -62
  61. package/build/plugins.js.map +1 -1
  62. package/build/storage/types.d.ts +31 -0
  63. package/build/storage/types.d.ts.map +1 -0
  64. package/build/storage/types.js +2 -0
  65. package/build/storage/types.js.map +1 -0
  66. package/build/storage.d.ts +6 -22
  67. package/build/storage.d.ts.map +1 -1
  68. package/build/storage.js +72 -81
  69. package/build/storage.js.map +1 -1
  70. package/build/store.d.ts +2 -2
  71. package/build/store.d.ts.map +1 -1
  72. package/build/store.js +116 -67
  73. package/build/store.js.map +1 -1
  74. package/build/types.d.ts +147 -26
  75. package/build/types.d.ts.map +1 -1
  76. package/build/types.local.d.ts +9 -0
  77. package/build/types.local.d.ts.map +1 -0
  78. package/build/types.local.js +2 -0
  79. package/build/types.local.js.map +1 -0
  80. package/package.json +3 -3
  81. package/src/consts.local.ts +16 -0
  82. package/src/consts.ts +126 -34
  83. package/src/geo/types.ts +49 -0
  84. package/src/geo.ts +122 -0
  85. package/src/gtobserver/types.ts +73 -0
  86. package/src/gtobserver.ts +182 -0
  87. package/src/i18n/be.json +9 -4
  88. package/src/i18n/de.json +9 -4
  89. package/src/i18n/en.json +9 -4
  90. package/src/i18n/es.json +9 -4
  91. package/src/i18n/fr.json +9 -4
  92. package/src/i18n/pl.json +9 -4
  93. package/src/i18n/ru.json +9 -4
  94. package/src/i18n/types.ts +11 -0
  95. package/src/i18n/uk.json +9 -4
  96. package/src/i18n.ts +20 -32
  97. package/src/index.ts +13 -13
  98. package/src/linker/types.ts +59 -0
  99. package/src/linker.ts +301 -364
  100. package/src/plugins/types.ts +42 -0
  101. package/src/plugins.ts +82 -106
  102. package/src/storage/types.ts +31 -0
  103. package/src/storage.ts +75 -77
  104. package/src/store.ts +122 -69
  105. package/src/types.local.ts +9 -0
  106. package/src/types.ts +153 -25
  107. package/tests/consent.spec.ts +26 -28
  108. package/tests/geo.spec.ts +474 -0
  109. package/tests/linker.spec.ts +144 -309
  110. package/tests/tracking.spec.ts +14 -14
  111. package/build/functional.d.ts +0 -35
  112. package/build/functional.d.ts.map +0 -1
  113. package/build/functional.js +0 -67
  114. package/build/functional.js.map +0 -1
  115. package/build/gtm.d.ts +0 -72
  116. package/build/gtm.d.ts.map +0 -1
  117. package/build/gtm.js +0 -182
  118. package/build/gtm.js.map +0 -1
  119. package/src/functional.ts +0 -73
  120. package/src/gtm.ts +0 -220
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/consent",
3
- "version": "0.1.18-rc.8",
3
+ "version": "0.1.18",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -22,8 +22,8 @@
22
22
  }
23
23
  },
24
24
  "devDependencies": {
25
- "@owlmeans/dep-config": "workspace:*",
26
- "@types/bun": "^1.4.0",
25
+ "@owlmeans/dep-config": "^0.1.18",
26
+ "@types/bun": "^1.4.2",
27
27
  "nodemon": "^3.1.14",
28
28
  "typescript": "^7.0.2"
29
29
  },
@@ -0,0 +1,16 @@
1
+ export const CONSENT_LINK_VERSION = 2
2
+
3
+ /** A BCP 47-shaped tag: a 2–3 letter language and up to three subtags (`pl`, `pt-BR`, `zh-Hant-TW`). */
4
+ export const LANGUAGE_TAG = /^[a-z]{2,3}(?:[-_][a-z0-9]{1,8}){0,3}$/i
5
+
6
+ /** The built-in Cloudflare locator's registry alias. */
7
+ export const CLOUDFLARE_LOCATOR_ALIAS = 'cloudflare'
8
+
9
+ /** Below anything an application registers: an application's own locator always asks first. */
10
+ export const CLOUDFLARE_LOCATOR_PRIORITY = -100
11
+
12
+ /** An ISO 3166-1 alpha-2 code, upper case. */
13
+ export const COUNTRY_CODE = /^[A-Z]{2}$/
14
+
15
+ /** Clock skew tolerated on an automatic decision's timestamp, in seconds. */
16
+ export const AUTO_SKEW = 60
package/src/consts.ts CHANGED
@@ -1,4 +1,13 @@
1
- import type { ConsentCategory } from './types.js'
1
+ import type { ConsentCategory, ConsentLocale, ConsentState } from './types.js'
2
+
3
+ import en from './i18n/en.json' with { type: 'json' }
4
+ import pl from './i18n/pl.json' with { type: 'json' }
5
+ import ru from './i18n/ru.json' with { type: 'json' }
6
+ import be from './i18n/be.json' with { type: 'json' }
7
+ import uk from './i18n/uk.json' with { type: 'json' }
8
+ import es from './i18n/es.json' with { type: 'json' }
9
+ import de from './i18n/de.json' with { type: 'json' }
10
+ import fr from './i18n/fr.json' with { type: 'json' }
2
11
 
3
12
  /**
4
13
  * Where a visitor's choice is stored.
@@ -34,45 +43,39 @@ export const CONSENT_SETUP_FLAG = 'cookieConsentSetup'
34
43
  export const CONSENT_EVENT = 'owlmeans:consent'
35
44
 
36
45
  export const CONSENT_ESSENTIAL = 'essential'
37
- export const CONSENT_FUNCTIONAL = 'functional'
38
46
  export const CONSENT_ANALYTICS = 'analytics'
39
47
  export const CONSENT_MARKETING = 'marketing'
40
48
 
41
- /**
42
- * The DOM event the store dispatches on `window` when a language that arrived with a link (or an
43
- * explicit choice made before the visitor answered the dialog) has just become storable — the
44
- * visitor granted functional storage — so the application can switch to it now instead of at the
45
- * next load. `detail: { language }`. An application that resolves its language from storage at
46
- * start-up (`@owlmeans/client-i18n`) listens for it.
47
- */
48
- export const CONSENT_LANGUAGE_EVENT = 'owlmeans:language'
49
-
50
- /**
51
- * The `window` property the inline `<head>` fragment leaves a carried language on when it may not
52
- * store it yet (no decision, or functional not granted): memory only, never storage. The store
53
- * picks it up at `init` and writes it if — and only if — the visitor later grants functional storage
54
- * in this page's life. Fixed for the same reason `CONSENT_SETUP_FLAG` is: two bundles, one page.
55
- */
56
- export const CONSENT_PENDING_LANGUAGE = 'cookieConsentPendingLanguage'
57
-
58
49
  /**
59
50
  * Where an application persists an explicit interface-language choice — `@owlmeans/client-i18n`'s
60
- * `owlmeans-lng`. Repeated here rather than imported: this package has no runtime dependencies, and
61
- * the linker's language part has to run in an inline `<head>` script, before any bundle exists.
51
+ * `owlmeans-lng`. The interface language is strictly necessary storage (the visitor asked for it by
52
+ * choosing it), so it is written whatever the cookie decision says and no category governs it.
53
+ * Repeated here rather than imported: this package has no runtime dependencies, and the linker's
54
+ * language part has to run in an inline `<head>` script, before any bundle exists.
62
55
  */
63
56
  export const CONSENT_LANGUAGE_KEY = 'owlmeans-lng'
64
57
 
65
- export const CONSENT_LOCALES = ['en', 'pl', 'ru', 'be', 'uk', 'es', 'de', 'fr'] as const
58
+ export const CONSENT_LOCALES = ['en', 'pl', 'ru', 'be', 'uk', 'es', 'de', 'fr'] as const satisfies readonly ConsentLocale[]
66
59
 
67
- export type ConsentLocale = (typeof CONSENT_LOCALES)[number]
60
+ /**
61
+ * The dialog's copy, in the box.
62
+ *
63
+ * Carried here rather than registered into an i18n framework because one of the three consumers is
64
+ * an Astro site with React islands and no OwlMeans i18n at all — and a consent dialog that renders
65
+ * raw keys is worse than no dialog. An application that HAS translations passes `translate` and
66
+ * overrides every one of these.
67
+ */
68
+ export const DEFAULT_CONSENT_MESSAGES: Record<ConsentLocale, Record<string, string>> = {
69
+ en, pl, ru, be, uk, es, de, fr,
70
+ }
68
71
 
69
72
  /**
70
73
  * The categories every OwlMeans surface starts with.
71
74
  *
72
75
  * What owlmeans.com already asked (analytics, marketing) plus the essential row, which the original
73
76
  * widget left implicit — making it explicit is what lets a flow require an acknowledgement before
74
- * it sets a session cookie, and what tells a visitor what is being stored regardless — plus
75
- * functional, the visitor's own remembered preferences.
77
+ * it sets a session cookie, and what tells a visitor what is being stored regardless: the consent
78
+ * record itself and the interface language the visitor picked.
76
79
  */
77
80
  export const DEFAULT_CONSENT_CATEGORIES: ConsentCategory[] = [
78
81
  {
@@ -81,15 +84,6 @@ export const DEFAULT_CONSENT_CATEGORIES: ConsentCategory[] = [
81
84
  globalVar: 'owlConsentEssential',
82
85
  signals: ['security_storage', 'functionality_storage'],
83
86
  },
84
- // A preference the visitor set — today the interface language — remembered on their device. Not
85
- // essential (nothing breaks without it) and not tracking, so it drives NO Consent Mode signal:
86
- // a signal would make `trackingGranted` count it, and a visitor who only allowed their language
87
- // to be remembered would load the tag container.
88
- {
89
- key: CONSENT_FUNCTIONAL,
90
- labelKey: 'functional', descriptionKey: 'functionalDesc',
91
- globalVar: 'owlConsentFunctional',
92
- },
93
87
  {
94
88
  key: CONSENT_ANALYTICS,
95
89
  labelKey: 'analytics', descriptionKey: 'analyticsDesc',
@@ -119,3 +113,101 @@ export const CONSENT_SIGNAL_DEFAULTS: Record<string, 'granted' | 'denied'> = {
119
113
  personalization_storage: 'denied',
120
114
  security_storage: 'granted',
121
115
  }
116
+
117
+ /** The URL parameter a decorated link carries the decision in, absent an override. */
118
+ export const CONSENT_LINK_PARAM = 'owlcc'
119
+
120
+ /** How old a decorated link's timestamp may be and still be trusted, in seconds, absent an override. */
121
+ export const CONSENT_LINK_MAX_AGE = 300
122
+
123
+ /** Clock skew allowed the OTHER way — a timestamp up to this far in the future is still trusted. */
124
+ export const CONSENT_LINK_SKEW = 60
125
+
126
+ /**
127
+ * Where prior consent for non-essential cookies is the law: the EU and EEA (ePrivacy Art. 5(3) with
128
+ * the GDPR), including the parts of member states that carry an ISO code of their own — Åland and
129
+ * the French outermost regions (French Guiana, Guadeloupe, Martinique, Réunion, Mayotte,
130
+ * Saint-Martin). The Canary Islands, Azores, Madeira, Ceuta and Melilla report their state's code.
131
+ * Greece is `GR`, as Cloudflare and every ISO feed spell it.
132
+ */
133
+ export const CONSENT_COUNTRIES_GDPR: readonly string[] = Object.freeze([
134
+ 'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'GR', 'HR', 'HU',
135
+ 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK',
136
+ 'AX', 'GF', 'GP', 'MQ', 'RE', 'YT', 'MF',
137
+ 'IS', 'LI', 'NO',
138
+ ])
139
+
140
+ /**
141
+ * Jurisdictions outside the EEA whose rule for cookies is the same prior opt-in, by statute or by
142
+ * the regulator's guidance (2026):
143
+ *
144
+ * - the United Kingdom (PECR — the 2025 Data (Use and Access) Act exempts some first-party
145
+ * analytics, advertising still needs consent), the Crown Dependencies and Gibraltar;
146
+ * - Switzerland (FDPIC guidelines, 2025: explicit consent for tracking and profiling);
147
+ * - Andorra, Monaco, San Marino and the Vatican; Türkiye (KVKK cookie guide); Serbia (ZZPL and the
148
+ * electronic-communications law); Albania, Bosnia and Herzegovina, Montenegro, North Macedonia,
149
+ * Kosovo, Moldova, Georgia and Ukraine (GDPR-modelled laws);
150
+ * - territories under a member state's regulator but outside the EU: the Faroe Islands and
151
+ * Greenland (Denmark); Saint-Barthélemy, Saint-Pierre-et-Miquelon, New Caledonia, French
152
+ * Polynesia, Wallis and Futuna (France); the Caribbean Netherlands.
153
+ */
154
+ export const CONSENT_COUNTRIES_ALIGNED: readonly string[] = Object.freeze([
155
+ 'GB', 'GG', 'JE', 'IM', 'GI',
156
+ 'CH',
157
+ 'AD', 'MC', 'SM', 'VA', 'TR', 'RS', 'AL', 'BA', 'ME', 'MK', 'XK', 'MD', 'GE', 'UA',
158
+ 'FO', 'GL', 'BL', 'PM', 'NC', 'PF', 'WF', 'BQ',
159
+ ])
160
+
161
+ /**
162
+ * Prior opt-in regimes elsewhere (2026): Brazil (LGPD, ANPD cookie guide), Canada (Québec's Law 25
163
+ * — the country is the finest grain a CDN reports, so all of Canada), China (PIPL), South Korea
164
+ * (PIPA), Nigeria (NDPA), Saudi Arabia (PDPL), Thailand (PDPA), Vietnam (PDPL 2026).
165
+ *
166
+ * Deliberately absent: India (the DPDP Act's consent rules apply from mid-2027 — add `IN` then),
167
+ * and the notice-or-opt-out regimes — the United States, Japan, Australia, Singapore. South
168
+ * Africa's POPIA is read both ways by the guides and is left out until its regulator says more.
169
+ */
170
+ export const CONSENT_COUNTRIES_OPT_IN: readonly string[] = Object.freeze([
171
+ 'BR', 'CA', 'CN', 'KR', 'NG', 'SA', 'TH', 'VN',
172
+ ])
173
+
174
+ /**
175
+ * Every country where a located visitor is ASKED (`ConsentGeoOptions.countries` replaces it). A
176
+ * visitor located anywhere else gets an automatic decision; one who cannot be located is asked.
177
+ */
178
+ export const CONSENT_REQUIRED_COUNTRIES: readonly string[] = Object.freeze([
179
+ ...CONSENT_COUNTRIES_GDPR, ...CONSENT_COUNTRIES_ALIGNED, ...CONSENT_COUNTRIES_OPT_IN,
180
+ ])
181
+
182
+ /**
183
+ * Codes that name no country: Cloudflare's `XX` (no data) and `T1` (Tor), the retired MaxMind
184
+ * pseudo-codes (`A1` anonymous proxy, `A2` satellite, `O1` other) and the continent buckets some
185
+ * feeds fall back to (`EU`, `AP`), plus `ZZ` (unknown). A visitor reported with one is asked.
186
+ */
187
+ export const CONSENT_GEO_UNKNOWN: readonly string[] = Object.freeze([
188
+ 'XX', 'T1', 'A1', 'A2', 'O1', 'EU', 'AP', 'ZZ',
189
+ ])
190
+
191
+ /** Cloudflare's trace endpoint — served by the edge on every proxied host, `loc=` among its lines. */
192
+ export const CONSENT_TRACE_PATH = '/cdn-cgi/trace'
193
+
194
+ /** How long locating may take before the visitor is asked anyway, in ms. */
195
+ export const CONSENT_GEO_TIMEOUT = 2500
196
+
197
+ /**
198
+ * How long an automatic decision is trusted, in seconds. Older, it counts as undecided — in the
199
+ * head scripts as much as in the store — until the visitor is located again, so a grant derived in
200
+ * one country never reaches a page opened an hour later in another.
201
+ */
202
+ export const CONSENT_AUTO_MAX_AGE = 3600
203
+
204
+ /** The state of a store nobody has initialised — and the only server-side snapshot. */
205
+ export const CONSENT_IDLE_STATE: ConsentState = Object.freeze({
206
+ record: null, open: false, reason: null, locating: null,
207
+ })
208
+
209
+ /**
210
+ * The attribute on `<html>` that mirrors the consent phase: `locating`, `open`, `decided` or `idle`.
211
+ * Absent until the store first runs. A test waits on it, and a stylesheet may key on it.
212
+ */
213
+ export const CONSENT_STATE_ATTRIBUTE = 'data-consent'
@@ -0,0 +1,49 @@
1
+ import type { ConsentGeoPlugin, ConsentOptions, ConsentRecord } from '../types.js'
2
+
3
+ /** What locating the visitor decided: ask them, or decide for them automatically. */
4
+ export type ConsentGeoVerdict = 'ask' | 'auto'
5
+
6
+ /** Whether a record is an automatic decision, and whether it is still trusted. */
7
+ export type ConsentAutoState = 'fresh' | 'stale'
8
+
9
+ /**
10
+ * Asking only where the law requires it: locating the visitor, the decision that follows, and the
11
+ * automatic record written where nobody needs to be asked.
12
+ */
13
+ export interface ConsentGeoHelper {
14
+ /** Whether `opts.geo` turns the gate on AND something can answer — the Cloudflare locator or a plugin. */
15
+ enabled: (opts?: ConsentOptions) => boolean
16
+ /** Every `key=value` line of a Cloudflare trace body. Never throws; anything else parses to `{}`. */
17
+ parseTrace: (text: string) => Record<string, string>
18
+ /**
19
+ * The built-in locator: a same-origin GET of the trace endpoint, cookies left out, never cached.
20
+ * Rejects when `opts.geo.cloudflare` is off, the request fails or answers non-2xx, or the body is
21
+ * not a trace (an SPA serving its `index.html` for every path answers 200 here).
22
+ */
23
+ cloudflareLocator: () => ConsentGeoPlugin
24
+ /**
25
+ * Whether a visitor located in `country` must be asked: yes inside `opts.geo.countries`
26
+ * (`CONSENT_REQUIRED_COUNTRIES`), and yes for anything that names no country.
27
+ */
28
+ requiresConsent: (country: string | null, opts?: ConsentOptions) => boolean
29
+ /**
30
+ * Locate and decide, bounded by `opts.geo.timeout`. Never rejects: a visitor nobody could locate
31
+ * resolves `'ask'`.
32
+ */
33
+ decide: (opts: ConsentOptions) => Promise<ConsentGeoVerdict>
34
+ /**
35
+ * Whether the browser sends Global Privacy Control — a legally binding opt-out in several US
36
+ * states, so an automatic decision honours it.
37
+ */
38
+ privacySignal: () => boolean
39
+ /**
40
+ * The automatic decision for a visitor nobody needs to ask: every category on — or, under Global
41
+ * Privacy Control, only the required ones — stamped `auto` with the current time.
42
+ */
43
+ automaticRecord: (opts?: ConsentOptions) => ConsentRecord
44
+ /**
45
+ * `fresh` for an automatic decision younger than `CONSENT_AUTO_MAX_AGE`, `stale` for an older one
46
+ * (or one stamped in the future beyond the skew), `null` for an explicit record or none.
47
+ */
48
+ autoState: (record: ConsentRecord | null) => ConsentAutoState | null
49
+ }
package/src/geo.ts ADDED
@@ -0,0 +1,122 @@
1
+ import {
2
+ CONSENT_AUTO_MAX_AGE, CONSENT_GEO_TIMEOUT, CONSENT_GEO_UNKNOWN, CONSENT_REQUIRED_COUNTRIES,
3
+ CONSENT_SCHEMA_VERSION, CONSENT_TRACE_PATH, DEFAULT_CONSENT_CATEGORIES,
4
+ } from './consts.js'
5
+ import {
6
+ AUTO_SKEW, CLOUDFLARE_LOCATOR_ALIAS, CLOUDFLARE_LOCATOR_PRIORITY, COUNTRY_CODE,
7
+ } from './consts.local.js'
8
+ import { consentPluginHelper } from './plugins.js'
9
+ import type { ConsentGeoPlugin, ConsentOptions, ConsentRecord } from './types.js'
10
+ import type { ConsentNavigator } from './types.local.js'
11
+ import type { ConsentAutoState, ConsentGeoHelper, ConsentGeoVerdict } from './geo/types.js'
12
+
13
+ export const createConsentGeoHelper = (): ConsentGeoHelper => {
14
+ const now = (): number => Math.floor(Date.now() / 1000)
15
+
16
+ const enabled = (opts?: ConsentOptions): boolean => {
17
+ const geo = opts?.geo
18
+ if (geo == null) {
19
+ return false
20
+ }
21
+
22
+ return (geo.cloudflare != null && geo.cloudflare !== false)
23
+ || consentPluginHelper.consentPlugins().some(plugin => plugin.locate != null
24
+ && plugin.alias !== CLOUDFLARE_LOCATOR_ALIAS)
25
+ }
26
+
27
+ const parseTrace = (text: string): Record<string, string> => {
28
+ const trace: Record<string, string> = {}
29
+ for (const line of text.split(/\r?\n/)) {
30
+ const at = line.indexOf('=')
31
+ if (at > 0 && /^[a-z_]+$/.test(line.slice(0, at))) {
32
+ trace[line.slice(0, at)] = line.slice(at + 1).trim()
33
+ }
34
+ }
35
+
36
+ return trace
37
+ }
38
+
39
+ const cloudflareLocator = (): ConsentGeoPlugin => ({
40
+ alias: CLOUDFLARE_LOCATOR_ALIAS,
41
+ priority: CLOUDFLARE_LOCATOR_PRIORITY,
42
+ locate: async opts => {
43
+ const setting = opts.geo?.cloudflare
44
+ if (setting == null || setting === false) {
45
+ throw new Error('consent:geo:cloudflare:off')
46
+ }
47
+ if (typeof fetch !== 'function') {
48
+ throw new Error('consent:geo:cloudflare:no-fetch')
49
+ }
50
+ const path = typeof setting === 'object' ? setting.path ?? CONSENT_TRACE_PATH : CONSENT_TRACE_PATH
51
+ const response = await fetch(path, { credentials: 'omit', cache: 'no-store' })
52
+ if (!response.ok) {
53
+ throw new Error(`consent:geo:cloudflare:status:${response.status}`)
54
+ }
55
+ const trace = parseTrace(await response.text())
56
+ // Every trace names the edge that answered; an HTML fallback page names nothing at all.
57
+ if (trace.loc == null || (trace.colo == null && trace.fl == null)) {
58
+ throw new Error('consent:geo:cloudflare:not-a-trace')
59
+ }
60
+
61
+ return { country: trace.loc }
62
+ },
63
+ })
64
+
65
+ const requiresConsent = (country: string | null, opts?: ConsentOptions): boolean => {
66
+ const code = country?.trim().toUpperCase() ?? ''
67
+ if (!COUNTRY_CODE.test(code) || CONSENT_GEO_UNKNOWN.includes(code)) {
68
+ return true
69
+ }
70
+
71
+ return (opts?.geo?.countries ?? CONSENT_REQUIRED_COUNTRIES)
72
+ .some(candidate => candidate.toUpperCase() === code)
73
+ }
74
+
75
+ const decide = async (opts: ConsentOptions): Promise<ConsentGeoVerdict> => {
76
+ let timer: ReturnType<typeof setTimeout> | undefined
77
+ const timeout = new Promise<never>((_, reject) => {
78
+ timer = setTimeout(
79
+ () => reject(new Error('consent:geo:timeout')), opts.geo?.timeout ?? CONSENT_GEO_TIMEOUT
80
+ )
81
+ })
82
+ try {
83
+ const location = await Promise.race([consentPluginHelper.locateConsent(opts), timeout])
84
+
85
+ return requiresConsent(location.country, opts) ? 'ask' : 'auto'
86
+ } catch {
87
+ return 'ask'
88
+ } finally {
89
+ clearTimeout(timer)
90
+ }
91
+ }
92
+
93
+ const privacySignal = (): boolean =>
94
+ typeof navigator !== 'undefined'
95
+ && (navigator as unknown as ConsentNavigator).globalPrivacyControl === true
96
+
97
+ const automaticRecord = (opts?: ConsentOptions): ConsentRecord => {
98
+ const optOut = privacySignal()
99
+ const record: ConsentRecord = { v: CONSENT_SCHEMA_VERSION, auto: now() }
100
+ for (const category of opts?.categories ?? DEFAULT_CONSENT_CATEGORIES) {
101
+ record[category.key] = category.required === true || !optOut
102
+ }
103
+
104
+ return record
105
+ }
106
+
107
+ const autoState = (record: ConsentRecord | null): ConsentAutoState | null => {
108
+ if (record == null || typeof record.auto !== 'number') {
109
+ return null
110
+ }
111
+ const age = now() - record.auto
112
+
113
+ return age <= CONSENT_AUTO_MAX_AGE && age >= -AUTO_SKEW ? 'fresh' : 'stale'
114
+ }
115
+
116
+ return {
117
+ enabled, parseTrace, cloudflareLocator, requiresConsent, decide, privacySignal, automaticRecord,
118
+ autoState,
119
+ }
120
+ }
121
+
122
+ export const consentGeoHelper = createConsentGeoHelper()
@@ -0,0 +1,73 @@
1
+ import type { ConsentCategory, ConsentOptions, ConsentRecord } from '../types.js'
2
+
3
+ /** Google Consent Mode: the queue, the signals a decision implies, and the inline scripts. */
4
+ export interface ConsentModeHelper {
5
+ /**
6
+ * Push onto the tag-manager queue in the shape Google's own snippet uses.
7
+ *
8
+ * `arguments`, not an array literal. Both work with today's Consent API, but `arguments` is what
9
+ * `gtag.js` itself emits, and a page that carries both this and Google's snippet should not have
10
+ * two shapes in one queue.
11
+ */
12
+ gtagConsent: (..._args: unknown[]) => void
13
+ /** Every signal the configured categories drive, at its pre-consent value. */
14
+ consentDefaults: (categories?: ConsentCategory[]) => Record<string, 'granted' | 'denied'>
15
+ /** Every signal, at the value this record implies. */
16
+ consentUpdate: (record: ConsentRecord, categories?: ConsentCategory[]) => Record<string, 'granted' | 'denied'>
17
+ /**
18
+ * Declare what is denied, before any tag can act.
19
+ *
20
+ * Idempotent through a window flag, because the page may carry this call twice — once inline in
21
+ * the document head, once from whatever bundle mounts the dialog — and a second `default` after a
22
+ * tag has loaded is worse than none: it can widen what was already narrowed.
23
+ */
24
+ pushConsentDefaults: (opts?: ConsentOptions) => void
25
+ /**
26
+ * Apply a decision: globals first, then the signal update, then any per-category events.
27
+ *
28
+ * The order is load-bearing. A tag that fires on the update event reads the globals in the same
29
+ * turn, so writing them afterwards would let the first firing see the previous answer.
30
+ */
31
+ applyConsent: (record: ConsentRecord, opts?: ConsentOptions) => void
32
+ /**
33
+ * Whether a stored decision grants tracking at all — a signal-bearing category, not merely the
34
+ * required essential one.
35
+ *
36
+ * "Signal-bearing" is what a required category can never be: it exists to gate whether a LOADER
37
+ * runs, and a required category is disclosure, not a question — everyone gets it. Only a category
38
+ * that both is optional and drives at least one Consent Mode signal counts, which is exactly what
39
+ * `googleTagHeadScript`'s `'basic'` mode gates a Google tag behind.
40
+ */
41
+ trackingGranted: (record: ConsentRecord | null, categories?: ConsentCategory[]) => boolean
42
+ /**
43
+ * The inline script a document stamps ABOVE its tag-manager snippet.
44
+ *
45
+ * It exists because the defaults have to be on the queue before `gtm.js` loads, and a React bundle
46
+ * cannot be: by the time an island mounts, the container has been running for hundreds of
47
+ * milliseconds and has already decided what it may do. Emitting it from the HTML is the only
48
+ * ordering that holds — and it reads the stored record too, so a returning visitor's tags are not
49
+ * denied for the first paint of every page.
50
+ */
51
+ consentBootstrapScript: (opts?: ConsentOptions) => string
52
+ /**
53
+ * Withhold a loader until a stored or later decision grants tracking.
54
+ *
55
+ * The counterpart to `consentBootstrapScript` for the `'basic'` gated loading mode
56
+ * (`@owlmeans/web-gtm`'s `GOOGLE_TAG_DEFAULT_MODE`): where the bootstrap always declares Consent
57
+ * Mode's defaults so a tag may load cookieless, this withholds the tag itself — the loader string
58
+ * is only ever reached once `trackingGranted` is true, either because a returning visitor's stored
59
+ * record already says so, or because `applyConsent` later dispatches {@link CONSENT_EVENT} with
60
+ * one that does. The listener removes itself on the first passing event, so the loader — which
61
+ * marks its own element and refuses to run twice — is asked to run at most once from here.
62
+ *
63
+ * `loaderExpr` is a complete statement or expression that runs the loader when reached — the
64
+ * self-invoking IIFE string `googleTagHeadScript`'s `gtagScript`/`gtmContainerScript` already
65
+ * produce, embedded here verbatim rather than called. It is placed inline exactly as
66
+ * `consentBootstrapScript`'s own output is: dynamic values (the storage key, the category shape,
67
+ * the event name) are JSON-encoded so nothing configured can break out of a string literal, and
68
+ * escaping the RESULT for HTML (`</script`, `<!--`) is left to whoever composes the final inline
69
+ * script — the same discipline `consentBootstrapScript` follows, and `googleTagHeadScript` already
70
+ * applies to the whole script it stamps.
71
+ */
72
+ consentGateScript: (loaderExpr: string, opts?: ConsentOptions & { categories?: ConsentCategory[] }) => string
73
+ }
@@ -0,0 +1,182 @@
1
+ import {
2
+ CONSENT_AUTO_MAX_AGE, CONSENT_EVENT, CONSENT_KEY, CONSENT_SETUP_FLAG, CONSENT_SIGNAL_DEFAULTS,
3
+ DEFAULT_CONSENT_CATEGORIES,
4
+ } from './consts.js'
5
+ import { AUTO_SKEW } from './consts.local.js'
6
+ import { consentLinkHelper } from './linker.js'
7
+ import type { ConsentCategory, ConsentOptions, ConsentRecord } from './types.js'
8
+ import type { ConsentWindow } from './types.local.js'
9
+ import type { ConsentModeHelper } from './gtobserver/types.js'
10
+
11
+ export const createConsentModeHelper = (): ConsentModeHelper => {
12
+ const global = (): ConsentWindow | null =>
13
+ typeof window !== 'undefined' ? window as unknown as ConsentWindow : null
14
+
15
+ // A `function`, not an arrow: what goes on the queue is its own `arguments` object.
16
+ function gtagConsent(..._args: unknown[]): void {
17
+ const win = global()
18
+ if (win == null) {
19
+ return
20
+ }
21
+ win.dataLayer = win.dataLayer ?? []
22
+ // `arguments`, not `_args`. The rest parameter is only here to type the call sites; what goes on
23
+ // the queue must be the arguments object itself.
24
+ // eslint-disable-next-line prefer-rest-params
25
+ win.dataLayer.push(arguments)
26
+ }
27
+
28
+ const categoriesOf = (opts?: ConsentOptions): ConsentCategory[] =>
29
+ opts?.categories ?? DEFAULT_CONSENT_CATEGORIES
30
+
31
+ /**
32
+ * Inline: treat an automatic decision past `CONSENT_AUTO_MAX_AGE` as no decision at all — run
33
+ * `onStale` on the parsed record `r`. The store holds such a record back until the visitor is
34
+ * located again, and the head scripts must not apply what the store will not.
35
+ */
36
+ const staleAutoScript = (onStale: string): string =>
37
+ `if(r&&typeof r.auto==='number'){var a=Math.floor(Date.now()/1000)-r.auto;` +
38
+ `if(a>${CONSENT_AUTO_MAX_AGE}||a<-${AUTO_SKEW}){${onStale}}}`
39
+
40
+ const consentDefaults = (
41
+ categories?: ConsentCategory[]
42
+ ): Record<string, 'granted' | 'denied'> => {
43
+ const used = new Set((categories ?? DEFAULT_CONSENT_CATEGORIES)
44
+ .flatMap(category => category.signals ?? []))
45
+
46
+ return Object.fromEntries(Object.entries(CONSENT_SIGNAL_DEFAULTS)
47
+ .filter(([signal]) => used.has(signal as never)))
48
+ }
49
+
50
+ const consentUpdate = (
51
+ record: ConsentRecord, categories?: ConsentCategory[]
52
+ ): Record<string, 'granted' | 'denied'> => {
53
+ const update: Record<string, 'granted' | 'denied'> = {}
54
+ for (const category of categories ?? DEFAULT_CONSENT_CATEGORIES) {
55
+ const granted = category.required === true || record[category.key] === true
56
+ for (const signal of category.signals ?? []) {
57
+ // A signal named by two categories is granted only when every one of them is.
58
+ update[signal] = granted && update[signal] !== 'denied' ? 'granted' : 'denied'
59
+ }
60
+ }
61
+
62
+ return update
63
+ }
64
+
65
+ const pushConsentDefaults = (opts?: ConsentOptions): void => {
66
+ const win = global()
67
+ if (win == null || opts?.silent === true || win[CONSENT_SETUP_FLAG] === true) {
68
+ return
69
+ }
70
+ gtagConsent('consent', 'default', consentDefaults(categoriesOf(opts)))
71
+ win[CONSENT_SETUP_FLAG] = true
72
+ }
73
+
74
+ const applyConsent = (record: ConsentRecord, opts?: ConsentOptions): void => {
75
+ const win = global()
76
+ if (win == null || opts?.silent === true) {
77
+ return
78
+ }
79
+ const categories = categoriesOf(opts)
80
+
81
+ for (const category of categories) {
82
+ if (category.globalVar != null) {
83
+ win[category.globalVar] = category.required === true || record[category.key] === true
84
+ }
85
+ }
86
+
87
+ gtagConsent('consent', 'update', consentUpdate(record, categories))
88
+
89
+ for (const category of categories) {
90
+ if (category.event != null && (category.required === true || record[category.key] === true)) {
91
+ win.dataLayer = win.dataLayer ?? []
92
+ win.dataLayer.push({ event: category.event })
93
+ }
94
+ }
95
+
96
+ // A gate script (`consentGateScript`) that already decided NOT to load, because nothing was
97
+ // granted at the time it ran, has no other way to hear this. Consent Mode itself speaks only on
98
+ // `dataLayer`, which a tag that never loaded is not listening to.
99
+ if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function'
100
+ && typeof CustomEvent === 'function') {
101
+ window.dispatchEvent(new CustomEvent(CONSENT_EVENT, { detail: { record } }))
102
+ }
103
+ }
104
+
105
+ const trackingGranted = (
106
+ record: ConsentRecord | null, categories: ConsentCategory[] = DEFAULT_CONSENT_CATEGORIES
107
+ ): boolean => record != null && categories.some(
108
+ category => !category.required && (category.signals?.length ?? 0) > 0 && record[category.key] === true
109
+ )
110
+
111
+ const consentBootstrapScript = (opts?: ConsentOptions): string => {
112
+ const defaults = JSON.stringify(consentDefaults(categoriesOf(opts)))
113
+ const categories = JSON.stringify(categoriesOf(opts).map(category => ({
114
+ key: category.key,
115
+ required: category.required === true,
116
+ signals: category.signals ?? [],
117
+ globalVar: category.globalVar ?? null,
118
+ })))
119
+ const storageKey = JSON.stringify(opts?.storageKey ?? CONSENT_KEY)
120
+ const flag = JSON.stringify(CONSENT_SETUP_FLAG)
121
+ // Right after the default is pushed and before EITHER storage read below (this one, and the
122
+ // `consentGateScript` that is concatenated after this whole IIFE) — an adopted decision must
123
+ // already be in storage by the time anything reads it, so `gtm.js`'s own `page_view` never sees
124
+ // the parameter and a granted decision loads the tag on arrival. A page with no `opts.linker`
125
+ // gets an empty string here, unchanged from before this existed.
126
+ const linker = opts?.linker != null ? `${consentLinkHelper.consentLinkerScript(opts)};` : ''
127
+
128
+ return `(function(w,d){` +
129
+ `w.dataLayer=w.dataLayer||[];function g(){w.dataLayer.push(arguments)}` +
130
+ `if(w[${flag}])return;g('consent','default',${defaults});w[${flag}]=true;` +
131
+ linker +
132
+ `var raw=null;try{raw=w.localStorage.getItem(${storageKey})}catch(e){}` +
133
+ `if(!raw){var p=('; '+d.cookie).split('; '+${storageKey}+'=');` +
134
+ `if(p.length===2){raw=p.pop().split(';').shift()}}` +
135
+ `if(!raw)return;var r;try{r=JSON.parse(raw)}catch(e){return}` +
136
+ staleAutoScript('return') +
137
+ `var cs=${categories},u={};` +
138
+ `for(var i=0;i<cs.length;i++){var c=cs[i];var ok=c.required||r[c.key]===true;` +
139
+ `if(c.globalVar){w[c.globalVar]=ok}` +
140
+ `for(var j=0;j<c.signals.length;j++){var s=c.signals[j];` +
141
+ `u[s]=ok&&u[s]!=='denied'?'granted':'denied'}}` +
142
+ `g('consent','update',u)})(window,document)`
143
+ }
144
+
145
+ const consentGateScript = (
146
+ loaderExpr: string, opts?: ConsentOptions & { categories?: ConsentCategory[] }
147
+ ): string => {
148
+ const gate = JSON.stringify(categoriesOf(opts).map(category => ({
149
+ key: category.key,
150
+ required: category.required === true,
151
+ hasSignal: (category.signals ?? []).length > 0,
152
+ })))
153
+ const storageKey = JSON.stringify(opts?.storageKey ?? CONSENT_KEY)
154
+ const event = JSON.stringify(CONSENT_EVENT)
155
+
156
+ return `(function(w,d){` +
157
+ `function ok(r){if(!r)return false;var cs=${gate};` +
158
+ `for(var i=0;i<cs.length;i++){var c=cs[i];` +
159
+ `if(!c.required&&c.hasSignal&&r[c.key]===true)return true}return false}` +
160
+ `function load(){${loaderExpr}}` +
161
+ `var raw=null;try{raw=w.localStorage.getItem(${storageKey})}catch(e){}` +
162
+ `if(!raw){var p=('; '+d.cookie).split('; '+${storageKey}+'=');` +
163
+ `if(p.length===2){raw=p.pop().split(';').shift()}}` +
164
+ `var r=null;if(raw){try{r=JSON.parse(raw)}catch(e){r=null}}` +
165
+ staleAutoScript('r=null') +
166
+ `if(ok(r)){load();return}` +
167
+ `function h(ev){if(ok(ev&&ev.detail&&ev.detail.record)){` +
168
+ `w.removeEventListener(${event},h);load()}}` +
169
+ `w.addEventListener(${event},h)` +
170
+ `})(window,document)`
171
+ }
172
+
173
+ return {
174
+ gtagConsent, consentDefaults, consentUpdate, pushConsentDefaults, applyConsent, trackingGranted,
175
+ consentBootstrapScript, consentGateScript,
176
+ }
177
+ }
178
+
179
+ export const consentModeHelper = createConsentModeHelper()
180
+
181
+ /** @deprecated compat:factory-refactor — use `consentModeHelper.consentBootstrapScript(…)` */
182
+ export const consentBootstrapScript = (opts?: ConsentOptions): string => consentModeHelper.consentBootstrapScript(opts)