@unitninety/site-kit 1.6.5 → 1.7.0
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 +68 -4
- package/package.json +5 -4
- package/src/bridge/DraftBlocks.tsx +10 -0
- package/src/bridge/U90Bridge.tsx +4 -2
- package/src/bridge/caret.ts +7 -8
- package/src/bridge/createBridge.ts +30 -10
- package/src/bridge/diagnostics.ts +61 -0
- package/src/bridge/index.ts +3 -0
- package/src/bridge/nativeDraft.ts +47 -36
- package/src/bridge/nativeOrder.ts +72 -0
- package/src/bridge/nativePath.ts +45 -0
- package/src/bridge/registry.ts +3 -2
- package/src/bridge/selection.ts +59 -10
- package/src/bridge/styles.ts +12 -0
- package/src/bridge/useDraftContent.tsx +68 -29
- package/src/components/BlockLink.tsx +28 -0
- package/src/components/Image.tsx +8 -4
- package/src/components/Link.tsx +8 -1
- package/src/components/block-navigation.ts +24 -0
- package/src/components/draft.ts +19 -0
- package/src/components/index.ts +1 -1
- 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/index.ts +1 -0
- package/src/elements/ops/draftPaths.ts +23 -0
- package/src/elements/ops/expand.ts +8 -6
- 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 +12 -6
- package/src/site/fields.ts +2 -2
- package/src/site/types.ts +4 -2
- 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,95 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
import {
|
|
3
|
-
Suspense,
|
|
4
|
-
useCallback,
|
|
5
|
-
useLayoutEffect,
|
|
6
|
-
useMemo,
|
|
7
|
-
useRef,
|
|
8
|
-
useState,
|
|
9
|
-
type ReactNode,
|
|
10
|
-
} from 'react'
|
|
11
|
-
import type { PublicFeatureBase } from '../contracts'
|
|
12
|
-
import { deniedConsentState } from '../contracts/consent'
|
|
13
|
-
import type { CustomConsentRegistration } from '../consent/custom'
|
|
14
|
-
import { consentRegistry } from '../consent/registry'
|
|
15
|
-
import { ConsentContext } from './useConsent'
|
|
16
|
-
import { PageReporter } from './PageReporter'
|
|
17
|
-
const ignoreTitle = () => {}
|
|
18
|
-
/** Commit-only ownership: rendering never installs listeners, scripts, or authority. */
|
|
19
|
-
export function ConsentRuntime({
|
|
20
|
-
endpoint,
|
|
21
|
-
config,
|
|
22
|
-
customConsent,
|
|
23
|
-
children,
|
|
24
|
-
}: {
|
|
25
|
-
endpoint: string
|
|
26
|
-
config: PublicFeatureBase | null
|
|
27
|
-
customConsent?: CustomConsentRegistration
|
|
28
|
-
children: ReactNode
|
|
29
|
-
}) {
|
|
30
|
-
const owner = useRef({})
|
|
31
|
-
const registry = useRef<ReturnType<typeof consentRegistry> | null>(null)
|
|
32
|
-
const [snapshot, setSnapshot] = useState(() => ({
|
|
33
|
-
state: deniedConsentState(),
|
|
34
|
-
ready: false,
|
|
35
|
-
error: null as string | null,
|
|
36
|
-
reloadRequired: false,
|
|
37
|
-
}))
|
|
38
|
-
useLayoutEffect(() => {
|
|
39
|
-
const current = consentRegistry(window)
|
|
40
|
-
registry.current = current
|
|
41
|
-
const off = current.subscribe(() => setSnapshot(current.read()))
|
|
42
|
-
const currentOwner = owner.current
|
|
43
|
-
current.attach(currentOwner)
|
|
44
|
-
return () => {
|
|
45
|
-
off()
|
|
46
|
-
current.detach(currentOwner)
|
|
47
|
-
registry.current = null
|
|
48
|
-
}
|
|
49
|
-
}, [])
|
|
50
|
-
useLayoutEffect(() => {
|
|
51
|
-
registry.current!.commit(
|
|
52
|
-
owner.current,
|
|
53
|
-
endpoint,
|
|
54
|
-
config?.enabled.consent ?? false,
|
|
55
|
-
config?.consent,
|
|
56
|
-
customConsent,
|
|
57
|
-
)
|
|
58
|
-
setSnapshot(registry.current!.read())
|
|
59
|
-
}, [endpoint, config, customConsent, customConsent?.adapter, customConsent?.openPreferences])
|
|
60
|
-
const openPreferences = useCallback(() => registry.current?.openPreferences(), [])
|
|
61
|
-
const page = useCallback((path: string) => registry.current?.page(path), [])
|
|
62
|
-
const value = useMemo(
|
|
63
|
-
() => ({
|
|
64
|
-
state: snapshot.state,
|
|
65
|
-
ready: snapshot.ready,
|
|
66
|
-
error: snapshot.error,
|
|
67
|
-
openPreferences,
|
|
68
|
-
}),
|
|
69
|
-
[snapshot, openPreferences],
|
|
70
|
-
)
|
|
71
|
-
return (
|
|
72
|
-
<ConsentContext.Provider value={value}>
|
|
73
|
-
{children}
|
|
74
|
-
<Suspense fallback={null}>
|
|
75
|
-
<PageReporter report={page} titleChanged={ignoreTitle} />
|
|
76
|
-
</Suspense>
|
|
77
|
-
{snapshot.error && (
|
|
78
|
-
<div className="u90-features u90-consent-status">
|
|
79
|
-
<p role="status" aria-live="polite">
|
|
80
|
-
{snapshot.error}
|
|
81
|
-
</p>
|
|
82
|
-
{snapshot.reloadRequired ? (
|
|
83
|
-
<button type="button" data-u90-consent-reload onClick={() => window.location.reload()}>
|
|
84
|
-
Reload to apply consent changes
|
|
85
|
-
</button>
|
|
86
|
-
) : (
|
|
87
|
-
<button type="button" onClick={() => registry.current?.retry()}>
|
|
88
|
-
Retry consent setup
|
|
89
|
-
</button>
|
|
90
|
-
)}
|
|
91
|
-
</div>
|
|
92
|
-
)}
|
|
93
|
-
</ConsentContext.Provider>
|
|
94
|
-
)
|
|
95
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
import { createContext, useContext } from 'react'
|
|
3
|
-
import type { ConsentState } from '../contracts/consent'
|
|
4
|
-
export type ConsentController = {
|
|
5
|
-
state: ConsentState
|
|
6
|
-
ready: boolean
|
|
7
|
-
error: string | null
|
|
8
|
-
openPreferences(): void
|
|
9
|
-
}
|
|
10
|
-
export const ConsentContext = createContext<ConsentController | null>(null)
|
|
11
|
-
export function useConsent(): ConsentController {
|
|
12
|
-
const value = useContext(ConsentContext)
|
|
13
|
-
if (!value) throw new Error('Consent hooks require a SiteFeatures root.')
|
|
14
|
-
return value
|
|
15
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
# Consent setup and lifecycle
|
|
2
|
-
|
|
3
|
-
Install the existing same-origin feature gateway. Mount this standard setup once in the consuming server root layout, independently of whether Consent is currently enabled:
|
|
4
|
-
|
|
5
|
-
```tsx
|
|
6
|
-
import { SiteFeatures } from '@unitninety/site-kit/features'
|
|
7
|
-
import { ConsentDefaults } from '@unitninety/site-kit/features/server'
|
|
8
|
-
import '@unitninety/site-kit/features/styles.css'
|
|
9
|
-
|
|
10
|
-
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
11
|
-
return <html lang="en"><body>
|
|
12
|
-
<ConsentDefaults />
|
|
13
|
-
<SiteFeatures>{children}</SiteFeatures>
|
|
14
|
-
</body></html>
|
|
15
|
-
}
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
Keep the root around children from their first render, including while choosing custom presentations. Remote accepted configuration controls activation, provider/source, Google mode and integrations. No installation, grant or mode prop is supported. Later administration does not require layout edits. Reuse the native gateway handlers; never add a competing catch-all.
|
|
19
|
-
|
|
20
|
-
`ConsentDefaults` is exported only through the server entry. It renders a stable-ID denied-only `next/script` with `beforeInteractive`, without provider IDs, tag sources, configuration fetches or pageviews. Installed Next 16.3.4 queues App Router scripts before hydration; this does not promise native parser-time execution or control unmanaged async scripts. Remove old CMP, GA and other raw optional installers. The browser driver defensively runs the same initializer before its own first managed Google insertion if its marker is absent; conflicting unmanaged Google ownership aborts insertion. The marker is bookkeeping, never consent authority, and defaults never replay on refresh/navigation/regrant.
|
|
21
|
-
|
|
22
|
-
`useConsent()` exposes exactly `{ state, ready, error, openPreferences }`. A real preferences button calls `openPreferences`; unavailable or throwing providers produce a handled error. The root supplies an accessible status/retry control and a visitor-controlled **Reload to apply consent changes** button when needed. It never reloads automatically on Close or assumes any event confirms a saved choice. Existing preferences controls remain usable during disable/withdrawal where the provider is still available.
|
|
23
|
-
|
|
24
|
-
## Banner embed URL
|
|
25
|
-
|
|
26
|
-
The Cookies workspace accepts an HTTPS script URL from any banner provider. With an active
|
|
27
|
-
`custom` installation and no `customConsent` registration, Site Kit inserts that script once per
|
|
28
|
-
document. No adapter or consent event is required just to display the provider's banner. The URL
|
|
29
|
-
must point to its external JavaScript, not a provider dashboard, iframe page or pasted HTML. Providers
|
|
30
|
-
that need inline initialization, extra attributes or synchronous parser execution may still need
|
|
31
|
-
website code. The website Content Security Policy must allow the provider’s script origin and
|
|
32
|
-
any additional resources it needs; saving a URL does not relax that policy. Custom script changes retain the existing `code.write` permission requirement.
|
|
33
|
-
|
|
34
|
-
Embedding does not grant U90 consent: `useConsent().ready` stays false, all U90-managed optional
|
|
35
|
-
integrations remain denied, and the provider's own controls handle preferences. Unregistered
|
|
36
|
-
`u90:consent` events are ignored. Successful loading alone is not a consent signal. A missing optional
|
|
37
|
-
registration is not an installation error; actual network failures/timeouts still surface normally.
|
|
38
|
-
Already executed scripts cannot be undone by disabling the feature; reload the document to apply
|
|
39
|
-
URL removal/replacement. Remove duplicate legacy installers before enabling the embed.
|
|
40
|
-
|
|
41
|
-
Recognized direct CookieScript URLs retain their built-in category connection automatically. Other
|
|
42
|
-
URLs use the same provider-independent embed path. Existing saved installations remain compatible.
|
|
43
|
-
|
|
44
|
-
## Optional custom consent connection
|
|
45
|
-
|
|
46
|
-
The optional `SiteFeatures` prop `customConsent?: CustomConsentRegistration` selects exactly one code-owned registration, only when the accepted remote provider is `custom`:
|
|
47
|
-
|
|
48
|
-
```ts
|
|
49
|
-
// Stable module identity, or useMemo with stable callback/adapter dependencies.
|
|
50
|
-
const customConsent = { adapter: myConsentAdapter }
|
|
51
|
-
// Alternatively: const customConsent = { openPreferences: reopenMyCMP }
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
The adapter supplies `read`, `subscribe` (returning cleanup) and `openPreferences`. The event bridge supplies only the reopen callback and publishes a fresh complete validated `ConsentState` in `window` event `u90:consent` during its active subscription. An event before registration cannot qualify. Missing/conflicting registration, invalid/unready snapshots and missing reopening deny optional code. An absent registration still permits the banner embed described above; an explicitly invalid registration remains an error. There is no arbitrary-window-global fallback. Factories are side-effect free; committed ownership subscribes before provider insertion and immediately reads current state.
|
|
55
|
-
|
|
56
|
-
Keep actual adapter identity stable. For a bridge keep both its registration object and reopen callback stable. Replacement after activation requires a document reload and cannot transfer old events/grants. Render-only/suspended updates do not install listeners or replace authority.
|
|
57
|
-
|
|
58
|
-
## Runtime guarantees and limits
|
|
59
|
-
|
|
60
|
-
A document-owned registry retains execution records across navigation, React replay, root remount, integration removal and endpoint replacement. Equivalent settings revisions do not reinstall scripts or resubscribe. Last accepted config remains authoritative during failed/rejected refresh; real provider failures, committed disable and endpoint/provider replacement still deny immediately. Session/Feedback errors never decide consent. Disabled Consent does not grant categories.
|
|
61
|
-
|
|
62
|
-
The fixed `createConsentLoader(driver)` API exposes `apply(state, integrations): Promise<void>` and `dispose(): void`. `IntegrationDriver` remains `start(integration): Promise<void>` and `stop(id): Promise<'stopped' | 'reload-required'>`. Starts deduplicate; withdrawal invokes stop immediately even during an unresolved start, and late initialization is stopped again. Built-in vetoes are synchronous. Drivers supplied by code must honor that same emission-veto obligation. Ten-second load/stop bounds surface failure and uncertainty. A network load error is retryable; possibly executing timed-out code requires reload.
|
|
63
|
-
|
|
64
|
-
Basic Google mode makes no managed Google tag request before consent. Explicit advanced mode permits only classified Google requests while denied and may produce vendor consent-mode traffic; generic rows remain category-gated. Google defaults/update, `ga-disable`, `send_page_view:false` and current-permission checks guard Site Kit-owned SPA pageviews. Denied queued navigation is discarded; regrant emits only the current page.
|
|
65
|
-
|
|
66
|
-
The shared closed classifier determines new GA4/ContentSquare drivers and approved executable attributes. An already executed GA source retains its proven teardown on category-policy changes, but its events require **both current row-category permission and analytics consent**. That historical capability never changes classification, code.write authorization or advanced eligibility for the edited row. New recategorized rows are generic.
|
|
67
|
-
|
|
68
|
-
ContentSquare uses documented `optout` and requires reload; no web optin or cookie-clearing workaround is supplied. Its persisted opt-out may still affect regrant after reload. Attempted generic/Maps code requires reload regardless of a requested `stop` policy. Removing a script node is not teardown. Any reload requirement blocks every optional restart, including renamed/readded rows, until a real document reload. Previously executed third-party code cannot be rolled back.
|
|
69
|
-
|
|
70
|
-
Tests use deterministic local DOM/provider doubles only. Task3 supplies actual production Next browser tag/beacon proof, both public projections, settings authorization/UI, the standard starter mount and exact KAW fixture transforms. No live-vendor account, regional/legal compliance, ContentSquare delivery/regrant or production rollout is certified.
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import { deniedConsentState, type ConsentAdapter, type ConsentState } from '../contracts/consent'
|
|
2
|
-
|
|
3
|
-
/** Provider state is authoritative; neither event names nor an action alone grant categories. */
|
|
4
|
-
export function normalizeCookieScript(input: unknown): ConsentState {
|
|
5
|
-
if (!input || typeof input !== 'object') return deniedConsentState()
|
|
6
|
-
const state = input as { action?: unknown; categories?: unknown }
|
|
7
|
-
if ((state.action !== 'accept' && state.action !== 'reject') || !Array.isArray(state.categories) ||
|
|
8
|
-
!state.categories.every((category) => typeof category === 'string')) return deniedConsentState()
|
|
9
|
-
const accepted = state.action === 'accept' ? state.categories : []
|
|
10
|
-
return {
|
|
11
|
-
version: 1, necessary: true, ready: true,
|
|
12
|
-
preferences: accepted.includes('functionality'), analytics: accepted.includes('performance'),
|
|
13
|
-
marketing: accepted.includes('targeting'),
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
type CookieScriptWindow = Window & { CookieScript?: { instance?: { currentState?: () => unknown; show?: () => void } } }
|
|
18
|
-
const EVENTS = ['CookieScriptLoaded', 'CookieScriptAccept', 'CookieScriptAcceptAll', 'CookieScriptReject', 'CookieScriptClose'] as const
|
|
19
|
-
|
|
20
|
-
/** Construct without side effects; the root subscribes before installing its owned CMP. */
|
|
21
|
-
export function cookieScriptAdapter(window: Window): ConsentAdapter {
|
|
22
|
-
const target = window as CookieScriptWindow
|
|
23
|
-
const listeners = new Set<(state: ConsentState) => void>()
|
|
24
|
-
let reopenFailed = false
|
|
25
|
-
const read = (): ConsentState => {
|
|
26
|
-
try {
|
|
27
|
-
const instance = target.CookieScript?.instance
|
|
28
|
-
if (reopenFailed || typeof instance?.currentState !== 'function' || typeof instance.show !== 'function') return deniedConsentState()
|
|
29
|
-
return normalizeCookieScript(instance.currentState())
|
|
30
|
-
} catch { return deniedConsentState() }
|
|
31
|
-
}
|
|
32
|
-
const publish = () => { const state = read(); for (const listener of listeners) listener({ ...state }) }
|
|
33
|
-
const change = () => { reopenFailed = false; publish() }
|
|
34
|
-
return {
|
|
35
|
-
read,
|
|
36
|
-
subscribe(listener) {
|
|
37
|
-
if (listeners.size === 0) for (const event of EVENTS) window.addEventListener(event, change)
|
|
38
|
-
listeners.add(listener)
|
|
39
|
-
let subscribed = true
|
|
40
|
-
return () => {
|
|
41
|
-
if (!subscribed) return
|
|
42
|
-
subscribed = false
|
|
43
|
-
listeners.delete(listener)
|
|
44
|
-
if (listeners.size === 0) for (const event of EVENTS) window.removeEventListener(event, change)
|
|
45
|
-
}
|
|
46
|
-
},
|
|
47
|
-
openPreferences() {
|
|
48
|
-
if (!read().ready) { publish(); return }
|
|
49
|
-
try { target.CookieScript!.instance!.show!() }
|
|
50
|
-
catch { reopenFailed = true; publish() }
|
|
51
|
-
},
|
|
52
|
-
}
|
|
53
|
-
}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { ConsentStateSchema, deniedConsentState, type ConsentAdapter, type ConsentState } from '../contracts/consent'
|
|
2
|
-
|
|
3
|
-
/** Code-owned registration: choose one stable adapter or one normalized-event bridge. */
|
|
4
|
-
export type CustomConsentRegistration =
|
|
5
|
-
| { adapter: ConsentAdapter; openPreferences?: never }
|
|
6
|
-
| { adapter?: never; openPreferences: () => void }
|
|
7
|
-
|
|
8
|
-
export function customConsentAdapter(window: Window, registration?: CustomConsentRegistration): ConsentAdapter {
|
|
9
|
-
const listeners = new Set<(state: ConsentState) => void>()
|
|
10
|
-
const adapter = registration?.adapter
|
|
11
|
-
const registered = adapter && registration.openPreferences === undefined &&
|
|
12
|
-
typeof adapter.read === 'function' && typeof adapter.subscribe === 'function' && typeof adapter.openPreferences === 'function'
|
|
13
|
-
const reopen = registration?.openPreferences
|
|
14
|
-
const bridge = adapter === undefined && typeof registration?.openPreferences === 'function'
|
|
15
|
-
let state = deniedConsentState()
|
|
16
|
-
let failed = false
|
|
17
|
-
let subscription = 0
|
|
18
|
-
const hasReopen = () => registered
|
|
19
|
-
? registration?.adapter === adapter && registration.openPreferences === undefined && typeof adapter.openPreferences === 'function'
|
|
20
|
-
: bridge && registration?.adapter === undefined && typeof reopen === 'function' && registration?.openPreferences === reopen
|
|
21
|
-
let cleanup: (() => void) | undefined
|
|
22
|
-
const normalize = (input: unknown): ConsentState => {
|
|
23
|
-
try {
|
|
24
|
-
const result = ConsentStateSchema.safeParse(input)
|
|
25
|
-
return result.success ? result.data : deniedConsentState()
|
|
26
|
-
} catch { return deniedConsentState() }
|
|
27
|
-
}
|
|
28
|
-
const publish = (input: unknown) => {
|
|
29
|
-
state = normalize(input)
|
|
30
|
-
for (const listener of listeners) listener({ ...state })
|
|
31
|
-
}
|
|
32
|
-
const change = (event: Event) => { failed = false; publish(hasReopen() ? (event as CustomEvent<unknown>).detail : deniedConsentState()) }
|
|
33
|
-
const read = (): ConsentState => {
|
|
34
|
-
if (failed || !hasReopen()) return deniedConsentState()
|
|
35
|
-
if (registered) {
|
|
36
|
-
try { state = normalize(adapter.read()); return { ...state } }
|
|
37
|
-
catch { state = deniedConsentState(); return { ...state } }
|
|
38
|
-
}
|
|
39
|
-
return bridge && listeners.size > 0 ? { ...state } : deniedConsentState()
|
|
40
|
-
}
|
|
41
|
-
return {
|
|
42
|
-
read,
|
|
43
|
-
subscribe(listener) {
|
|
44
|
-
listeners.add(listener)
|
|
45
|
-
if (listeners.size === 1) {
|
|
46
|
-
const current = ++subscription
|
|
47
|
-
state = deniedConsentState()
|
|
48
|
-
failed = false
|
|
49
|
-
if (registered) {
|
|
50
|
-
try {
|
|
51
|
-
let active = false
|
|
52
|
-
const off = adapter.subscribe((next) => {
|
|
53
|
-
if (!active || current !== subscription || !listeners.size) return
|
|
54
|
-
failed = false
|
|
55
|
-
publish(hasReopen() ? next : deniedConsentState())
|
|
56
|
-
})
|
|
57
|
-
if (typeof off !== 'function') throw new Error('Custom subscription must supply cleanup.')
|
|
58
|
-
active = true
|
|
59
|
-
cleanup = off
|
|
60
|
-
publish(read())
|
|
61
|
-
} catch { ++subscription; failed = true; publish(deniedConsentState()) }
|
|
62
|
-
} else if (bridge) {
|
|
63
|
-
window.addEventListener('u90:consent', change)
|
|
64
|
-
cleanup = () => window.removeEventListener('u90:consent', change)
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
let subscribed = true
|
|
68
|
-
return () => {
|
|
69
|
-
if (!subscribed) return
|
|
70
|
-
subscribed = false
|
|
71
|
-
listeners.delete(listener)
|
|
72
|
-
if (listeners.size === 0) {
|
|
73
|
-
++subscription
|
|
74
|
-
try { cleanup?.() } catch { /* Detached listeners cannot restore this authority. */ }
|
|
75
|
-
cleanup = undefined
|
|
76
|
-
state = deniedConsentState()
|
|
77
|
-
failed = true
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
},
|
|
81
|
-
openPreferences() {
|
|
82
|
-
if (!read().ready) { publish(deniedConsentState()); return }
|
|
83
|
-
try {
|
|
84
|
-
if (registered) adapter.openPreferences()
|
|
85
|
-
else if (bridge) reopen!()
|
|
86
|
-
} catch { failed = true; publish(deniedConsentState()) }
|
|
87
|
-
},
|
|
88
|
-
}
|
|
89
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
/** Shared server/bootstrap and defensive driver initializer. No tenant/category authority. */
|
|
2
|
-
export type GoogleConsentWindow = Window & {
|
|
3
|
-
__u90ConsentDefaultsV1?: true
|
|
4
|
-
dataLayer?: unknown[]
|
|
5
|
-
gtag?: (...args: unknown[]) => void
|
|
6
|
-
}
|
|
7
|
-
export function initializeConsentDefaults(window: Window): void {
|
|
8
|
-
const target = window as GoogleConsentWindow
|
|
9
|
-
if (target.__u90ConsentDefaultsV1 === true) {
|
|
10
|
-
if (!Array.isArray(target.dataLayer) || typeof target.gtag !== 'function')
|
|
11
|
-
throw new Error('Consent defaults are unavailable.')
|
|
12
|
-
return
|
|
13
|
-
}
|
|
14
|
-
if (
|
|
15
|
-
target.gtag !== undefined ||
|
|
16
|
-
(target.dataLayer !== undefined &&
|
|
17
|
-
(!Array.isArray(target.dataLayer) || target.dataLayer.length > 0))
|
|
18
|
-
)
|
|
19
|
-
throw new Error(
|
|
20
|
-
'Consent defaults ownership conflicts with unmanaged Google code. Reload after correcting setup.',
|
|
21
|
-
)
|
|
22
|
-
target.dataLayer = target.dataLayer || []
|
|
23
|
-
target.gtag = function () {
|
|
24
|
-
// Preserve Google's documented gtag queue payload shape.
|
|
25
|
-
// eslint-disable-next-line prefer-rest-params
|
|
26
|
-
target.dataLayer!.push(arguments)
|
|
27
|
-
}
|
|
28
|
-
target.gtag('consent', 'default', {
|
|
29
|
-
analytics_storage: 'denied',
|
|
30
|
-
ad_storage: 'denied',
|
|
31
|
-
ad_user_data: 'denied',
|
|
32
|
-
ad_personalization: 'denied',
|
|
33
|
-
functionality_storage: 'denied',
|
|
34
|
-
personalization_storage: 'denied',
|
|
35
|
-
security_storage: 'granted',
|
|
36
|
-
})
|
|
37
|
-
target.__u90ConsentDefaultsV1 = true
|
|
38
|
-
}
|
|
39
|
-
export const CONSENT_DEFAULTS_ID = 'u90-consent-defaults-v1'
|
|
40
|
-
export const CONSENT_DEFAULTS_SCRIPT = `(${initializeConsentDefaults.toString()})(window);`
|
|
@@ -1,233 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
classifyManagedIntegration,
|
|
3
|
-
deniedConsentState,
|
|
4
|
-
ManagedIntegrationSchema,
|
|
5
|
-
type ConsentState,
|
|
6
|
-
type IntegrationDriver,
|
|
7
|
-
type ManagedIntegration,
|
|
8
|
-
} from '../contracts/consent'
|
|
9
|
-
import { ConsentReloadRequiredError, executableFingerprint } from './loader'
|
|
10
|
-
import { initializeConsentDefaults, type GoogleConsentWindow } from './defaults'
|
|
11
|
-
|
|
12
|
-
type BrowserWindow = GoogleConsentWindow & {
|
|
13
|
-
_uxa?: unknown[][]
|
|
14
|
-
[key: `ga-disable-${string}`]: boolean
|
|
15
|
-
}
|
|
16
|
-
type Execution = {
|
|
17
|
-
integration: ManagedIntegration
|
|
18
|
-
kind: ReturnType<typeof classifyManagedIntegration>
|
|
19
|
-
attempted: boolean
|
|
20
|
-
loaded: boolean
|
|
21
|
-
running: boolean
|
|
22
|
-
allowed: boolean
|
|
23
|
-
configured: boolean
|
|
24
|
-
uncertain: boolean
|
|
25
|
-
element?: HTMLScriptElement
|
|
26
|
-
loading?: Promise<void>
|
|
27
|
-
lastPage?: string
|
|
28
|
-
}
|
|
29
|
-
export const CONSENT_LOAD_TIMEOUT_MS = 10000
|
|
30
|
-
/** Private built-in driver. Its synchronous veto is independent of async loader completion. */
|
|
31
|
-
export function createBrowserConsentDriver(window: Window): IntegrationDriver & {
|
|
32
|
-
configure(integrations: ManagedIntegration[]): void
|
|
33
|
-
setPermission(state: ConsentState, mode: 'basic' | 'advanced'): void
|
|
34
|
-
suspend(value: boolean): void
|
|
35
|
-
page(path: string): void
|
|
36
|
-
attempted(): boolean
|
|
37
|
-
googleAttempted(): boolean
|
|
38
|
-
} {
|
|
39
|
-
const target = window as BrowserWindow
|
|
40
|
-
const records = new Map<string, Execution>()
|
|
41
|
-
let state = deniedConsentState()
|
|
42
|
-
let mode: 'basic' | 'advanced' = 'basic'
|
|
43
|
-
let suspended = false
|
|
44
|
-
let path = window.location.pathname
|
|
45
|
-
const permitted = (record: Execution) =>
|
|
46
|
-
!suspended &&
|
|
47
|
-
state.ready &&
|
|
48
|
-
state[record.integration.category] &&
|
|
49
|
-
record.allowed &&
|
|
50
|
-
(record.kind.kind !== 'ga4' || state.analytics)
|
|
51
|
-
const signal = () => {
|
|
52
|
-
if (target.__u90ConsentDefaultsV1 !== true) return
|
|
53
|
-
target.gtag?.('consent', 'update', {
|
|
54
|
-
analytics_storage: state.ready && state.analytics ? 'granted' : 'denied',
|
|
55
|
-
ad_storage: state.ready && state.marketing ? 'granted' : 'denied',
|
|
56
|
-
ad_user_data: state.ready && state.marketing ? 'granted' : 'denied',
|
|
57
|
-
ad_personalization: state.ready && state.marketing ? 'granted' : 'denied',
|
|
58
|
-
functionality_storage: state.ready && state.preferences ? 'granted' : 'denied',
|
|
59
|
-
personalization_storage: state.ready && state.preferences ? 'granted' : 'denied',
|
|
60
|
-
security_storage: 'granted',
|
|
61
|
-
})
|
|
62
|
-
}
|
|
63
|
-
const veto = (record: Execution) => {
|
|
64
|
-
if (record.kind.kind === 'ga4')
|
|
65
|
-
target[`ga-disable-${record.kind.measurementId}`] = !permitted(record)
|
|
66
|
-
}
|
|
67
|
-
const pageview = (record: Execution) => {
|
|
68
|
-
if (
|
|
69
|
-
record.kind.kind !== 'ga4' ||
|
|
70
|
-
!record.loaded ||
|
|
71
|
-
!permitted(record) ||
|
|
72
|
-
record.lastPage === path
|
|
73
|
-
)
|
|
74
|
-
return
|
|
75
|
-
record.lastPage = path
|
|
76
|
-
target.gtag!('event', 'page_view', {
|
|
77
|
-
send_to: record.kind.measurementId,
|
|
78
|
-
page_path: path,
|
|
79
|
-
page_location: new URL(path, window.location.origin).href,
|
|
80
|
-
page_title: window.document.title,
|
|
81
|
-
})
|
|
82
|
-
}
|
|
83
|
-
const optout = () => {
|
|
84
|
-
target._uxa = target._uxa || []
|
|
85
|
-
target._uxa.push(['optout'])
|
|
86
|
-
}
|
|
87
|
-
const driver = {
|
|
88
|
-
// Preserve proven teardown for executed code; policy edits never promote catalogue authority.
|
|
89
|
-
configure(integrations: ManagedIntegration[]) {
|
|
90
|
-
for (const record of records.values()) {
|
|
91
|
-
const next = integrations.find((row) => row.id === record.integration.id)
|
|
92
|
-
if (next && executableFingerprint(next) === executableFingerprint(record.integration))
|
|
93
|
-
record.integration = next
|
|
94
|
-
else {
|
|
95
|
-
record.running = false
|
|
96
|
-
record.allowed = false
|
|
97
|
-
}
|
|
98
|
-
veto(record)
|
|
99
|
-
}
|
|
100
|
-
},
|
|
101
|
-
setPermission(next: ConsentState, nextMode: 'basic' | 'advanced') {
|
|
102
|
-
const changed = JSON.stringify(state) !== JSON.stringify(next)
|
|
103
|
-
state = { ...next }
|
|
104
|
-
mode = nextMode
|
|
105
|
-
for (const record of records.values()) {
|
|
106
|
-
record.allowed = record.running && state.ready && state[record.integration.category]
|
|
107
|
-
if (!permitted(record)) record.lastPage = undefined
|
|
108
|
-
veto(record)
|
|
109
|
-
}
|
|
110
|
-
if (changed) signal()
|
|
111
|
-
for (const record of records.values()) pageview(record)
|
|
112
|
-
},
|
|
113
|
-
suspend(value: boolean) {
|
|
114
|
-
suspended = value
|
|
115
|
-
for (const record of records.values()) veto(record)
|
|
116
|
-
},
|
|
117
|
-
page(next: string) {
|
|
118
|
-
path = next
|
|
119
|
-
for (const record of records.values()) pageview(record)
|
|
120
|
-
},
|
|
121
|
-
attempted: () => [...records.values()].some((record) => record.attempted),
|
|
122
|
-
googleAttempted: () =>
|
|
123
|
-
[...records.values()].some((record) => record.attempted && record.kind.kind === 'ga4'),
|
|
124
|
-
async start(input: ManagedIntegration) {
|
|
125
|
-
const integration = ManagedIntegrationSchema.parse(input)
|
|
126
|
-
let record = records.get(integration.id)
|
|
127
|
-
if (!record) {
|
|
128
|
-
record = {
|
|
129
|
-
integration,
|
|
130
|
-
kind: classifyManagedIntegration(integration),
|
|
131
|
-
attempted: false,
|
|
132
|
-
loaded: false,
|
|
133
|
-
running: false,
|
|
134
|
-
allowed: false,
|
|
135
|
-
configured: false,
|
|
136
|
-
uncertain: false,
|
|
137
|
-
}
|
|
138
|
-
records.set(integration.id, record)
|
|
139
|
-
}
|
|
140
|
-
if (record.uncertain)
|
|
141
|
-
throw new ConsentReloadRequiredError(
|
|
142
|
-
'Reload required after uncertain integration initialization.',
|
|
143
|
-
)
|
|
144
|
-
const current = record
|
|
145
|
-
current.running = true
|
|
146
|
-
current.integration = integration
|
|
147
|
-
current.allowed = !suspended && state.ready && state[integration.category]
|
|
148
|
-
if (
|
|
149
|
-
!current.allowed &&
|
|
150
|
-
!(current.kind.kind === 'ga4' && mode === 'advanced' && state.ready && !suspended)
|
|
151
|
-
)
|
|
152
|
-
return
|
|
153
|
-
if (current.kind.kind === 'ga4') {
|
|
154
|
-
initializeConsentDefaults(window)
|
|
155
|
-
signal()
|
|
156
|
-
veto(current)
|
|
157
|
-
if (!current.configured) {
|
|
158
|
-
target.gtag!('js', new Date())
|
|
159
|
-
target.gtag!('config', current.kind.measurementId, { send_page_view: false })
|
|
160
|
-
current.configured = true
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
if (current.loaded) {
|
|
164
|
-
veto(current)
|
|
165
|
-
pageview(current)
|
|
166
|
-
return
|
|
167
|
-
}
|
|
168
|
-
if (!current.loading) {
|
|
169
|
-
current.loading = new Promise<void>((resolve, reject) => {
|
|
170
|
-
const script = window.document.createElement('script')
|
|
171
|
-
script.async = true
|
|
172
|
-
for (const [key, value] of Object.entries(integration.attributes ?? {}))
|
|
173
|
-
script.setAttribute(key, value)
|
|
174
|
-
if (script.id && window.document.getElementById(script.id)) {
|
|
175
|
-
reject(Error('Integration element ID is already owned.'))
|
|
176
|
-
return
|
|
177
|
-
}
|
|
178
|
-
script.src = integration.src
|
|
179
|
-
script.dataset.u90Integration = integration.id
|
|
180
|
-
current.element = script
|
|
181
|
-
const finish = () => {
|
|
182
|
-
window.clearTimeout(timer)
|
|
183
|
-
script.onload = null
|
|
184
|
-
script.onerror = null
|
|
185
|
-
}
|
|
186
|
-
const timer = window.setTimeout(() => {
|
|
187
|
-
finish()
|
|
188
|
-
current.uncertain = true
|
|
189
|
-
reject(new ConsentReloadRequiredError('Integration load timed out; reload required.'))
|
|
190
|
-
}, CONSENT_LOAD_TIMEOUT_MS)
|
|
191
|
-
script.onload = () => {
|
|
192
|
-
finish()
|
|
193
|
-
current.loaded = true
|
|
194
|
-
resolve()
|
|
195
|
-
}
|
|
196
|
-
script.onerror = () => {
|
|
197
|
-
finish()
|
|
198
|
-
script.remove()
|
|
199
|
-
current.element = undefined
|
|
200
|
-
reject(Error('Integration failed to load. Retry consent setup.'))
|
|
201
|
-
}
|
|
202
|
-
current.attempted = true
|
|
203
|
-
window.document.head.appendChild(script)
|
|
204
|
-
}).finally(() => {
|
|
205
|
-
current.loading = undefined
|
|
206
|
-
})
|
|
207
|
-
}
|
|
208
|
-
await current.loading
|
|
209
|
-
veto(current)
|
|
210
|
-
if (current.kind.kind === 'contentsquare' && !permitted(current)) optout()
|
|
211
|
-
pageview(current)
|
|
212
|
-
},
|
|
213
|
-
async stop(id: string): Promise<'stopped' | 'reload-required'> {
|
|
214
|
-
const record = records.get(id)
|
|
215
|
-
if (!record) return 'stopped'
|
|
216
|
-
record.running = false
|
|
217
|
-
record.allowed = false
|
|
218
|
-
record.lastPage = undefined
|
|
219
|
-
veto(record)
|
|
220
|
-
if (!record.attempted) return 'stopped'
|
|
221
|
-
if (record.kind.kind === 'contentsquare') optout()
|
|
222
|
-
if (
|
|
223
|
-
record.kind.kind === 'ga4' &&
|
|
224
|
-
!record.uncertain &&
|
|
225
|
-
record.integration.revocation === 'stop'
|
|
226
|
-
)
|
|
227
|
-
return 'stopped'
|
|
228
|
-
// Removing a node cannot undo downloaded or executing third-party code.
|
|
229
|
-
return 'reload-required'
|
|
230
|
-
},
|
|
231
|
-
}
|
|
232
|
-
return driver
|
|
233
|
-
}
|