@unitninety/site-kit 1.6.4 → 1.6.6
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 +104 -1
- package/package.json +4 -3
- package/src/bridge/DraftBlocks.tsx +10 -0
- package/src/bridge/createBridge.ts +11 -10
- package/src/bridge/index.ts +1 -0
- package/src/bridge/nativeDraft.ts +22 -4
- package/src/bridge/nativeOrder.ts +68 -0
- package/src/bridge/registry.ts +3 -2
- package/src/bridge/selection.ts +59 -10
- package/src/bridge/useDraftContent.tsx +35 -22
- package/src/components/BlockLink.tsx +28 -0
- package/src/components/Link.tsx +8 -1
- package/src/components/block-navigation.ts +24 -0
- package/src/custom-code/fragments.ts +64 -0
- package/src/custom-code/index.ts +30 -0
- package/src/elements/blockAnchor.ts +7 -0
- package/src/elements/declaration.ts +1 -1
- package/src/elements/define.ts +1 -1
- package/src/elements/index.ts +1 -0
- package/src/elements/ops/validate.ts +9 -2
- package/src/features/client/README.md +1 -2
- package/src/features/client/SiteFeatures.tsx +15 -25
- package/src/features/client/index.ts +0 -6
- package/src/features/client/verification.ts +3 -0
- package/src/features/contracts/base.ts +1 -5
- package/src/features/contracts/index.ts +0 -20
- package/src/features/contracts/verificationConfig.ts +7 -0
- package/src/features/server/gateway.ts +1 -3
- package/src/features/server/index.ts +0 -2
- package/src/legacy/cli.mjs +2 -2
- package/src/legacy/content.ts +1 -1
- package/src/server/BlockAnchors.tsx +92 -0
- package/src/server/CustomCode.tsx +29 -0
- package/src/server/index.ts +1 -0
- package/src/server/read.ts +13 -0
- package/src/server/render.tsx +11 -5
- package/src/site/defineSite.ts +6 -3
- package/src/site/documents.ts +2 -2
- package/src/site/forms.ts +32 -0
- package/src/site/index.ts +2 -0
- package/src/site/types.ts +4 -1
- package/src/site/version.ts +1 -1
- package/src/site/wire.ts +3 -0
- package/src/features/client/ConsentRuntime.tsx +0 -95
- package/src/features/client/useConsent.ts +0 -15
- package/src/features/consent/README.md +0 -70
- package/src/features/consent/cookieScript.ts +0 -53
- package/src/features/consent/custom.ts +0 -89
- package/src/features/consent/defaults.ts +0 -40
- package/src/features/consent/drivers.ts +0 -233
- package/src/features/consent/loader.ts +0 -248
- package/src/features/consent/registry.ts +0 -398
- package/src/features/contracts/consent.ts +0 -150
- package/src/features/server/ConsentDefaults.tsx +0 -15
|
@@ -1,398 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
classifyManagedIntegration,
|
|
3
|
-
ConsentInstallationSchema,
|
|
4
|
-
ConsentStateSchema,
|
|
5
|
-
deniedConsentState,
|
|
6
|
-
type ConsentAdapter,
|
|
7
|
-
type ConsentInstallation,
|
|
8
|
-
type ConsentState,
|
|
9
|
-
} from '../contracts/consent'
|
|
10
|
-
import { cookieScriptAdapter } from './cookieScript'
|
|
11
|
-
import { customConsentAdapter, type CustomConsentRegistration } from './custom'
|
|
12
|
-
import { createBrowserConsentDriver, CONSENT_LOAD_TIMEOUT_MS } from './drivers'
|
|
13
|
-
import {
|
|
14
|
-
consentLoaderStatus,
|
|
15
|
-
createConsentLoader,
|
|
16
|
-
observeConsentLoader,
|
|
17
|
-
requireConsentReload,
|
|
18
|
-
} from './loader'
|
|
19
|
-
|
|
20
|
-
type Snapshot = {
|
|
21
|
-
state: ConsentState
|
|
22
|
-
ready: boolean
|
|
23
|
-
error: string | null
|
|
24
|
-
reloadRequired: boolean
|
|
25
|
-
}
|
|
26
|
-
type Identity = {
|
|
27
|
-
endpoint: string
|
|
28
|
-
provider: string
|
|
29
|
-
source: string
|
|
30
|
-
adapter?: ConsentAdapter
|
|
31
|
-
owner?: CustomConsentRegistration
|
|
32
|
-
reopen?: () => void
|
|
33
|
-
}
|
|
34
|
-
const registries = new WeakMap<Document, ReturnType<typeof createRegistry>>()
|
|
35
|
-
export function consentRegistry(window: Window) {
|
|
36
|
-
let registry = registries.get(window.document)
|
|
37
|
-
if (!registry) {
|
|
38
|
-
registry = createRegistry(window)
|
|
39
|
-
registries.set(window.document, registry)
|
|
40
|
-
}
|
|
41
|
-
return registry
|
|
42
|
-
}
|
|
43
|
-
function identity(
|
|
44
|
-
endpoint: string,
|
|
45
|
-
installation: ConsentInstallation,
|
|
46
|
-
registration?: CustomConsentRegistration,
|
|
47
|
-
): Identity {
|
|
48
|
-
return {
|
|
49
|
-
endpoint,
|
|
50
|
-
provider: installation.provider,
|
|
51
|
-
source: installation.scriptURL ?? '',
|
|
52
|
-
...(installation.provider === 'custom'
|
|
53
|
-
? registration?.adapter
|
|
54
|
-
? { adapter: registration.adapter }
|
|
55
|
-
: { owner: registration, reopen: registration?.openPreferences }
|
|
56
|
-
: {}),
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
function same(a: Identity, b: Identity) {
|
|
60
|
-
return (
|
|
61
|
-
a.endpoint === b.endpoint &&
|
|
62
|
-
a.provider === b.provider &&
|
|
63
|
-
a.source === b.source &&
|
|
64
|
-
a.adapter === b.adapter &&
|
|
65
|
-
a.owner === b.owner &&
|
|
66
|
-
a.reopen === b.reopen
|
|
67
|
-
)
|
|
68
|
-
}
|
|
69
|
-
function validRegistration(registration?: CustomConsentRegistration) {
|
|
70
|
-
if (!registration) return false
|
|
71
|
-
if (registration.adapter !== undefined)
|
|
72
|
-
return (
|
|
73
|
-
registration.openPreferences === undefined &&
|
|
74
|
-
!!registration.adapter &&
|
|
75
|
-
['read', 'subscribe', 'openPreferences'].every(
|
|
76
|
-
(key) => typeof registration.adapter?.[key as keyof ConsentAdapter] === 'function',
|
|
77
|
-
)
|
|
78
|
-
)
|
|
79
|
-
return typeof registration.openPreferences === 'function'
|
|
80
|
-
}
|
|
81
|
-
function createRegistry(window: Window) {
|
|
82
|
-
const driver = createBrowserConsentDriver(window)
|
|
83
|
-
const loader = createConsentLoader(driver)
|
|
84
|
-
const listeners = new Set<() => void>()
|
|
85
|
-
const owners = new Set<object>()
|
|
86
|
-
let endpoint: string | undefined
|
|
87
|
-
let enabled = false
|
|
88
|
-
let installation: ConsentInstallation | undefined
|
|
89
|
-
let registration: CustomConsentRegistration | undefined
|
|
90
|
-
let installed: Identity | undefined
|
|
91
|
-
let adapter: ConsentAdapter | undefined
|
|
92
|
-
let unsubscribe: (() => void) | undefined
|
|
93
|
-
let generation = 0
|
|
94
|
-
let activated = false
|
|
95
|
-
let attempted = false
|
|
96
|
-
let script: HTMLScriptElement | undefined
|
|
97
|
-
let providerFailed = false
|
|
98
|
-
let providerError: string | null = null
|
|
99
|
-
let timer: number | undefined
|
|
100
|
-
let loadTimer: number | undefined
|
|
101
|
-
let raw = deniedConsentState()
|
|
102
|
-
let snapshot: Snapshot = { state: raw, ready: false, error: null, reloadRequired: false }
|
|
103
|
-
const clearTimer = () => {
|
|
104
|
-
if (timer !== undefined) window.clearTimeout(timer)
|
|
105
|
-
timer = undefined
|
|
106
|
-
}
|
|
107
|
-
// Embedding a banner does not make it a consent authority for U90-managed scripts.
|
|
108
|
-
const embedOnly = () => installation?.provider === 'custom' && registration === undefined
|
|
109
|
-
const registrationComplete = () =>
|
|
110
|
-
installation?.provider !== 'custom' || validRegistration(registration)
|
|
111
|
-
const effective = () =>
|
|
112
|
-
enabled && owners.size === 1 && !!unsubscribe && !providerFailed && registrationComplete()
|
|
113
|
-
? raw
|
|
114
|
-
: deniedConsentState()
|
|
115
|
-
const publish = () => {
|
|
116
|
-
const state = effective()
|
|
117
|
-
const status = consentLoaderStatus(driver)
|
|
118
|
-
const next = {
|
|
119
|
-
state: { ...state },
|
|
120
|
-
ready: state.ready,
|
|
121
|
-
error: status.reloadRequired
|
|
122
|
-
? status.error
|
|
123
|
-
: !registrationComplete() && !embedOnly() && enabled
|
|
124
|
-
? 'Custom consent registration is incomplete. Supply one adapter or a preferences callback.'
|
|
125
|
-
: (providerError ?? status.error),
|
|
126
|
-
reloadRequired: status.reloadRequired,
|
|
127
|
-
}
|
|
128
|
-
if (JSON.stringify(next) !== JSON.stringify(snapshot)) {
|
|
129
|
-
snapshot = next
|
|
130
|
-
for (const listener of listeners) listener()
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
observeConsentLoader(driver, () => {
|
|
134
|
-
if (consentLoaderStatus(driver).reloadRequired)
|
|
135
|
-
driver.setPermission(deniedConsentState(), installation?.googleMode ?? 'basic')
|
|
136
|
-
publish()
|
|
137
|
-
})
|
|
138
|
-
const reconcile = () => {
|
|
139
|
-
const state = effective()
|
|
140
|
-
const mode = installation?.googleMode ?? 'basic'
|
|
141
|
-
const blocked = consentLoaderStatus(driver).reloadRequired
|
|
142
|
-
driver.configure(installation?.integrations ?? [])
|
|
143
|
-
driver.setPermission(blocked ? deniedConsentState() : state, mode)
|
|
144
|
-
// Advanced mode is a request exception only for classified Google code. Generic rows stay category-gated.
|
|
145
|
-
const advanced = !blocked && state.ready && mode === 'advanced'
|
|
146
|
-
const integrations = installation?.integrations ?? []
|
|
147
|
-
const selected = advanced
|
|
148
|
-
? integrations.filter(
|
|
149
|
-
(row) => state[row.category] || classifyManagedIntegration(row).kind === 'ga4',
|
|
150
|
-
)
|
|
151
|
-
: integrations
|
|
152
|
-
void loader.apply(advanced ? { ...state, analytics: true } : state, selected).catch(() => {
|
|
153
|
-
publish()
|
|
154
|
-
})
|
|
155
|
-
publish()
|
|
156
|
-
}
|
|
157
|
-
const receive = (value: unknown) => {
|
|
158
|
-
const parsed = ConsentStateSchema.safeParse(value)
|
|
159
|
-
raw = parsed.success ? parsed.data : deniedConsentState()
|
|
160
|
-
providerError = raw.ready ? null : 'Consent preferences are unavailable. Retry consent setup.'
|
|
161
|
-
if (raw.ready) clearTimer()
|
|
162
|
-
reconcile()
|
|
163
|
-
}
|
|
164
|
-
const readAdapter = () => {
|
|
165
|
-
if (!adapter) {
|
|
166
|
-
reconcile()
|
|
167
|
-
return
|
|
168
|
-
}
|
|
169
|
-
try {
|
|
170
|
-
receive(adapter?.read())
|
|
171
|
-
} catch {
|
|
172
|
-
receive(undefined)
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
const disconnect = () => {
|
|
176
|
-
++generation
|
|
177
|
-
try {
|
|
178
|
-
unsubscribe?.()
|
|
179
|
-
} catch {
|
|
180
|
-
/* authority is already invalid */
|
|
181
|
-
}
|
|
182
|
-
unsubscribe = undefined
|
|
183
|
-
clearTimer()
|
|
184
|
-
raw = deniedConsentState()
|
|
185
|
-
}
|
|
186
|
-
const subscribe = () => {
|
|
187
|
-
if (!adapter || unsubscribe) return
|
|
188
|
-
const current = ++generation
|
|
189
|
-
try {
|
|
190
|
-
unsubscribe = adapter.subscribe((value) => {
|
|
191
|
-
if (current === generation && owners.size === 1 && !providerFailed) receive(value)
|
|
192
|
-
})
|
|
193
|
-
if (typeof unsubscribe !== 'function') throw Error('Subscription unavailable')
|
|
194
|
-
activated = true
|
|
195
|
-
readAdapter()
|
|
196
|
-
} catch {
|
|
197
|
-
providerFailed = true
|
|
198
|
-
providerError = 'Consent subscription unavailable. Retry consent setup.'
|
|
199
|
-
reconcile()
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
const watchReadiness = () => {
|
|
203
|
-
clearTimer()
|
|
204
|
-
if (!raw.ready)
|
|
205
|
-
timer = window.setTimeout(() => {
|
|
206
|
-
raw = deniedConsentState()
|
|
207
|
-
providerError = 'Consent provider did not become ready. Retry consent setup.'
|
|
208
|
-
reconcile()
|
|
209
|
-
}, CONSENT_LOAD_TIMEOUT_MS)
|
|
210
|
-
}
|
|
211
|
-
const install = () => {
|
|
212
|
-
if (
|
|
213
|
-
!installation ||
|
|
214
|
-
!enabled ||
|
|
215
|
-
providerFailed ||
|
|
216
|
-
(!registrationComplete() && !embedOnly()) ||
|
|
217
|
-
owners.size !== 1 ||
|
|
218
|
-
consentLoaderStatus(driver).reloadRequired
|
|
219
|
-
)
|
|
220
|
-
return
|
|
221
|
-
if (!adapter && !embedOnly()) {
|
|
222
|
-
if (installation.provider === 'cookiescript') {
|
|
223
|
-
if ((window as Window & { CookieScript?: unknown }).CookieScript !== undefined) {
|
|
224
|
-
providerFailed = true
|
|
225
|
-
providerError =
|
|
226
|
-
'CookieScript ownership conflicts with an already installed provider. Correct setup and reload.'
|
|
227
|
-
reconcile()
|
|
228
|
-
return
|
|
229
|
-
}
|
|
230
|
-
adapter = cookieScriptAdapter(window)
|
|
231
|
-
} else {
|
|
232
|
-
if (!validRegistration(registration)) {
|
|
233
|
-
providerError =
|
|
234
|
-
'Custom consent registration is incomplete. Supply one adapter or a preferences callback.'
|
|
235
|
-
reconcile()
|
|
236
|
-
return
|
|
237
|
-
}
|
|
238
|
-
adapter = customConsentAdapter(window, registration)
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
const wasSubscribed = !!unsubscribe
|
|
242
|
-
subscribe() // subscription and immediate read precede insertion
|
|
243
|
-
if (providerFailed) return
|
|
244
|
-
if (!attempted) {
|
|
245
|
-
script = window.document.createElement('script')
|
|
246
|
-
script.async = true
|
|
247
|
-
script.src = installation.scriptURL!
|
|
248
|
-
script.dataset.u90ConsentProvider = installation.provider
|
|
249
|
-
const ownedScript = script
|
|
250
|
-
loadTimer = window.setTimeout(() => {
|
|
251
|
-
if (script !== ownedScript) return
|
|
252
|
-
providerFailed = true
|
|
253
|
-
raw = deniedConsentState()
|
|
254
|
-
providerError = 'Consent provider load timed out. Reload after checking setup.'
|
|
255
|
-
requireConsentReload(driver, providerError)
|
|
256
|
-
reconcile()
|
|
257
|
-
}, CONSENT_LOAD_TIMEOUT_MS)
|
|
258
|
-
script.onload = () => {
|
|
259
|
-
if (script !== ownedScript) return
|
|
260
|
-
window.clearTimeout(loadTimer)
|
|
261
|
-
loadTimer = undefined
|
|
262
|
-
if (!providerFailed && adapter) {
|
|
263
|
-
readAdapter()
|
|
264
|
-
watchReadiness()
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
script.onerror = () => {
|
|
268
|
-
if (script !== ownedScript) return
|
|
269
|
-
window.clearTimeout(loadTimer)
|
|
270
|
-
loadTimer = undefined
|
|
271
|
-
providerFailed = true
|
|
272
|
-
providerError = 'Consent provider failed to load. Retry consent setup.'
|
|
273
|
-
raw = deniedConsentState()
|
|
274
|
-
script?.remove()
|
|
275
|
-
script = undefined
|
|
276
|
-
clearTimer()
|
|
277
|
-
reconcile()
|
|
278
|
-
}
|
|
279
|
-
attempted = true
|
|
280
|
-
window.document.head.appendChild(script)
|
|
281
|
-
}
|
|
282
|
-
if (adapter && !wasSubscribed) watchReadiness()
|
|
283
|
-
}
|
|
284
|
-
return {
|
|
285
|
-
read: () => snapshot,
|
|
286
|
-
subscribe(listener: () => void) {
|
|
287
|
-
listeners.add(listener)
|
|
288
|
-
return () => {
|
|
289
|
-
listeners.delete(listener)
|
|
290
|
-
}
|
|
291
|
-
},
|
|
292
|
-
attach(owner: object) {
|
|
293
|
-
owners.add(owner)
|
|
294
|
-
driver.suspend(true)
|
|
295
|
-
},
|
|
296
|
-
detach(owner: object) {
|
|
297
|
-
owners.delete(owner)
|
|
298
|
-
driver.suspend(true)
|
|
299
|
-
// StrictMode's same-turn replay retains installation; a lasting owner loss revokes.
|
|
300
|
-
queueMicrotask(() => {
|
|
301
|
-
if (owners.size === 0) {
|
|
302
|
-
disconnect()
|
|
303
|
-
reconcile()
|
|
304
|
-
}
|
|
305
|
-
})
|
|
306
|
-
},
|
|
307
|
-
commit(
|
|
308
|
-
owner: object,
|
|
309
|
-
nextEndpoint: string,
|
|
310
|
-
nextEnabled: boolean,
|
|
311
|
-
input?: ConsentInstallation,
|
|
312
|
-
custom?: CustomConsentRegistration,
|
|
313
|
-
) {
|
|
314
|
-
if (!owners.has(owner)) return
|
|
315
|
-
const parsed = input === undefined ? null : ConsentInstallationSchema.safeParse(input)
|
|
316
|
-
const next = parsed?.success ? parsed.data : undefined
|
|
317
|
-
if (endpoint !== undefined && endpoint !== nextEndpoint) {
|
|
318
|
-
disconnect()
|
|
319
|
-
enabled = false
|
|
320
|
-
reconcile()
|
|
321
|
-
if (activated || attempted || driver.attempted())
|
|
322
|
-
requireConsentReload(
|
|
323
|
-
driver,
|
|
324
|
-
'Reload to apply consent changes. The site consent authority changed.',
|
|
325
|
-
)
|
|
326
|
-
else {
|
|
327
|
-
adapter = undefined
|
|
328
|
-
installed = undefined
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
endpoint = nextEndpoint
|
|
332
|
-
if (next && installation?.googleMode !== next.googleMode && driver.googleAttempted())
|
|
333
|
-
requireConsentReload(
|
|
334
|
-
driver,
|
|
335
|
-
'Reload to apply consent changes. Google consent mode changed.',
|
|
336
|
-
)
|
|
337
|
-
installation = next
|
|
338
|
-
registration = custom
|
|
339
|
-
enabled = !!nextEnabled && !!next && next.provider !== 'none'
|
|
340
|
-
driver.suspend(owners.size !== 1)
|
|
341
|
-
if (enabled) {
|
|
342
|
-
const nextIdentity = identity(nextEndpoint, next!, custom)
|
|
343
|
-
if (installed && !same(installed, nextIdentity)) {
|
|
344
|
-
disconnect()
|
|
345
|
-
providerFailed = true
|
|
346
|
-
if (activated || attempted)
|
|
347
|
-
requireConsentReload(
|
|
348
|
-
driver,
|
|
349
|
-
'Reload to apply consent changes. The consent provider or registration changed.',
|
|
350
|
-
)
|
|
351
|
-
else {
|
|
352
|
-
adapter = undefined
|
|
353
|
-
installed = undefined
|
|
354
|
-
providerFailed = false
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
if (!installed) installed = nextIdentity
|
|
358
|
-
if (!consentLoaderStatus(driver).reloadRequired) install()
|
|
359
|
-
}
|
|
360
|
-
if (!enabled) {
|
|
361
|
-
raw = deniedConsentState()
|
|
362
|
-
providerError = parsed && !parsed.success ? 'Consent installation is invalid.' : null
|
|
363
|
-
}
|
|
364
|
-
driver.suspend(owners.size !== 1)
|
|
365
|
-
if (enabled && !providerFailed && unsubscribe) readAdapter()
|
|
366
|
-
else reconcile()
|
|
367
|
-
},
|
|
368
|
-
page(path: string) {
|
|
369
|
-
driver.page(path)
|
|
370
|
-
},
|
|
371
|
-
openPreferences() {
|
|
372
|
-
// Existing CMP controls remain necessary UI even after disable or reload latch.
|
|
373
|
-
try {
|
|
374
|
-
if (!adapter || !ConsentStateSchema.parse(adapter.read()).ready || providerFailed)
|
|
375
|
-
throw Error('unavailable')
|
|
376
|
-
adapter.openPreferences()
|
|
377
|
-
readAdapter()
|
|
378
|
-
} catch {
|
|
379
|
-
raw = deniedConsentState()
|
|
380
|
-
providerError = 'Cookie preferences are unavailable. Retry consent setup.'
|
|
381
|
-
reconcile()
|
|
382
|
-
}
|
|
383
|
-
},
|
|
384
|
-
retry() {
|
|
385
|
-
if (consentLoaderStatus(driver).reloadRequired) return
|
|
386
|
-
if (enabled && (providerFailed || !raw.ready)) {
|
|
387
|
-
// A wrapper may have caught subscribe() failure while still returning cleanup.
|
|
388
|
-
// Retry that same authority from a fresh subscription, retaining its installation.
|
|
389
|
-
disconnect()
|
|
390
|
-
}
|
|
391
|
-
if (providerFailed && attempted && !script) attempted = false
|
|
392
|
-
providerFailed = false
|
|
393
|
-
providerError = null
|
|
394
|
-
install()
|
|
395
|
-
readAdapter()
|
|
396
|
-
},
|
|
397
|
-
}
|
|
398
|
-
}
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod'
|
|
2
|
-
|
|
3
|
-
export type ConsentState = {
|
|
4
|
-
version: 1
|
|
5
|
-
necessary: true
|
|
6
|
-
preferences: boolean
|
|
7
|
-
analytics: boolean
|
|
8
|
-
marketing: boolean
|
|
9
|
-
ready: boolean
|
|
10
|
-
}
|
|
11
|
-
export type ConsentAdapter = {
|
|
12
|
-
read(): ConsentState
|
|
13
|
-
subscribe(listener: (state: ConsentState) => void): () => void
|
|
14
|
-
openPreferences(): void
|
|
15
|
-
}
|
|
16
|
-
export type ConsentCategory = 'preferences' | 'analytics' | 'marketing'
|
|
17
|
-
export type ManagedIntegration = {
|
|
18
|
-
id: string
|
|
19
|
-
src: string
|
|
20
|
-
category: ConsentCategory
|
|
21
|
-
attributes?: Record<string, string>
|
|
22
|
-
revocation: 'stop' | 'reload'
|
|
23
|
-
}
|
|
24
|
-
export type ConsentInstallation = {
|
|
25
|
-
provider: 'none' | 'cookiescript' | 'custom'
|
|
26
|
-
scriptURL?: string
|
|
27
|
-
integrations: ManagedIntegration[]
|
|
28
|
-
googleMode: 'basic' | 'advanced'
|
|
29
|
-
}
|
|
30
|
-
export type IntegrationDriver = {
|
|
31
|
-
start(integration: ManagedIntegration): Promise<void>
|
|
32
|
-
stop(id: string): Promise<'stopped' | 'reload-required'>
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Always allocate a new snapshot so consumers cannot mutate shared denial. */
|
|
36
|
-
export function deniedConsentState(): ConsentState {
|
|
37
|
-
return { version: 1, necessary: true, preferences: false, analytics: false, marketing: false, ready: false }
|
|
38
|
-
}
|
|
39
|
-
export const ConsentStateSchema = z.strictObject({
|
|
40
|
-
version: z.literal(1), necessary: z.literal(true),
|
|
41
|
-
preferences: z.boolean(), analytics: z.boolean(), marketing: z.boolean(), ready: z.boolean(),
|
|
42
|
-
}).transform((state): ConsentState => state.ready ? state : deniedConsentState())
|
|
43
|
-
|
|
44
|
-
/** URL parsing must not silently discard raw controls, fragments, or backslashes. */
|
|
45
|
-
const HTTPSURLSchema = z.string().max(2048).refine((source) => {
|
|
46
|
-
if (/[\s\\#]/.test(source)) return false
|
|
47
|
-
try {
|
|
48
|
-
const url = new URL(source)
|
|
49
|
-
return url.protocol === 'https:' && !!url.hostname && !url.username && !url.password && !source.includes('@')
|
|
50
|
-
} catch { return false }
|
|
51
|
-
}, 'Expected an HTTPS URL without userinfo, fragments, whitespace or backslashes.')
|
|
52
|
-
.transform((source) => new URL(source).href)
|
|
53
|
-
|
|
54
|
-
export function isCookieScriptURL(source: string): boolean {
|
|
55
|
-
const parsed = HTTPSURLSchema.safeParse(source)
|
|
56
|
-
return parsed.success && /^https:\/\/(?:cdn|dev-cdn)\.cookie-script\.com\/s\/[a-fA-F0-9]{32}\.js$/.test(parsed.data)
|
|
57
|
-
}
|
|
58
|
-
const ElementIdSchema = z.string().min(1).max(128).regex(/^[A-Za-z][A-Za-z0-9_:.-]*$/)
|
|
59
|
-
// Only standard attributes are shipped. No data-* key is approved for this release.
|
|
60
|
-
const AttributesSchema = z.strictObject({
|
|
61
|
-
id: ElementIdSchema.optional(),
|
|
62
|
-
charset: z.string().max(40).regex(/^[A-Za-z0-9._-]+$/).transform((value) => value.toLowerCase()).optional(),
|
|
63
|
-
crossorigin: z.enum(['anonymous', 'use-credentials']).optional(),
|
|
64
|
-
integrity: z.string().max(1024).refine((value) => value.split(' ').every((token) =>
|
|
65
|
-
/^sha256-[A-Za-z0-9+/]{43}=$/.test(token) ||
|
|
66
|
-
/^sha384-[A-Za-z0-9+/]{64}$/.test(token) ||
|
|
67
|
-
/^sha512-[A-Za-z0-9+/]{86}==$/.test(token)), 'Expected SHA-256, SHA-384 or SHA-512 SRI digests.').optional(),
|
|
68
|
-
})
|
|
69
|
-
const IntegrationFields = z.strictObject({
|
|
70
|
-
id: z.string().trim().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9_.:-]*$/),
|
|
71
|
-
src: HTTPSURLSchema,
|
|
72
|
-
category: z.enum(['preferences', 'analytics', 'marketing']),
|
|
73
|
-
attributes: AttributesSchema.optional(),
|
|
74
|
-
revocation: z.enum(['stop', 'reload']),
|
|
75
|
-
})
|
|
76
|
-
export const ManagedIntegrationSchema = IntegrationFields
|
|
77
|
-
const InstallationFields = z.strictObject({
|
|
78
|
-
provider: z.enum(['none', 'cookiescript', 'custom']),
|
|
79
|
-
scriptURL: HTTPSURLSchema.optional(),
|
|
80
|
-
integrations: z.array(ManagedIntegrationSchema).max(100).default([]),
|
|
81
|
-
googleMode: z.enum(['basic', 'advanced']).default('basic'),
|
|
82
|
-
})
|
|
83
|
-
function validateInstallation(value: ConsentInstallation, context: z.RefinementCtx) {
|
|
84
|
-
if (value.provider !== 'none' && !value.scriptURL) {
|
|
85
|
-
context.addIssue({ code: 'custom', path: ['scriptURL'], message: 'An active provider requires an installation URL.' })
|
|
86
|
-
} else if (value.provider === 'none' && value.scriptURL) {
|
|
87
|
-
context.addIssue({ code: 'custom', path: ['scriptURL'], message: 'No provider cannot install a script.' })
|
|
88
|
-
} else if (value.provider === 'cookiescript' && !isCookieScriptURL(value.scriptURL!)) {
|
|
89
|
-
context.addIssue({ code: 'custom', path: ['scriptURL'], message: 'Use a direct global CookieScript production or staging installation; geographic wrappers are unsupported.' })
|
|
90
|
-
}
|
|
91
|
-
const ids = new Set<string>()
|
|
92
|
-
value.integrations.forEach((integration, index) => {
|
|
93
|
-
if (ids.has(integration.id)) context.addIssue({ code: 'custom', path: ['integrations', index, 'id'], message: 'Integration IDs must be unique within the site.' })
|
|
94
|
-
ids.add(integration.id)
|
|
95
|
-
})
|
|
96
|
-
}
|
|
97
|
-
export const ConsentInstallationSchema = InstallationFields.superRefine(validateInstallation)
|
|
98
|
-
/** Readers tolerate additive fields but retain only known validated installation data. */
|
|
99
|
-
export const PublicConsentInstallationSchema = InstallationFields.loose().extend({
|
|
100
|
-
integrations: z.array(IntegrationFields.loose().transform((row) => ManagedIntegrationSchema.parse({
|
|
101
|
-
id: row.id, src: row.src, category: row.category, revocation: row.revocation,
|
|
102
|
-
...(row.attributes ? { attributes: row.attributes } : {}),
|
|
103
|
-
}))).max(100).default([]),
|
|
104
|
-
}).superRefine(validateInstallation).transform((row) => ConsentInstallationSchema.parse({
|
|
105
|
-
provider: row.provider, ...(row.scriptURL ? { scriptURL: row.scriptURL } : {}),
|
|
106
|
-
integrations: row.integrations, googleMode: row.googleMode,
|
|
107
|
-
}))
|
|
108
|
-
|
|
109
|
-
export type ManagedIntegrationClassification =
|
|
110
|
-
| { kind: 'ga4'; measurementId: string }
|
|
111
|
-
| { kind: 'contentsquare'; tagId: string }
|
|
112
|
-
| { kind: 'custom' }
|
|
113
|
-
/** Shared by permissions and drivers. Caller IDs and known hosts confer no authority. */
|
|
114
|
-
export function classifyManagedIntegration(input: unknown): ManagedIntegrationClassification {
|
|
115
|
-
const parsed = ManagedIntegrationSchema.safeParse(input)
|
|
116
|
-
if (!parsed.success) return { kind: 'custom' }
|
|
117
|
-
const row = parsed.data
|
|
118
|
-
if (row.category !== 'analytics' ||
|
|
119
|
-
(row.attributes?.charset !== undefined && row.attributes.charset !== 'utf-8') ||
|
|
120
|
-
(row.attributes?.crossorigin !== undefined && row.attributes.crossorigin !== 'anonymous')) return { kind: 'custom' }
|
|
121
|
-
// The 1–32 suffix bound is a U90 supported-input limit, not vendor certification.
|
|
122
|
-
const ga = /^https:\/\/www\.googletagmanager\.com\/gtag\/js\?id=(G-[A-Z0-9]{1,32})$/.exec(row.src)
|
|
123
|
-
if (ga) return { kind: 'ga4', measurementId: ga[1] }
|
|
124
|
-
const cs = /^https:\/\/t\.contentsquare\.net\/uxa\/([a-f0-9]{13})\.js$/.exec(row.src)
|
|
125
|
-
if (cs && row.revocation === 'reload') return { kind: 'contentsquare', tagId: cs[1] }
|
|
126
|
-
return { kind: 'custom' }
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function customConfiguration(installation?: ConsentInstallation): string {
|
|
130
|
-
if (!installation) return JSON.stringify({ provider: null, integrations: [] })
|
|
131
|
-
const normalized = ConsentInstallationSchema.parse(installation)
|
|
132
|
-
return JSON.stringify({
|
|
133
|
-
provider: normalized.provider === 'custom' ? normalized.scriptURL : null,
|
|
134
|
-
integrations: normalized.integrations.filter((row) => classifyManagedIntegration(row).kind === 'custom')
|
|
135
|
-
.sort((a, b) => a.id.localeCompare(b.id)).map((row) => ({
|
|
136
|
-
id: row.id, src: row.src, category: row.category, revocation: row.revocation,
|
|
137
|
-
attributes: Object.fromEntries(Object.entries(row.attributes ?? {}).sort(([a], [b]) => a.localeCompare(b))),
|
|
138
|
-
})),
|
|
139
|
-
})
|
|
140
|
-
}
|
|
141
|
-
export type ConsentConfiguration = { enabled: boolean; installation?: ConsentInstallation }
|
|
142
|
-
/** The protected writer and all feature toggles must use this same before/after check. */
|
|
143
|
-
export function consentChangeRequiresCodeWrite(before: ConsentConfiguration, after: ConsentConfiguration): boolean {
|
|
144
|
-
const oldCustom = customConfiguration(before.installation)
|
|
145
|
-
const newCustom = customConfiguration(after.installation)
|
|
146
|
-
const wasActive = before.enabled && !!before.installation && before.installation.provider !== 'none'
|
|
147
|
-
const willBeActive = after.enabled && !!after.installation && after.installation.provider !== 'none'
|
|
148
|
-
const activates = (!before.enabled && after.enabled) || (!wasActive && willBeActive)
|
|
149
|
-
return oldCustom !== newCustom || (activates && newCustom !== customConfiguration())
|
|
150
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import Script from 'next/script'
|
|
2
|
-
import { CONSENT_DEFAULTS_ID, CONSENT_DEFAULTS_SCRIPT } from '../consent/defaults'
|
|
3
|
-
/** Mount once in the consuming server root layout, before SiteFeatures. */
|
|
4
|
-
export function ConsentDefaults() {
|
|
5
|
-
return (
|
|
6
|
-
// App Router consumers mount this wrapper in their root layout. This rule
|
|
7
|
-
// uses the Pages Router _document filename heuristic for reusable packages.
|
|
8
|
-
// eslint-disable-next-line @next/next/no-before-interactive-script-outside-document
|
|
9
|
-
<Script
|
|
10
|
-
id={CONSENT_DEFAULTS_ID}
|
|
11
|
-
strategy="beforeInteractive"
|
|
12
|
-
dangerouslySetInnerHTML={{ __html: CONSENT_DEFAULTS_SCRIPT }}
|
|
13
|
-
/>
|
|
14
|
-
)
|
|
15
|
-
}
|