@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/src/store.ts CHANGED
@@ -1,15 +1,16 @@
1
1
  import {
2
- CONSENT_FUNCTIONAL, CONSENT_KEY, CONSENT_LANGUAGE_EVENT, CONSENT_PENDING_LANGUAGE,
3
- DEFAULT_CONSENT_CATEGORIES,
2
+ CONSENT_IDLE_STATE, CONSENT_KEY, CONSENT_STATE_ATTRIBUTE, DEFAULT_CONSENT_CATEGORIES,
4
3
  } from './consts.js'
5
- import { purgeFunctionalStorage } from './functional.js'
6
- import { applyConsent, pushConsentDefaults } from './gtm.js'
7
- import { consentLinker, stripConsentLinkParam, writeConsentLanguage } from './linker.js'
8
- import { adoptConsent, adoptConsentLanguage, registerConsentPlugin, startConsentPlugins } from './plugins.js'
9
- import { readConsent, writeConsent } from './storage.js'
4
+ import { CLOUDFLARE_LOCATOR_ALIAS } from './consts.local.js'
5
+ import { consentModeHelper } from './gtobserver.js'
6
+ import { consentGeoHelper } from './geo.js'
7
+ import { consentLinkHelper } from './linker.js'
8
+ import { consentPluginHelper } from './plugins.js'
9
+ import { consentStorageHelper } from './storage.js'
10
10
  import type {
11
11
  ConsentListener, ConsentOptions, ConsentReason, ConsentRecord, ConsentState, ConsentStore,
12
12
  } from './types.js'
13
+ import type { ConsentGeoVerdict } from './geo/types.js'
13
14
 
14
15
  /**
15
16
  * The consent state of this DOCUMENT.
@@ -21,49 +22,73 @@ import type {
21
22
  */
