@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.
- package/README.md +229 -0
- package/build/consts.d.ts +84 -23
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +110 -29
- package/build/consts.js.map +1 -1
- package/build/consts.local.d.ts +12 -0
- package/build/consts.local.d.ts.map +1 -0
- package/build/consts.local.js +12 -0
- package/build/consts.local.js.map +1 -0
- package/build/geo/types.d.ts +47 -0
- package/build/geo/types.d.ts.map +1 -0
- package/build/geo/types.js +2 -0
- package/build/geo/types.js.map +1 -0
- package/build/geo.d.ts +4 -0
- package/build/geo.d.ts.map +1 -0
- package/build/geo.js +96 -0
- package/build/geo.js.map +1 -0
- package/build/gtobserver/types.d.ts +75 -0
- package/build/gtobserver/types.d.ts.map +1 -0
- package/build/gtobserver/types.js +2 -0
- package/build/gtobserver/types.js.map +1 -0
- package/build/gtobserver.d.ts +7 -0
- package/build/gtobserver.d.ts.map +1 -0
- package/build/gtobserver.js +142 -0
- package/build/gtobserver.js.map +1 -0
- package/build/i18n/be.json +9 -4
- package/build/i18n/de.json +9 -4
- package/build/i18n/en.json +9 -4
- package/build/i18n/es.json +9 -4
- package/build/i18n/fr.json +9 -4
- package/build/i18n/pl.json +9 -4
- package/build/i18n/ru.json +9 -4
- package/build/i18n/types.d.ts +11 -0
- package/build/i18n/types.d.ts.map +1 -0
- package/build/i18n/types.js +2 -0
- package/build/i18n/types.js.map +1 -0
- package/build/i18n/uk.json +9 -4
- package/build/i18n.d.ts +4 -14
- package/build/i18n.d.ts.map +1 -1
- package/build/i18n.js +12 -27
- package/build/i18n.js.map +1 -1
- package/build/index.d.ts +13 -6
- package/build/index.d.ts.map +1 -1
- package/build/index.js +5 -4
- package/build/index.js.map +1 -1
- package/build/linker/types.d.ts +59 -0
- package/build/linker/types.d.ts.map +1 -0
- package/build/linker/types.js +2 -0
- package/build/linker/types.js.map +1 -0
- package/build/linker.d.ts +5 -76
- package/build/linker.d.ts.map +1 -1
- package/build/linker.js +290 -339
- package/build/linker.js.map +1 -1
- package/build/plugins/types.d.ts +42 -0
- package/build/plugins/types.d.ts.map +1 -0
- package/build/plugins/types.js +2 -0
- package/build/plugins/types.js.map +1 -0
- package/build/plugins.d.ts +4 -69
- package/build/plugins.d.ts.map +1 -1
- package/build/plugins.js +73 -62
- package/build/plugins.js.map +1 -1
- package/build/storage/types.d.ts +31 -0
- package/build/storage/types.d.ts.map +1 -0
- package/build/storage/types.js +2 -0
- package/build/storage/types.js.map +1 -0
- package/build/storage.d.ts +6 -22
- package/build/storage.d.ts.map +1 -1
- package/build/storage.js +72 -81
- package/build/storage.js.map +1 -1
- package/build/store.d.ts +2 -2
- package/build/store.d.ts.map +1 -1
- package/build/store.js +116 -67
- package/build/store.js.map +1 -1
- package/build/types.d.ts +147 -26
- package/build/types.d.ts.map +1 -1
- package/build/types.local.d.ts +9 -0
- package/build/types.local.d.ts.map +1 -0
- package/build/types.local.js +2 -0
- package/build/types.local.js.map +1 -0
- package/package.json +3 -3
- package/src/consts.local.ts +16 -0
- package/src/consts.ts +126 -34
- package/src/geo/types.ts +49 -0
- package/src/geo.ts +122 -0
- package/src/gtobserver/types.ts +73 -0
- package/src/gtobserver.ts +182 -0
- package/src/i18n/be.json +9 -4
- package/src/i18n/de.json +9 -4
- package/src/i18n/en.json +9 -4
- package/src/i18n/es.json +9 -4
- package/src/i18n/fr.json +9 -4
- package/src/i18n/pl.json +9 -4
- package/src/i18n/ru.json +9 -4
- package/src/i18n/types.ts +11 -0
- package/src/i18n/uk.json +9 -4
- package/src/i18n.ts +20 -32
- package/src/index.ts +13 -13
- package/src/linker/types.ts +59 -0
- package/src/linker.ts +301 -364
- package/src/plugins/types.ts +42 -0
- package/src/plugins.ts +82 -106
- package/src/storage/types.ts +31 -0
- package/src/storage.ts +75 -77
- package/src/store.ts +122 -69
- package/src/types.local.ts +9 -0
- package/src/types.ts +153 -25
- package/tests/consent.spec.ts +26 -28
- package/tests/geo.spec.ts +474 -0
- package/tests/linker.spec.ts +144 -309
- package/tests/tracking.spec.ts +14 -14
- package/build/functional.d.ts +0 -35
- package/build/functional.d.ts.map +0 -1
- package/build/functional.js +0 -67
- package/build/functional.js.map +0 -1
- package/build/gtm.d.ts +0 -72
- package/build/gtm.d.ts.map +0 -1
- package/build/gtm.js +0 -182
- package/build/gtm.js.map +0 -1
- package/src/functional.ts +0 -73
- package/src/gtm.ts +0 -220
package/src/linker.ts
CHANGED
|
@@ -1,413 +1,350 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
} from './
|
|
5
|
-
import {
|
|
6
|
-
import type {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
c: Record<string, 0 | 1>
|
|
25
|
-
/** Unix seconds the link was decorated at. */
|
|
26
|
-
t: number
|
|
27
|
-
/**
|
|
28
|
-
* The interface language of the page the link was on (a BCP 47 tag, lower-cased) — present only
|
|
29
|
-
* when the sender has `linker.language` set and its `<html lang>` names one. Optional on the
|
|
30
|
-
* wire, so a receiver that predates it reads the same `v: 2` payload and ignores the field.
|
|
31
|
-
*/
|
|
32
|
-
l?: string
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** A BCP 47-shaped tag: a 2–3 letter language and up to three subtags (`pl`, `pt-BR`, `zh-Hant-TW`). */
|
|
36
|
-
const LANGUAGE_TAG = /^[a-z]{2,3}(?:[-_][a-z0-9]{1,8}){0,3}$/i
|
|
37
|
-
|
|
38
|
-
const categoriesOf = (opts?: ConsentOptions): ConsentCategory[] =>
|
|
39
|
-
opts?.categories ?? DEFAULT_CONSENT_CATEGORIES
|
|
40
|
-
|
|
41
|
-
const optionalOf = (opts?: ConsentOptions): ConsentCategory[] =>
|
|
42
|
-
categoriesOf(opts).filter(category => category.required !== true)
|
|
1
|
+
import { CONSENT_COOKIE_DAYS, CONSENT_KEY, CONSENT_LANGUAGE_KEY, CONSENT_SCHEMA_VERSION, DEFAULT_CONSENT_CATEGORIES, CONSENT_LINK_MAX_AGE, CONSENT_LINK_PARAM, CONSENT_LINK_SKEW } from './consts.js'
|
|
2
|
+
import type { ConsentPlugin, ConsentCategory, ConsentOptions, ConsentRecord, ConsentLinkPayload } from './types.js'
|
|
3
|
+
import { consentPluginHelper } from './plugins.js'
|
|
4
|
+
import { consentStorageHelper } from './storage.js'
|
|
5
|
+
import { CONSENT_LINK_VERSION, LANGUAGE_TAG } from './consts.local.js'
|
|
6
|
+
import type { ConsentLinkHelper } from './linker/types.js'
|
|
7
|
+
|
|
8
|
+
export const createConsentLinkHelper = (): ConsentLinkHelper => {
|
|
9
|
+
const categoriesOf = (opts?: ConsentOptions): ConsentCategory[] =>
|
|
10
|
+
opts?.categories ?? DEFAULT_CONSENT_CATEGORIES
|
|
11
|
+
|
|
12
|
+
const optionalOf = (opts?: ConsentOptions): ConsentCategory[] =>
|
|
13
|
+
categoriesOf(opts).filter(category => category.required !== true)
|
|
14
|
+
|
|
15
|
+
const toBase64Url = (json: string): string =>
|
|
16
|
+
btoa(json).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
17
|
+
|
|
18
|
+
const fromBase64Url = (value: string): string | null => {
|
|
19
|
+
try {
|
|
20
|
+
let padded = value.replace(/-/g, '+').replace(/_/g, '/')
|
|
21
|
+
while (padded.length % 4 !== 0) {
|
|
22
|
+
padded += '='
|
|
23
|
+
}
|
|
43
24
|
|
|
44
|
-
|
|
45
|
-
|
|
25
|
+
return atob(padded)
|
|
26
|
+
} catch {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
}
|
|
46
30
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
padded += '='
|
|
31
|
+
/** The language this document is written in — `<html lang>`, the one signal every page has. */
|
|
32
|
+
const documentLanguage = (): string | null => {
|
|
33
|
+
if (typeof document === 'undefined') {
|
|
34
|
+
return null
|
|
52
35
|
}
|
|
36
|
+
const lang = document.documentElement?.lang?.trim() ?? ''
|
|
53
37
|
|
|
54
|
-
return
|
|
55
|
-
} catch {
|
|
56
|
-
return null
|
|
38
|
+
return LANGUAGE_TAG.test(lang) ? lang.toLowerCase() : null
|
|
57
39
|
}
|
|
58
|
-
}
|
|
59
40
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The visitor's OWN decision, or `null`. An automatic one (`auto`) was derived from where they
|
|
43
|
+
* browsed from and nobody chose it, so it never travels as their choice — the receiving domain
|
|
44
|
+
* locates them itself.
|
|
45
|
+
*/
|
|
46
|
+
const explicitOf = (record: ConsentRecord | null): ConsentRecord | null =>
|
|
47
|
+
record != null && typeof record.auto !== 'number' ? record : null
|
|
48
|
+
|
|
49
|
+
const encodeConsentLink = (input: ConsentRecord | null, opts?: ConsentOptions): string => {
|
|
50
|
+
const c: Record<string, 0 | 1> = {}
|
|
51
|
+
const record = explicitOf(input)
|
|
52
|
+
if (record != null) {
|
|
53
|
+
for (const category of optionalOf(opts)) {
|
|
54
|
+
c[category.key] = record[category.key] === true ? 1 : 0
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const language = opts?.linker?.language != null ? documentLanguage() : null
|
|
58
|
+
const payload: ConsentLinkPayload = {
|
|
59
|
+
v: CONSENT_LINK_VERSION, c, t: Math.floor(Date.now() / 1000), ...(language != null ? { l: language } : {}),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return toBase64Url(JSON.stringify(payload))
|
|
64
63
|
}
|
|
65
|
-
const lang = document.documentElement?.lang?.trim() ?? ''
|
|
66
64
|
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
const decodeConsentLink = (value: string): ConsentLinkPayload | null => {
|
|
66
|
+
const json = fromBase64Url(value)
|
|
67
|
+
if (json == null) {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const parsed = JSON.parse(json) as unknown
|
|
72
|
+
if (parsed == null || typeof parsed !== 'object') {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
const { v, c, t, l } = parsed as Record<string, unknown>
|
|
76
|
+
if (v !== CONSENT_LINK_VERSION || typeof t !== 'number' || c == null || typeof c !== 'object') {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
69
79
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (record != null) {
|
|
78
|
-
for (const category of optionalOf(opts)) {
|
|
79
|
-
c[category.key] = record[category.key] === true ? 1 : 0
|
|
80
|
+
return {
|
|
81
|
+
v: CONSENT_LINK_VERSION, c: c as Record<string, 0 | 1>, t,
|
|
82
|
+
// A malformed language is dropped, never a reason to refuse the decision beside it.
|
|
83
|
+
...(typeof l === 'string' && LANGUAGE_TAG.test(l) ? { l: l.toLowerCase() } : {}),
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
return null
|
|
80
87
|
}
|
|
81
88
|
}
|
|
82
|
-
const language = opts?.linker?.language != null ? documentLanguage() : null
|
|
83
|
-
const payload: ConsentLinkPayload = {
|
|
84
|
-
v: CONSENT_LINK_VERSION, c, t: Math.floor(Date.now() / 1000), ...(language != null ? { l: language } : {}),
|
|
85
|
-
}
|
|
86
89
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
/** Parse-only — no trust decision here, that is `consentLinker(opts).adopt`'s job. Never throws. */
|
|
91
|
-
export const decodeConsentLink = (value: string): ConsentLinkPayload | null => {
|
|
92
|
-
const json = fromBase64Url(value)
|
|
93
|
-
if (json == null) {
|
|
94
|
-
return null
|
|
95
|
-
}
|
|
96
|
-
try {
|
|
97
|
-
const parsed = JSON.parse(json) as unknown
|
|
98
|
-
if (parsed == null || typeof parsed !== 'object') {
|
|
90
|
+
const referrerHostname = (): string | null => {
|
|
91
|
+
if (typeof document === 'undefined' || document.referrer === '') {
|
|
99
92
|
return null
|
|
100
93
|
}
|
|
101
|
-
|
|
102
|
-
|
|
94
|
+
try {
|
|
95
|
+
return new URL(document.referrer).hostname
|
|
96
|
+
} catch {
|
|
103
97
|
return null
|
|
104
98
|
}
|
|
105
|
-
|
|
106
|
-
return {
|
|
107
|
-
v: CONSENT_LINK_VERSION, c: c as Record<string, 0 | 1>, t,
|
|
108
|
-
// A malformed language is dropped, never a reason to refuse the decision beside it.
|
|
109
|
-
...(typeof l === 'string' && LANGUAGE_TAG.test(l) ? { l: l.toLowerCase() } : {}),
|
|
110
|
-
}
|
|
111
|
-
} catch {
|
|
112
|
-
return null
|
|
113
99
|
}
|
|
114
|
-
}
|
|
115
100
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
101
|
+
/**
|
|
102
|
+
* The payload of the link this document was opened from, once it has passed every check that does
|
|
103
|
+
* not depend on WHAT it carries: the parameter decodes and its version matches, its timestamp is
|
|
104
|
+
* fresh (and not from the future beyond the allowed skew), and the request's referrer host is a
|
|
105
|
+
* LISTED domain — read from `document.referrer`, never from the parameter itself. `null` otherwise.
|
|
106
|
+
*/
|
|
107
|
+
const trustedPayload = (opts: ConsentOptions): ConsentLinkPayload | null => {
|
|
108
|
+
const linker = opts.linker
|
|
109
|
+
if (linker == null || typeof location === 'undefined') {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
const raw = new URLSearchParams(location.search).get(linker.param ?? CONSENT_LINK_PARAM)
|
|
113
|
+
if (raw == null) {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
const decoded = decodeConsentLink(raw)
|
|
117
|
+
if (decoded == null) {
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
const now = Math.floor(Date.now() / 1000)
|
|
121
|
+
const maxAge = linker.maxAge ?? CONSENT_LINK_MAX_AGE
|
|
122
|
+
if (now - decoded.t > maxAge || decoded.t - now > CONSENT_LINK_SKEW) {
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
const referrer = referrerHostname()
|
|
126
|
+
if (referrer == null || !linker.domains.includes(referrer)) {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
126
129
|
|
|
127
|
-
|
|
128
|
-
* The payload of the link this document was opened from, once it has passed every check that does
|
|
129
|
-
* not depend on WHAT it carries: the parameter decodes and its version matches, its timestamp is
|
|
130
|
-
* fresh (and not from the future beyond the allowed skew), and the request's referrer host is a
|
|
131
|
-
* LISTED domain — read from `document.referrer`, never from the parameter itself. `null` otherwise.
|
|
132
|
-
*/
|
|
133
|
-
const trustedPayload = (opts: ConsentOptions): ConsentLinkPayload | null => {
|
|
134
|
-
const linker = opts.linker
|
|
135
|
-
if (linker == null || typeof location === 'undefined') {
|
|
136
|
-
return null
|
|
137
|
-
}
|
|
138
|
-
const raw = new URLSearchParams(location.search).get(linker.param ?? CONSENT_LINK_PARAM)
|
|
139
|
-
if (raw == null) {
|
|
140
|
-
return null
|
|
141
|
-
}
|
|
142
|
-
const decoded = decodeConsentLink(raw)
|
|
143
|
-
if (decoded == null) {
|
|
144
|
-
return null
|
|
145
|
-
}
|
|
146
|
-
const now = Math.floor(Date.now() / 1000)
|
|
147
|
-
const maxAge = linker.maxAge ?? CONSENT_LINK_MAX_AGE
|
|
148
|
-
if (now - decoded.t > maxAge || decoded.t - now > CONSENT_LINK_SKEW) {
|
|
149
|
-
return null
|
|
150
|
-
}
|
|
151
|
-
const referrer = referrerHostname()
|
|
152
|
-
if (referrer == null || !linker.domains.includes(referrer)) {
|
|
153
|
-
return null
|
|
130
|
+
return decoded
|
|
154
131
|
}
|
|
155
132
|
|
|
156
|
-
|
|
157
|
-
|
|
133
|
+
/** The entry of `supported` that `language` names — exactly, else by its base tag — or `null`. */
|
|
134
|
+
const supportedLanguage = (language: string, supported: string[]): string | null => {
|
|
135
|
+
const wanted = language.toLowerCase()
|
|
136
|
+
const exact = supported.find(candidate => candidate.toLowerCase() === wanted)
|
|
137
|
+
if (exact != null) {
|
|
138
|
+
return exact
|
|
139
|
+
}
|
|
140
|
+
const base = wanted.split(/[-_]/)[0]
|
|
158
141
|
|
|
159
|
-
|
|
160
|
-
const supportedLanguage = (language: string, supported: string[]): string | null => {
|
|
161
|
-
const wanted = language.toLowerCase()
|
|
162
|
-
const exact = supported.find(candidate => candidate.toLowerCase() === wanted)
|
|
163
|
-
if (exact != null) {
|
|
164
|
-
return exact
|
|
142
|
+
return supported.find(candidate => candidate.toLowerCase() === base) ?? null
|
|
165
143
|
}
|
|
166
|
-
const base = wanted.split(/[-_]/)[0]
|
|
167
144
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Persist an adopted language where the application's i18n layer reads its explicit choice from —
|
|
173
|
-
* `linker.language.storageKey`, `owlmeans-lng` by default — and only while the visitor has GRANTED
|
|
174
|
-
* `functional` on this document (a stored record, or the one adopted from the very link that
|
|
175
|
-
* carried the language). Without that grant nothing is written and `false` comes back: remembering
|
|
176
|
-
* a preference on a visitor's device is storage like any other, and a refusal ("reject all") is an
|
|
177
|
-
* answer, not a gap to fill. The gate lives here, not in the callers, so no caller can forget it.
|
|
178
|
-
*
|
|
179
|
-
* It overwrites what is there on purpose: the carried language is the one the visitor was reading a
|
|
180
|
-
* moment ago, which outranks a choice they made on this domain some other day. Storage that refuses
|
|
181
|
-
* the write is not an error. Returns whether the language was written.
|
|
182
|
-
*/
|
|
183
|
-
export const writeConsentLanguage = (language: string, opts?: ConsentOptions): boolean => {
|
|
184
|
-
if (!functionalGranted(opts)) {
|
|
185
|
-
return false
|
|
186
|
-
}
|
|
187
|
-
try {
|
|
188
|
-
localStorage.setItem(opts?.linker?.language?.storageKey ?? CONSENT_LANGUAGE_KEY, language)
|
|
145
|
+
const writeConsentLanguage = (language: string, opts?: ConsentOptions): boolean => {
|
|
146
|
+
try {
|
|
147
|
+
localStorage.setItem(opts?.linker?.language?.storageKey ?? CONSENT_LANGUAGE_KEY, language)
|
|
189
148
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
149
|
+
return true
|
|
150
|
+
} catch {
|
|
151
|
+
// Private modes and blocked storage both throw; the app simply keeps choosing its own language.
|
|
152
|
+
return false
|
|
153
|
+
}
|
|
194
154
|
}
|
|
195
|
-
}
|
|
196
155
|
|
|
197
|
-
/** Escape `<` inside a JSON literal meant for an inline `<script>` — the same discipline every
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const jsonForScript = (value: unknown): string => JSON.stringify(value).replace(/</g, '\\u003c')
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Cross-domain consent: share this decision, and adopt one, between the domains named in
|
|
205
|
-
* `opts.linker.domains`.
|
|
206
|
-
*
|
|
207
|
-
* `start` decorates outgoing first-party links; `adopt` reads and validates an incoming one;
|
|
208
|
-
* `domains` discloses the list a dialog or policy page names. `decorate` is generic (a bare `URL`,
|
|
209
|
-
* no DOM) so the same trust rule serves a real anchor click and a programmatic navigation
|
|
210
|
-
* (`decorateConsentUrl`) alike — the `rel="noreferrer"` exemption is a DOM-only concept and is
|
|
211
|
-
* therefore checked by `start`'s click handler, never here.
|
|
212
|
-
*/
|
|
213
|
-
export const consentLinker = (): ConsentPlugin => {
|
|
214
|
-
let installed = false
|
|
156
|
+
/** Escape `<` inside a JSON literal meant for an inline `<script>` — the same discipline every
|
|
157
|
+
* caller of `consentBootstrapScript`/`consentGateScript` already owes the HTML it lands in, applied
|
|
158
|
+
* here at the source since this fragment's dynamic values (a configured domain, a storage key) are
|
|
159
|
+
* least likely to have been reviewed for it by whoever composes the final page. */
|
|
160
|
+
const jsonForScript = (value: unknown): string => JSON.stringify(value).replace(/</g, '\\u003c')
|
|
215
161
|
|
|
216
|
-
|
|
217
|
-
|
|
162
|
+
const consentLinker = (): ConsentPlugin => {
|
|
163
|
+
let installed = false
|
|
218
164
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
return
|
|
222
|
-
}
|
|
223
|
-
installed = true
|
|
165
|
+
return {
|
|
166
|
+
alias: 'linker',
|
|
224
167
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const anchor = target instanceof Element ? target.closest('a[href]') : null
|
|
228
|
-
if (!(anchor instanceof HTMLAnchorElement)) {
|
|
229
|
-
return
|
|
230
|
-
}
|
|
231
|
-
// The trust rule keeps `noreferrer` links untouched: a link that refuses to disclose the
|
|
232
|
-
// referrer is refusing exactly the signal the receiving side's `adopt` needs to trust it.
|
|
233
|
-
if (/\bnoreferrer\b/.test(anchor.getAttribute('rel') ?? '')) {
|
|
168
|
+
start: opts => {
|
|
169
|
+
if (typeof document === 'undefined' || installed || opts.linker == null) {
|
|
234
170
|
return
|
|
235
171
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
172
|
+
installed = true
|
|
173
|
+
|
|
174
|
+
const onNavigate = (event: Event): void => {
|
|
175
|
+
const target = event.target
|
|
176
|
+
const anchor = target instanceof Element ? target.closest('a[href]') : null
|
|
177
|
+
if (!(anchor instanceof HTMLAnchorElement)) {
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
// The trust rule keeps `noreferrer` links untouched: a link that refuses to disclose the
|
|
181
|
+
// referrer is refusing exactly the signal the receiving side's `adopt` needs to trust it.
|
|
182
|
+
if (/\bnoreferrer\b/.test(anchor.getAttribute('rel') ?? '')) {
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
// No decision yet is still worth a decoration when a language rides along with it.
|
|
186
|
+
const record = explicitOf(consentStorageHelper.readConsent(opts))
|
|
187
|
+
if (record == null && opts.linker?.language == null) {
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
let url: URL
|
|
191
|
+
try {
|
|
192
|
+
url = new URL(anchor.href, location.href)
|
|
193
|
+
} catch {
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
const decorated = consentPluginHelper.decorateConsentUrl(url, record, opts)
|
|
197
|
+
if (decorated.toString() !== url.toString()) {
|
|
198
|
+
anchor.href = decorated.toString()
|
|
199
|
+
}
|
|
240
200
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
201
|
+
|
|
202
|
+
// Capture phase: this must decide before the click's own navigation (or a framework router
|
|
203
|
+
// intercepting it) reads `href`. `auxclick`/`contextmenu` cover a middle-click and "copy link
|
|
204
|
+
// address" — an address copied undecorated is a link that silently fails to carry the choice.
|
|
205
|
+
document.addEventListener('click', onNavigate, true)
|
|
206
|
+
document.addEventListener('auxclick', onNavigate, true)
|
|
207
|
+
document.addEventListener('contextmenu', onNavigate, true)
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
decorate: (url, input, opts) => {
|
|
211
|
+
const record = explicitOf(input)
|
|
212
|
+
const linker = opts.linker
|
|
213
|
+
const host = typeof location !== 'undefined' ? location.hostname : ''
|
|
214
|
+
if (linker == null || url.hostname === host || !linker.domains.includes(url.hostname)) {
|
|
215
|
+
return null
|
|
246
216
|
}
|
|
247
|
-
|
|
248
|
-
if (
|
|
249
|
-
|
|
217
|
+
// A link carries a decision, a language, or both; with neither there is nothing to say.
|
|
218
|
+
if (record == null && (linker.language == null || documentLanguage() == null)) {
|
|
219
|
+
return null
|
|
250
220
|
}
|
|
251
|
-
|
|
221
|
+
const next = new URL(url.toString())
|
|
222
|
+
next.searchParams.set(linker.param ?? CONSENT_LINK_PARAM, encodeConsentLink(record, opts))
|
|
252
223
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
// address" — an address copied undecorated is a link that silently fails to carry the choice.
|
|
256
|
-
document.addEventListener('click', onNavigate, true)
|
|
257
|
-
document.addEventListener('auxclick', onNavigate, true)
|
|
258
|
-
document.addEventListener('contextmenu', onNavigate, true)
|
|
259
|
-
},
|
|
260
|
-
|
|
261
|
-
decorate: (url, record, opts) => {
|
|
262
|
-
const linker = opts.linker
|
|
263
|
-
const host = typeof location !== 'undefined' ? location.hostname : ''
|
|
264
|
-
if (linker == null || url.hostname === host || !linker.domains.includes(url.hostname)) {
|
|
265
|
-
return null
|
|
266
|
-
}
|
|
267
|
-
// A link carries a decision, a language, or both; with neither there is nothing to say.
|
|
268
|
-
if (record == null && (linker.language == null || documentLanguage() == null)) {
|
|
269
|
-
return null
|
|
270
|
-
}
|
|
271
|
-
const next = new URL(url.toString())
|
|
272
|
-
next.searchParams.set(linker.param ?? CONSENT_LINK_PARAM, encodeConsentLink(record, opts))
|
|
224
|
+
return next
|
|
225
|
+
},
|
|
273
226
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (!optional.every(category => decoded.c[category.key] === 0 || decoded.c[category.key] === 1)) {
|
|
284
|
-
return null
|
|
285
|
-
}
|
|
227
|
+
adopt: opts => {
|
|
228
|
+
const decoded = trustedPayload(opts)
|
|
229
|
+
if (decoded == null) {
|
|
230
|
+
return null
|
|
231
|
+
}
|
|
232
|
+
const optional = optionalOf(opts)
|
|
233
|
+
if (!optional.every(category => decoded.c[category.key] === 0 || decoded.c[category.key] === 1)) {
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
286
236
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
237
|
+
const record: ConsentRecord = { v: CONSENT_SCHEMA_VERSION }
|
|
238
|
+
for (const category of categoriesOf(opts)) {
|
|
239
|
+
record[category.key] = category.required === true || decoded.c[category.key] === 1
|
|
240
|
+
}
|
|
291
241
|
|
|
292
|
-
|
|
293
|
-
|
|
242
|
+
return record
|
|
243
|
+
},
|
|
294
244
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
245
|
+
adoptLanguage: opts => {
|
|
246
|
+
const supported = opts.linker?.language?.supported
|
|
247
|
+
const decoded = supported != null ? trustedPayload(opts) : null
|
|
298
248
|
|
|
299
|
-
|
|
300
|
-
|
|
249
|
+
return decoded?.l != null && supported != null ? supportedLanguage(decoded.l, supported) : null
|
|
250
|
+
},
|
|
301
251
|
|
|
302
|
-
|
|
252
|
+
domains: opts => opts.linker?.domains ?? [],
|
|
253
|
+
}
|
|
303
254
|
}
|
|
304
|
-
}
|
|
305
255
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
256
|
+
const stripConsentLinkParam = (opts: ConsentOptions): void => {
|
|
257
|
+
const linker = opts.linker
|
|
258
|
+
if (linker == null || typeof location === 'undefined' || typeof history === 'undefined') {
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
const param = linker.param ?? CONSENT_LINK_PARAM
|
|
262
|
+
const url = new URL(location.href)
|
|
263
|
+
if (!url.searchParams.has(param)) {
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
url.searchParams.delete(param)
|
|
267
|
+
try {
|
|
268
|
+
history.replaceState(history.state, '', `${url.pathname}${url.search}${url.hash}`)
|
|
269
|
+
} catch {
|
|
270
|
+
// A sandboxed/embedded context may refuse history writes; the parameter just stays visible.
|
|
271
|
+
}
|
|
321
272
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
273
|
+
|
|
274
|
+
const consentLinkerScript = (opts: ConsentOptions): string => {
|
|
275
|
+
const linker = opts.linker
|
|
276
|
+
if (linker == null) {
|
|
277
|
+
return ''
|
|
278
|
+
}
|
|
279
|
+
const param = jsonForScript(linker.param ?? CONSENT_LINK_PARAM)
|
|
280
|
+
const domains = jsonForScript(linker.domains)
|
|
281
|
+
const storageKey = jsonForScript(opts.storageKey ?? CONSENT_KEY)
|
|
282
|
+
const optionalKeys = jsonForScript(optionalOf(opts).map(category => category.key))
|
|
283
|
+
const allCategories = jsonForScript(
|
|
284
|
+
categoriesOf(opts).map(category => ({ key: category.key, required: category.required === true }))
|
|
285
|
+
)
|
|
286
|
+
const maxAge = linker.maxAge ?? CONSENT_LINK_MAX_AGE
|
|
287
|
+
const skew = CONSENT_LINK_SKEW
|
|
288
|
+
const cookieDays = opts.cookieDays ?? CONSENT_COOKIE_DAYS
|
|
289
|
+
const version = CONSENT_LINK_VERSION
|
|
290
|
+
// The language rides the same trust decision and is written unconditionally — it is strictly
|
|
291
|
+
// necessary storage, whatever the cookie decision is. Mirrors `supportedLanguage` +
|
|
292
|
+
// `writeConsentLanguage`; empty for a page that does not receive a language.
|
|
293
|
+
const language = linker.language?.supported != null
|
|
294
|
+
? `var L=${jsonForScript(linker.language.supported)};` +
|
|
295
|
+
`if(typeof j.l==='string'){var lc=j.l.toLowerCase(),lm=null,q;` +
|
|
296
|
+
`for(q=0;q<L.length;q++){if(String(L[q]).toLowerCase()===lc){lm=L[q];break}}` +
|
|
297
|
+
`if(!lm){lc=lc.split(/[-_]/)[0];for(q=0;q<L.length;q++){if(String(L[q]).toLowerCase()===lc){lm=L[q];break}}}` +
|
|
298
|
+
`if(lm){try{w.localStorage.setItem(${jsonForScript(linker.language.storageKey ?? CONSENT_LANGUAGE_KEY)},lm)}catch(e){}}}`
|
|
299
|
+
: ''
|
|
300
|
+
|
|
301
|
+
return `(function(w,d){` +
|
|
302
|
+
`var p=${param};var qs;try{qs=new URLSearchParams(location.search)}catch(e){return}` +
|
|
303
|
+
`var raw=qs.get(p);if(raw==null)return;` +
|
|
304
|
+
`qs.delete(p);var rest=qs.toString();` +
|
|
305
|
+
`var url=location.pathname+(rest?('?'+rest):'')+location.hash;` +
|
|
306
|
+
`try{history.replaceState(history.state,'',url)}catch(e){}` +
|
|
307
|
+
`try{` +
|
|
308
|
+
`var b=raw.replace(/-/g,'+').replace(/_/g,'/');while(b.length%4)b+='=';` +
|
|
309
|
+
`var j=JSON.parse(atob(b));` +
|
|
310
|
+
`if(!j||j.v!==${version}||typeof j.t!=='number'||!j.c||typeof j.c!=='object')return;` +
|
|
311
|
+
`var now=Math.floor(Date.now()/1000);` +
|
|
312
|
+
`if(now-j.t>${maxAge}||j.t-now>${skew})return;` +
|
|
313
|
+
`var ref=null;try{ref=new URL(d.referrer).hostname}catch(e){}` +
|
|
314
|
+
`if(!ref||${domains}.indexOf(ref)<0)return;` +
|
|
315
|
+
`var existing=null;try{existing=w.localStorage.getItem(${storageKey})}catch(e){}` +
|
|
316
|
+
`if(!existing){var cp=('; '+d.cookie).split('; '+${storageKey}+'=');` +
|
|
317
|
+
`if(cp.length===2)existing=cp.pop().split(';').shift()}` +
|
|
318
|
+
// An automatic decision is replaced by the visitor's own; an unparseable one still blocks.
|
|
319
|
+
`var ex=null;if(existing){try{ex=JSON.parse(existing)}catch(e){ex=false}}` +
|
|
320
|
+
`if(!existing||(ex&&typeof ex.auto==='number')){` +
|
|
321
|
+
`var keys=${optionalKeys},ok=true;` +
|
|
322
|
+
`for(var i=0;i<keys.length;i++){if(j.c[keys[i]]!==0&&j.c[keys[i]]!==1){ok=false;break}}` +
|
|
323
|
+
`if(ok){` +
|
|
324
|
+
`var cats=${allCategories},rec={v:${version}};` +
|
|
325
|
+
`for(var k=0;k<cats.length;k++){var c=cats[k];rec[c.key]=c.required||j.c[c.key]===1}` +
|
|
326
|
+
`var val=JSON.stringify(rec);` +
|
|
327
|
+
`try{w.localStorage.setItem(${storageKey},val)}catch(e){}` +
|
|
328
|
+
`var exp=new Date(Date.now()+${cookieDays}*86400000).toUTCString();` +
|
|
329
|
+
`d.cookie=${storageKey}+'='+val+';expires='+exp+';path=/;SameSite=Lax'` +
|
|
330
|
+
`}` +
|
|
331
|
+
`}` +
|
|
332
|
+
language +
|
|
333
|
+
`}catch(e){}` +
|
|
334
|
+
`})(window,document)`
|
|
327
335
|
}
|
|
328
|
-
}
|
|
329
336
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
* script). `@owlmeans/web-gtm`'s `consentBootstrapScript` embeds this SAME fragment right after
|
|
334
|
-
* pushing Consent Mode's defaults, so a page that has both never runs it twice with different
|
|
335
|
-
* effect — the second attempt finds the parameter already gone and does nothing.
|
|
336
|
-
*
|
|
337
|
-
* Mirrors `consentLinker().adopt` exactly (v2, referrer + freshness/skew + full optional coverage +
|
|
338
|
-
* no existing record), but as hand-rolled JS text rather than a call into this module — the same
|
|
339
|
-
* discipline `consentBootstrapScript`/`consentGateScript` already follow, because this has to run
|
|
340
|
-
* before any bundle (this module included) has loaded. The parameter is ALWAYS stripped via
|
|
341
|
-
* `history.replaceState`, whether or not the trust rule accepts it: a stale or foreign `owlcc` is
|
|
342
|
-
* exactly as much noise as an adopted one, and neither belongs in the visible URL or in what a page
|
|
343
|
-
* reload, a shared link or a browser history entry carries forward.
|
|
344
|
-
*/
|
|
345
|
-
export const consentLinkerScript = (opts: ConsentOptions): string => {
|
|
346
|
-
const linker = opts.linker
|
|
347
|
-
if (linker == null) {
|
|
348
|
-
return ''
|
|
337
|
+
return {
|
|
338
|
+
encodeConsentLink, decodeConsentLink, writeConsentLanguage, consentLinker, stripConsentLinkParam,
|
|
339
|
+
consentLinkerScript,
|
|
349
340
|
}
|
|
350
|
-
const param = jsonForScript(linker.param ?? CONSENT_LINK_PARAM)
|
|
351
|
-
const domains = jsonForScript(linker.domains)
|
|
352
|
-
const storageKey = jsonForScript(opts.storageKey ?? CONSENT_KEY)
|
|
353
|
-
const optionalKeys = jsonForScript(optionalOf(opts).map(category => category.key))
|
|
354
|
-
const allCategories = jsonForScript(
|
|
355
|
-
categoriesOf(opts).map(category => ({ key: category.key, required: category.required === true }))
|
|
356
|
-
)
|
|
357
|
-
const maxAge = linker.maxAge ?? CONSENT_LINK_MAX_AGE
|
|
358
|
-
const skew = CONSENT_LINK_SKEW
|
|
359
|
-
const cookieDays = opts.cookieDays ?? CONSENT_COOKIE_DAYS
|
|
360
|
-
const version = CONSENT_LINK_VERSION
|
|
361
|
-
// The language rides the same trust decision but is WRITTEN only while `functional` is granted
|
|
362
|
-
// here — in the stored record, or in the record just adopted from this very link (`fg` below).
|
|
363
|
-
// Otherwise it is left on `window` for the store to pick up (memory, never storage): the visitor
|
|
364
|
-
// may still grant functional storage later in this page's life. Mirrors `supportedLanguage` +
|
|
365
|
-
// `writeConsentLanguage`; empty for a page that does not receive a language.
|
|
366
|
-
const functionalKey = jsonForScript(CONSENT_FUNCTIONAL)
|
|
367
|
-
const language = linker.language?.supported != null
|
|
368
|
-
? `var L=${jsonForScript(linker.language.supported)};` +
|
|
369
|
-
`if(typeof j.l==='string'){var lc=j.l.toLowerCase(),lm=null,q;` +
|
|
370
|
-
`for(q=0;q<L.length;q++){if(String(L[q]).toLowerCase()===lc){lm=L[q];break}}` +
|
|
371
|
-
`if(!lm){lc=lc.split(/[-_]/)[0];for(q=0;q<L.length;q++){if(String(L[q]).toLowerCase()===lc){lm=L[q];break}}}` +
|
|
372
|
-
`if(lm){if(fg){try{w.localStorage.setItem(${jsonForScript(linker.language.storageKey ?? CONSENT_LANGUAGE_KEY)},lm)}catch(e){}}` +
|
|
373
|
-
`else{w[${jsonForScript(CONSENT_PENDING_LANGUAGE)}]=lm}}}`
|
|
374
|
-
: ''
|
|
375
|
-
|
|
376
|
-
return `(function(w,d){` +
|
|
377
|
-
`var p=${param};var qs;try{qs=new URLSearchParams(location.search)}catch(e){return}` +
|
|
378
|
-
`var raw=qs.get(p);if(raw==null)return;` +
|
|
379
|
-
`qs.delete(p);var rest=qs.toString();` +
|
|
380
|
-
`var url=location.pathname+(rest?('?'+rest):'')+location.hash;` +
|
|
381
|
-
`try{history.replaceState(history.state,'',url)}catch(e){}` +
|
|
382
|
-
`try{` +
|
|
383
|
-
`var b=raw.replace(/-/g,'+').replace(/_/g,'/');while(b.length%4)b+='=';` +
|
|
384
|
-
`var j=JSON.parse(atob(b));` +
|
|
385
|
-
`if(!j||j.v!==${version}||typeof j.t!=='number'||!j.c||typeof j.c!=='object')return;` +
|
|
386
|
-
`var now=Math.floor(Date.now()/1000);` +
|
|
387
|
-
`if(now-j.t>${maxAge}||j.t-now>${skew})return;` +
|
|
388
|
-
`var ref=null;try{ref=new URL(d.referrer).hostname}catch(e){}` +
|
|
389
|
-
`if(!ref||${domains}.indexOf(ref)<0)return;` +
|
|
390
|
-
`var existing=null;try{existing=w.localStorage.getItem(${storageKey})}catch(e){}` +
|
|
391
|
-
`if(!existing){var cp=('; '+d.cookie).split('; '+${storageKey}+'=');` +
|
|
392
|
-
`if(cp.length===2)existing=cp.pop().split(';').shift()}` +
|
|
393
|
-
// `fg`: functional storage is granted here — by a parseable stored record, or by the one
|
|
394
|
-
// adopted below. A record with no `functional` key (saved before the category existed) is not
|
|
395
|
-
// a grant.
|
|
396
|
-
`var fg=false;` +
|
|
397
|
-
`if(existing){try{var ex=JSON.parse(existing);fg=!!ex&&typeof ex==='object'&&ex[${functionalKey}]===true}catch(e){}}` +
|
|
398
|
-
`else{` +
|
|
399
|
-
`var keys=${optionalKeys},ok=true;` +
|
|
400
|
-
`for(var i=0;i<keys.length;i++){if(j.c[keys[i]]!==0&&j.c[keys[i]]!==1){ok=false;break}}` +
|
|
401
|
-
`if(ok){` +
|
|
402
|
-
`var cats=${allCategories},rec={v:${version}};` +
|
|
403
|
-
`for(var k=0;k<cats.length;k++){var c=cats[k];rec[c.key]=c.required||j.c[c.key]===1}` +
|
|
404
|
-
`var val=JSON.stringify(rec);` +
|
|
405
|
-
`try{w.localStorage.setItem(${storageKey},val)}catch(e){}` +
|
|
406
|
-
`var exp=new Date(Date.now()+${cookieDays}*86400000).toUTCString();` +
|
|
407
|
-
`d.cookie=${storageKey}+'='+val+';expires='+exp+';path=/;SameSite=Lax';fg=rec[${functionalKey}]===true` +
|
|
408
|
-
`}` +
|
|
409
|
-
`}` +
|
|
410
|
-
language +
|
|
411
|
-
`}catch(e){}` +
|
|
412
|
-
`})(window,document)`
|
|
413
341
|
}
|
|
342
|
+
|
|
343
|
+
export const consentLinkHelper = createConsentLinkHelper()
|
|
344
|
+
|
|
345
|
+
/** @deprecated compat:factory-refactor — use `consentLinkHelper.encodeConsentLink(…)` */
|
|
346
|
+
export const encodeConsentLink = (record: ConsentRecord | null, opts?: ConsentOptions): string =>
|
|
347
|
+
consentLinkHelper.encodeConsentLink(record, opts)
|
|
348
|
+
|
|
349
|
+
/** @deprecated compat:factory-refactor — use `consentLinkHelper.consentLinkerScript(…)` */
|
|
350
|
+
export const consentLinkerScript = (opts: ConsentOptions): string => consentLinkHelper.consentLinkerScript(opts)
|