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