22
23
  export const makeConsentStore = (): ConsentStore => {
23
24
  const listeners = new Set<ConsentListener>()
24
- let state: ConsentState = { record: null, open: false, reason: null }
25
+ const waiting = new Set<(state: ConsentState) => void>()
26
+ let state: ConsentState = CONSENT_IDLE_STATE
25
27
  let options: ConsentOptions = {}
26
- // A language that arrived with a link (or was chosen) before the visitor granted functional
27
- // storage. Memory only — it is never written until the grant, and it dies with the page.
28
- let pendingLanguage: string | null = null
28
+ /** The one country lookup of this document, while it runs. */
29
+ let lookup: Promise<void> | null = null
30
+ /** What the last lookup decided — a later `init` never locates the same visitor twice. */
31
+ let verdict: ConsentGeoVerdict | null = null
32
+
33
+ /** `<html data-consent>`: what a test waits on and a stylesheet may key on. */
34
+ const mark = (): void => {
35
+ const root = typeof document !== 'undefined' ? document.documentElement : undefined
36
+ if (root == null || typeof root.setAttribute !== 'function') {
37
+ return
38
+ }
39
+ root.setAttribute(CONSENT_STATE_ATTRIBUTE, state.open ? 'open'
40
+ : state.locating != null ? 'locating'
41
+ : state.record != null ? 'decided' : 'idle')
42
+ }
29
43
 
30
44
  const publish = (next: Partial<ConsentState>): void => {
31
45
  state = { ...state, ...next }
46
+ mark()
32
47
  listeners.forEach(listener => listener(state))
48
+ if (state.locating == null && lookup == null && waiting.size > 0) {
49
+ const settled = [...waiting]
50
+ waiting.clear()
51
+ settled.forEach(resolve => resolve(state))
52
+ }
53
+ }
54
+
55
+ const resolved = (): ConsentOptions & { categories: typeof DEFAULT_CONSENT_CATEGORIES, storageKey: string } => ({
56
+ ...options,
57
+ categories: options.categories ?? DEFAULT_CONSENT_CATEGORIES,
58
+ storageKey: options.storageKey ?? CONSENT_KEY,
59
+ })
60
+
61
+ const ask = (): void => {
62
+ publish({ record: null, open: true, reason: 'initial', locating: null })
33
63
  }
34
64
 
35
65
  /**
36
- * Bring functional storage in line with the record, now.
37
- *
38
- * Granted: a language that was waiting is written, and the application is told (`CONSENT_LANGUAGE_EVENT`)
39
- * so it can switch this very page instead of the next load. Not granted — a refusal, a withdrawal,
40
- * or a record that never answered the question — every functional key is REMOVED: a preference
41
- * must not outlive the consent to remember it. What was waiting stays waiting; the visitor may
42
- * still grant it later in this page's life.
66
+ * What a finished lookup does — unless the visitor got there first: a decision saved meanwhile, or
67
+ * the window opened for a reason of its own (signing in), always wins over the location.
43
68
  */
44
- const settleFunctional = (): void => {
45
- if (state.record?.[CONSENT_FUNCTIONAL] !== true) {
46
- purgeFunctionalStorage(options)
69
+ const settle = (result: ConsentGeoVerdict, pending: ConsentRecord | null): void => {
70
+ verdict = result
71
+ lookup = null
72
+ if (state.record != null || (state.open && state.reason !== 'initial')) {
73
+ publish({ locating: null })
47
74
 
48
75
  return
49
76
  }
50
- if (pendingLanguage == null) {
77
+ if (result === 'auto') {
78
+ const record = consentGeoHelper.automaticRecord(resolved())
79
+ consentStorageHelper.writeConsent(record, options)
80
+ consentModeHelper.applyConsent(record, options)
81
+ publish({ record, open: false, reason: null, locating: null })
82
+
51
83
  return
52
84
  }
53
- const language = pendingLanguage
54
- pendingLanguage = null
55
- if (writeConsentLanguage(language, options) && typeof window !== 'undefined'
56
- && typeof window.dispatchEvent === 'function' && typeof CustomEvent === 'function') {
57
- window.dispatchEvent(new CustomEvent(CONSENT_LANGUAGE_EVENT, { detail: { language } }))
85
+ if (pending != null) {
86
+ // A grant derived somewhere nobody had to be asked does not survive arriving where they must.
87
+ consentStorageHelper.clearConsent(options)
58
88
  }
89
+ ask()
59
90
  }
60
91
 
61
- const resolved = (): ConsentOptions & { categories: typeof DEFAULT_CONSENT_CATEGORIES, storageKey: string } => ({
62
- ...options,
63
- categories: options.categories ?? DEFAULT_CONSENT_CATEGORIES,
64
- storageKey: options.storageKey ?? CONSENT_KEY,
65
- })
66
-
67
92
  const store: ConsentStore = {
68
93
  get: () => state,
69
94
 
@@ -76,58 +101,82 @@ export const makeConsentStore = (): ConsentStore => {
76
101
  init: opts => {
77
102
  options = { ...options, ...opts }
78
103
  // Before anything is read, so a page with no stored answer still declares what is denied.
79
- pushConsentDefaults(options)
104
+ consentModeHelper.pushConsentDefaults(options)
80
105
 
81
106
  if (options.linker != null) {
82
- // Replace-by-alias (`registerConsentPlugin`), so a second `init` (a re-mounted provider,
83
- // StrictMode) never double-installs the click listener — `consentLinker().start` also
84
- // guards itself with its own closured flag, belt and braces.
85
- registerConsentPlugin(consentLinker())
86
- startConsentPlugins(options)
107
+ // Registered once: `start` installs the click listener of THAT instance, and a second
108
+ // instance (a second `init` — a re-mounted provider, StrictMode, `loadGtm`) would install
109
+ // its own beside it.
110
+ if (!consentPluginHelper.consentPlugins().some(plugin => plugin.alias === 'linker')) {
111
+ consentPluginHelper.registerConsentPlugin(consentLinkHelper.consentLinker())
112
+ }
113
+ consentPluginHelper.startConsentPlugins(options)
114
+ }
115
+ if (options.geo?.cloudflare != null && options.geo.cloudflare !== false
116
+ && !consentPluginHelper.consentPlugins().some(plugin => plugin.alias === CLOUDFLARE_LOCATOR_ALIAS)) {
117
+ consentPluginHelper.registerConsentPlugin(consentGeoHelper.cloudflareLocator())
87
118
  }
88
119
 
89
- let record = readConsent(options)
90
- // Only when THIS document has no decision yet — an existing one always wins, exactly as the
91
- // ordinary "ask" path would never overwrite a stored record either.
92
- if (record == null && options.linker != null) {
93
- record = adoptConsent(options)
94
- if (record != null) {
95
- writeConsent(record, options)
120
+ let record = consentStorageHelper.readConsent(options)
121
+ const automatic = consentGeoHelper.autoState(record)
122
+ // An automatic decision past its age is held back — applied nowhere — until the visitor is
123
+ // located again, exactly as the head scripts already skipped it.
124
+ const pending = automatic === 'stale' ? record : null
125
+ if (pending != null) {
126
+ record = null
127
+ }
128
+ // An explicit decision carried from another domain always beats none — and beats an automatic
129
+ // one, which nobody chose. An explicit stored one always wins, as the ordinary "ask" path
130
+ // would never overwrite it either.
131
+ if ((record == null || automatic != null) && options.linker != null) {
132
+ const adopted = consentPluginHelper.adoptConsent(options)
133
+ if (adopted != null) {
134
+ consentStorageHelper.writeConsent(adopted, options)
135
+ record = adopted
96
136
  }
97
137
  }
98
138
  if (options.linker != null) {
99
- // What the link carried, or what the inline head fragment already took off it and left on
100
- // `window` (it strips the parameter, so this is the only place that still knows). Held, not
101
- // written: whether it may be stored is settled below, against the record.
102
- const win = typeof window !== 'undefined' ? window as unknown as Record<string, unknown> : null
103
- const inline = win?.[CONSENT_PENDING_LANGUAGE]
104
- pendingLanguage = adoptConsentLanguage(options) ?? (typeof inline === 'string' ? inline : pendingLanguage)
105
- if (win != null && inline !== undefined) {
106
- delete win[CONSENT_PENDING_LANGUAGE]
139
+ // The language the link carried, stored at once — the interface language is strictly
140
+ // necessary storage, so no decision is consulted. A page that stamped the inline head
141
+ // fragment has already done this and stripped the parameter, which leaves nothing here.
142
+ const carried = consentPluginHelper.adoptConsentLanguage(options)
143
+ if (carried != null) {
144
+ consentLinkHelper.writeConsentLanguage(carried, options)
107
145
  }
108
146
  // Always, whether or not anything was adopted — a stale or foreign parameter is exactly as
109
147
  // much noise in the visible URL as an adopted one, and a page that already had its own
110
148
  // decision may still have arrived with one attached.
111
- stripConsentLinkParam(options)
149
+ consentLinkHelper.stripConsentLinkParam(options)
112
150
  }
113
151
 
114
152
  if (record != null) {
115
- applyConsent(record, options)
116
- publish({ record, open: false, reason: null })
117
- settleFunctional()
153
+ consentModeHelper.applyConsent(record, options)
154
+ publish({ record, open: false, reason: null, locating: null })
118
155
 
119
156
  return
120
157
  }
121
- publish({ record: null, open: true, reason: 'initial' })
122
- settleFunctional()
158
+ if (lookup != null) {
159
+ return
160
+ }
161
+ // The window is already open for a reason of its own (signing in): the visitor is answering.
162
+ if (state.open && state.reason !== 'initial') {
163
+ return
164
+ }
165
+ if (verdict === 'ask' || !consentGeoHelper.enabled(options)) {
166
+ ask()
167
+
168
+ return
169
+ }
170
+ // A first visit waits behind the spinner; a re-check runs silently, nothing applied meanwhile.
171
+ publish({ record: null, open: false, reason: null, locating: pending != null ? 'recheck' : 'first' })
172
+ lookup = consentGeoHelper.decide(options).then(result => settle(result, pending))
123
173
  },
124
174
 
125
175
  save: record => {
126
- writeConsent(record, options)
127
- applyConsent(record, options)
128
- publish({ record, open: false, reason: null })
129
- // After the record is stored: `writeConsentLanguage` reads it back to decide.
130
- settleFunctional()
176
+ const { auto: _auto, ...explicit } = record
177
+ consentStorageHelper.writeConsent(explicit, options)
178
+ consentModeHelper.applyConsent(explicit, options)
179
+ publish({ record: explicit, open: false, reason: null, locating: null })
131
180
  },
132
181
 
133
182
  acceptAll: () => {
@@ -137,7 +186,9 @@ export const makeConsentStore = (): ConsentStore => {
137
186
  store.save(record)
138
187
  },
139
188
 
140
- open: (reason?: ConsentReason) => { publish({ open: true, reason: reason ?? 'reopen' }) },
189
+ open: (reason?: ConsentReason) => {
190
+ publish({ open: true, reason: reason ?? 'reopen', locating: null })
191
+ },
141
192
 
142
193
  close: () => { publish({ open: false, reason: null }) },
143
194
 
@@ -152,7 +203,9 @@ export const makeConsentStore = (): ConsentStore => {
152
203
 
153
204
  options: resolved,
154
205
 
155
- pendingLanguage: () => pendingLanguage,
206
+ settled: () => state.locating == null && lookup == null
207
+ ? Promise.resolve(state)
208
+ : new Promise(resolve => { waiting.add(resolve) }),
156
209
  }
157
210
 
158
211
  return store
@@ -160,8 +213,8 @@ export const makeConsentStore = (): ConsentStore => {
160
213
 
161
214
  export const consentStore: ConsentStore = makeConsentStore()
162
215
 
163
- /** Open the preferences dialog from anywhere — a footer link, a policy page, a login gate. */
216
+ /** @deprecated compat:factory-refactor — use `consentStore.open(…)` */
164
217
  export const openConsent = (reason?: ConsentReason): void => consentStore.open(reason)
165
218
 
166
- /** Whether a category is granted, for the callers that are not components. */
219
+ /** @deprecated compat:factory-refactor — use `consentStore.granted(…)` */
167
220
  export const isConsented = (key: string): boolean => consentStore.granted(key)
@@ -0,0 +1,9 @@
1
+ export interface ConsentWindow {
2
+ dataLayer?: unknown[]
3
+ [flag: string]: unknown
4
+ }
5
+
6
+ /** The one navigator field the automatic decision reads: Global Privacy Control. */
7
+ export interface ConsentNavigator {
8
+ globalPrivacyControl?: boolean
9
+ }
package/src/types.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  * manager simply never sees the update, and the only symptom is analytics that stay switched off
6
6
  * for everyone who agreed to them.
7
7
  */
8
+ import { CONSENT_LINK_VERSION } from './consts.local.js'
9
+
8
10
  export type ConsentSignal =
9
11
  | 'ad_storage' | 'ad_user_data' | 'ad_personalization'
10
12
  | 'analytics_storage' | 'functionality_storage'
@@ -20,7 +22,7 @@ export interface ConsentCategory {
20
22
  * itself a dark pattern. A required category is disclosure, not a question.
21
23
  */
22
24
  required?: boolean
23
- /** Resolved through `translate`; the built-in bundle supplies the default set in 7 languages. */
25
+ /** Resolved through `translate`; the built-in bundle supplies the default set in 8 languages. */
24
26
  labelKey: string
25
27
  descriptionKey: string
26
28
  /**
@@ -68,12 +70,60 @@ export interface ConsentService {
68
70
  privacyHref?: string
69
71
  }
70
72
 
71
- /** `v` is the schema version; every other key is a category key. */
73
+ /**
74
+ * `v` is the schema version and `auto` marks an automatic decision; every other key is a category
75
+ * key — so neither `v` nor `auto` may ever name a category.
76
+ */
72
77
  export interface ConsentRecord {
73
78
  v?: number
79
+ /**
80
+ * Unix seconds at which this record was DERIVED rather than chosen: the visitor was located
81
+ * outside every consent-requiring country (`ConsentGeoOptions`) and nobody asked them. Present
82
+ * only on an automatic decision — an explicit save drops it. An automatic decision is trusted for
83
+ * `CONSENT_AUTO_MAX_AGE` seconds and then counts as undecided until the visitor is located again,
84
+ * so a traveller who crosses into a consent country is asked there instead of carrying a grant
85
+ * nobody gave. It never travels on a linker link as the visitor's own choice.
86
+ */
87
+ auto?: number
74
88
  [category: string]: boolean | number | undefined
75
89
  }
76
90
 
91
+ /** Where a locator places the visitor. */
92
+ export interface ConsentGeoLocation {
93
+ /** ISO 3166-1 alpha-2, upper case — `PL`, `US`. Anything else counts as "not located". */
94
+ country: string
95
+ }
96
+
97
+ export interface ConsentCloudflareOptions {
98
+ /** The same-origin path of Cloudflare's trace endpoint. Defaults to `CONSENT_TRACE_PATH`. */
99
+ path?: string
100
+ }
101
+
102
+ /**
103
+ * Ask only where the law requires it: locate the visitor first, and decide automatically wherever
104
+ * no consent is required.
105
+ *
106
+ * Its presence turns the gate on. The country comes from the registered locators
107
+ * (`ConsentGeoPlugin`, highest priority first, the first usable answer wins); `cloudflare` adds the
108
+ * built-in one. A visitor nobody can locate — no locator, every one of them failing, an unusable
109
+ * code, `timeout` passing — is asked, exactly as without the gate.
110
+ */
111
+ export interface ConsentGeoOptions {
112
+ /**
113
+ * The built-in locator: a same-origin GET of Cloudflare's `/cdn-cgi/trace`, reading `loc=`. It
114
+ * answers only where the host is proxied by Cloudflare — anywhere else (a local dev server, a
115
+ * self-hosted export) it fails, and the visitor is asked.
116
+ */
117
+ cloudflare?: boolean | ConsentCloudflareOptions
118
+ /** The countries where consent is asked. Defaults to `CONSENT_REQUIRED_COUNTRIES`. */
119
+ countries?: readonly string[]
120
+ /** How long locating may take before the visitor is asked anyway, in ms. `CONSENT_GEO_TIMEOUT`. */
121
+ timeout?: number
122
+ }
123
+
124
+ /** Which lookup is running: the first one (the page waits behind a spinner) or a silent re-check. */
125
+ export type ConsentLocating = 'first' | 'recheck'
126
+
77
127
  export interface ConsentOptions {
78
128
  categories?: ConsentCategory[]
79
129
  storageKey?: string
@@ -85,19 +135,14 @@ export interface ConsentOptions {
85
135
  cookieDomain?: string
86
136
  /** Skip every dataLayer and global write. For tests, and for an app that runs no tags. */
87
137
  silent?: boolean
88
- /**
89
- * The `localStorage` keys that hold this application's functional preferences (the interface
90
- * language, today). They may exist only while the visitor has granted `functional`: `init` and
91
- * `save` remove every one of them the moment it is not granted. Defaults to the language key
92
- * (`owlmeans-lng`); `linker.language.storageKey` is always included.
93
- */
94
- functionalKeys?: string[]
95
138
  /**
96
139
  * Cross-domain consent: share this decision, and adopt one, between the listed first-party
97
140
  * domains — a visitor who already decided on one need not decide again on another. See
98
141
  * `consentLinker`/`ConsentPlugin` in `./plugins.js` and `./linker.js`.
99
142
  */
100
143
  linker?: ConsentLinkerOptions
144
+ /** Ask only in consent-requiring countries — see `ConsentGeoOptions`. Off when absent. */
145
+ geo?: ConsentGeoOptions
101
146
  }
102
147
 
103
148
  export interface ConsentLinkerOptions {
@@ -115,8 +160,8 @@ export interface ConsentLinkerOptions {
115
160
  * Carry the visitor's interface language along with the decision, so a visitor reading the site
116
161
  * in Polish arrives on the next domain in Polish. Its PRESENCE turns the sending side on (a link
117
162
  * to a listed domain carries this document's `<html lang>`); `supported` turns the receiving side
118
- * on — which still stores nothing until functional storage is granted there. See
119
- * `ConsentLinkerLanguage`.
163
+ * on — which stores the carried language whatever the cookie decision says, because the interface
164
+ * language is strictly necessary. See `ConsentLinkerLanguage`.
120
165
  */
121
166
  language?: ConsentLinkerLanguage
122
167
  }
@@ -124,48 +169,131 @@ export interface ConsentLinkerOptions {
124
169
  export interface ConsentLinkerLanguage {
125
170
  /**
126
171
  * RECEIVING side: the languages this application can render. A carried language is adopted only
127
- * when it is one of these, exactly or by its base tag (`de-DE` → `de`). It is stored only while
128
- * the visitor has granted `functional` on this document — until then it is held in memory, and
129
- * stored if they grant it later in this page's life (`CONSENT_LANGUAGE_EVENT` tells the app);
172
+ * when it is one of these, exactly or by its base tag (`de-DE` → `de`), and is then stored at
173
+ * once — the interface language is strictly necessary storage, so no cookie decision gates it;
130
174
  * anything else is ignored and the application keeps choosing its own. Leave it out on a site
131
175
  * that only SENDS its language.
132
176
  */
133
177
  supported?: string[]
134
178
  /**
135
179
  * RECEIVING side: the `localStorage` key an explicit language choice lives under. Defaults to
136
- * `owlmeans-lng`, the key `@owlmeans/client-i18n` reads before it renders anything. It is a
137
- * functional key: written only while `functional` is granted, removed when it is not.
180
+ * `owlmeans-lng`, the key `@owlmeans/client-i18n` reads before it renders anything.
138
181
  */
139
182
  storageKey?: string
140
183
  }
141
184
 
142
- /** Why the dialog is open. `login` is what the sign-in precondition raises. */
143
- export type ConsentReason = 'initial' | 'reopen' | 'login' | string
185
+ /**
186
+ * Why the consent UI is open. `initial` is the first ask (the bar, in bar mode); `preferences` is the
187
+ * bar's own "Cookie preferences"; `reopen` a footer link or the corner button; `login` what the
188
+ * sign-in precondition raises. Every reason but `initial` opens the preferences window.
189
+ */
190
+ export type ConsentReason = 'initial' | 'reopen' | 'login' | 'preferences' | string
144
191
 
145
192
  export interface ConsentState {
146
193
  record: ConsentRecord | null
147
194
  open: boolean
148
195
  reason: ConsentReason | null
196
+ /** A country lookup is running (`ConsentGeoOptions`); `null` once it settled, or when there is none. */
197
+ locating: ConsentLocating | null
149
198
  }
150
199
 
151
- export type ConsentListener = (state: ConsentState) => void
200
+ export interface ConsentListener { (state: ConsentState): void }
152
201
 
153
202
  export interface ConsentStore {
154
203
  get: () => ConsentState
155
204
  subscribe: (listener: ConsentListener) => () => void
156
- /** Push the defaults, load and migrate any stored record, apply it, and open when there is none. */
205
+ /**
206
+ * Push the defaults, load and migrate any stored record, apply it — and, when there is none,
207
+ * either ask at once or, with `geo` set, locate the visitor first and ask or decide automatically.
208
+ * Synchronous: a lookup runs in the background, `locating` says so, and `settled()` waits for it.
209
+ */
157
210
  init: (opts?: ConsentOptions) => void
211
+ /** Record an explicit decision. Always explicit: an `auto` key on `record` is dropped. */
158
212
  save: (record: ConsentRecord) => void
159
213
  acceptAll: () => void
214
+ /** Open the consent UI from anywhere — a footer link, a policy page, a login gate. */
160
215
  open: (reason?: ConsentReason) => void
161
216
  close: () => void
162
217
  /** Imperative reader, for the callers that are not React. */
163
218
  granted: (key: string) => boolean
219
+ options: () => ConsentOptions & { categories: ConsentCategory[], storageKey: string }
220
+ /** The state once no country lookup is running — at once when none is. */
221
+ settled: () => Promise<ConsentState>
222
+ }
223
+
224
+ /** A language the dialog's built-in copy is carried in (`CONSENT_LOCALES`). */
225
+ export type ConsentLocale = 'en' | 'pl' | 'ru' | 'be' | 'uk' | 'es' | 'de' | 'fr'
226
+
227
+ /** The decoded, not-yet-validated payload a decorated link's parameter carries. */
228
+ export interface ConsentLinkPayload {
229
+ v: typeof CONSENT_LINK_VERSION
230
+ /** One entry per OPTIONAL category this site knows, `1` granted / `0` denied. */
231
+ c: Record<string, 0 | 1>
232
+ /** Unix seconds the link was decorated at. */
233
+ t: number
164
234
  /**
165
- * A language that arrived with a link and is waiting for the visitor to grant `functional`
166
- * storage — memory only, or `null`. It is written (and `CONSENT_LANGUAGE_EVENT` dispatched) the
167
- * moment the grant is saved.
235
+ * The interface language of the page the link was on (a BCP 47 tag, lower-cased) — present only
236
+ * when the sender has `linker.language` set and its `<html lang>` names one. Optional on the
237
+ * wire, so a receiver that predates it reads the same `v: 2` payload and ignores the field.
168
238
  */
169
- pendingLanguage: () => string | null
170
- options: () => ConsentOptions & { categories: ConsentCategory[], storageKey: string }
239
+ l?: string
240
+ }
241
+
242
+ /**
243
+ * An extension seam for `@owlmeans/consent`: something that reacts to this document's consent
244
+ * decision, or supplies one from elsewhere, without the core store knowing anything about where
245
+ * that decision came from.
246
+ *
247
+ * `consentLinker` (`./linker.js`) is the one built-in plugin — cross-domain consent through
248
+ * decorated links — but nothing here is specific to it. A registry rather than a fixed option on
249
+ * `ConsentOptions` because the store has zero dependencies today and stays that way: a plugin is
250
+ * opt-in code an application imports for its own reason, never a bundled feature this package
251
+ * pulls in whether or not anything uses it.
252
+ */
253
+ export interface ConsentPlugin {
254
+ /** Stable identity. Registering the same alias again REPLACES the earlier plugin. */
255
+ alias: string
256
+ /** Run order when more than one plugin implements the same hook. Higher first. Defaults to 0. */
257
+ priority?: number
258
+ /** Installed once, the first time `consentStore.init` runs with this plugin registered. */
259
+ start?: (opts: ConsentOptions) => void
260
+ /**
261
+ * Offer a decision from outside this document's own storage — e.g. one carried on the URL that
262
+ * led here. Returns a full record to adopt, or `null` to defer to the next plugin / the ordinary
263
+ * "ask" path. Never called once a stored record already exists.
264
+ */
265
+ adopt?: (opts: ConsentOptions) => ConsentRecord | null
266
+ /**
267
+ * Offer an interface language from outside this document's own storage — e.g. the one the page
268
+ * that led here was read in. Returns a language the application supports, or `null` to keep
269
+ * choosing its own. Only a CANDIDATE: it says what the link carried, and `writeConsentLanguage`
270
+ * stores it — unconditionally, since the interface language is strictly necessary storage.
271
+ */
272
+ adoptLanguage?: (opts: ConsentOptions) => string | null
273
+ /**
274
+ * Rewrite an outgoing URL to carry this document's current decision — and its language — or
275
+ * `null` to leave it untouched (a foreign host, a link that opts out, nothing to carry yet).
276
+ * `record` is `null` while the visitor has not decided: a plugin that also carries something
277
+ * else (the language) still has a reason to decorate then.
278
+ */
279
+ decorate?: (url: URL, record: ConsentRecord | null, opts: ConsentOptions) => URL | null
280
+ /** Extra domains this decision is understood to apply to, for disclosure — never deduplicated here. */
281
+ domains?: (opts: ConsentOptions) => string[]
282
+ /**
283
+ * Find the visitor's country, or throw when it cannot tell. Asked only while `opts.geo` is set
284
+ * and nothing is stored; locators run highest priority first and the first usable country wins,
285
+ * so a throw (or an unusable code) hands over to the next one — the built-in Cloudflare locator
286
+ * sits at the very bottom.
287
+ */
288
+ locate?: (opts: ConsentOptions) => Promise<ConsentGeoLocation>
289
+ }
290
+
291
+ /**
292
+ * The country-detection plugin: one async method that resolves the visitor's country or throws.
293
+ * Register it with `consentPluginHelper.registerConsentPlugin` — or, in an OwlMeans application,
294
+ * with `@owlmeans/web-panel/consent`'s `appendConsentGeoPlugin(context, plugin)` while the context is
295
+ * configured.
296
+ */
297
+ export interface ConsentGeoPlugin extends ConsentPlugin {
298
+ locate: (opts: ConsentOptions) => Promise<ConsentGeoLocation>
171
299
  }