@c15t/scripts 3.0.0-alpha.0 → 3.0.0-alpha.2
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/AGENTS.md +7 -0
- package/README.md +4 -3
- package/dist/e2e-test-utils.js +5 -3
- package/dist/engine/runtime.js +14 -3
- package/dist/events.js +218 -0
- package/dist/registry.js +40 -0
- package/dist/vendors/ads-and-pixels/pinterest-tag.js +123 -0
- package/dist/vendors/analytics/google-tag.js +14 -2
- package/dist/vendors/analytics/microsoft-clarity.js +4 -1
- package/dist/vendors/analytics/one-dollar-stats.js +30 -0
- package/dist/vendors/analytics/segment.js +10 -1
- package/dist/vendors/functional/front-chat.js +64 -0
- package/dist/vendors/tag-managers/cloudflare-zaraz.js +98 -0
- package/dist/vendors/tag-managers/google-tag-manager.js +17 -3
- package/dist-types/__tests__/helpers.d.ts +2 -2
- package/dist-types/engine/compile.d.ts +1 -1
- package/dist-types/engine/runtime.d.ts +1 -1
- package/dist-types/events.d.ts +46 -0
- package/dist-types/registry.d.ts +36 -0
- package/dist-types/resolve.d.ts +1 -1
- package/dist-types/vendors/_shared/install-builders.d.ts +1 -1
- package/dist-types/vendors/ads-and-pixels/pinterest-tag.d.ts +295 -0
- package/dist-types/vendors/analytics/adobe-analytics.d.ts +1 -1
- package/dist-types/vendors/analytics/google-tag.d.ts +3 -1
- package/dist-types/vendors/analytics/matomo-analytics.d.ts +1 -1
- package/dist-types/vendors/analytics/one-dollar-stats.d.ts +39 -0
- package/dist-types/vendors/analytics/segment.d.ts +7 -1
- package/dist-types/vendors/functional/front-chat.d.ts +62 -0
- package/dist-types/vendors/tag-managers/cloudflare-zaraz.d.ts +39 -0
- package/dist-types/vendors/tag-managers/google-tag-manager.d.ts +3 -1
- package/docs/README.md +7 -0
- package/docs/customization/overview.md +4 -3
- package/docs/customization/recipes.md +4 -2
- package/docs/customization/tokens.md +66 -3
- package/docs/frameworks/javascript/script-loader.md +53 -0
- package/docs/frameworks/next/script-loader.md +60 -12
- package/docs/frameworks/react/script-loader.md +14 -0
- package/docs/guides/consent-state.md +327 -0
- package/docs/guides/deployment-modes.md +12 -0
- package/docs/guides/shared-consent-controls.md +158 -0
- package/docs/integrations/adobe-analytics.md +1 -1
- package/docs/integrations/ahrefs-analytics.md +1 -1
- package/docs/integrations/amplitude.md +1 -1
- package/docs/integrations/building-integrations.md +5 -0
- package/docs/integrations/clear-on-revocation.md +167 -0
- package/docs/integrations/clearbit.md +1 -1
- package/docs/integrations/cloudflare-web-analytics.md +1 -1
- package/docs/integrations/cloudflare-zaraz.md +399 -0
- package/docs/integrations/crisp.md +1 -1
- package/docs/integrations/databuddy.md +1 -1
- package/docs/integrations/fathom-analytics.md +1 -1
- package/docs/integrations/front-chat.md +322 -0
- package/docs/integrations/google-maps.md +21 -21
- package/docs/integrations/google-tag-manager.md +1 -1
- package/docs/integrations/google-tag.md +1 -1
- package/docs/integrations/granular-consent.md +210 -0
- package/docs/integrations/heap.md +1 -1
- package/docs/integrations/hightouch.md +1 -1
- package/docs/integrations/hotjar.md +1 -1
- package/docs/integrations/intercom.md +1 -1
- package/docs/integrations/linkedin-insights.md +1 -1
- package/docs/integrations/logrocket.md +1 -1
- package/docs/integrations/matomo-analytics.md +1 -1
- package/docs/integrations/meta-pixel.md +1 -1
- package/docs/integrations/microsoft-clarity.md +1 -1
- package/docs/integrations/microsoft-uet.md +1 -1
- package/docs/integrations/mixpanel-analytics.md +1 -1
- package/docs/integrations/one-dollar-stats.md +305 -0
- package/docs/integrations/openai-pixel.md +1 -1
- package/docs/integrations/overview.md +22 -18
- package/docs/integrations/pinterest-tag.md +321 -0
- package/docs/integrations/pirsch.md +1 -1
- package/docs/integrations/plausible-analytics.md +1 -1
- package/docs/integrations/posthog.md +1 -1
- package/docs/integrations/promptwatch.md +1 -1
- package/docs/integrations/reddit-pixel.md +1 -1
- package/docs/integrations/rudderstack.md +1 -1
- package/docs/integrations/rybbit-analytics.md +1 -1
- package/docs/integrations/segment.md +1 -1
- package/docs/integrations/snapchat-pixel.md +1 -1
- package/docs/integrations/tiktok-pixel.md +1 -1
- package/docs/integrations/umami-analytics.md +1 -1
- package/docs/integrations/vercel-analytics.md +1 -1
- package/docs/integrations/x-pixel.md +1 -1
- package/docs/integrations/youtube.md +27 -22
- package/docs/upgrade-v3.md +176 -1
- package/package.json +30 -2
- package/readme.json +0 -19
|
@@ -20,6 +20,23 @@ grant.
|
|
|
20
20
|
| Explain regional behavior | `policyRule` |
|
|
21
21
|
| Diagnose initialization | `resolution` |
|
|
22
22
|
|
|
23
|
+
## Gate IAB vendors on the TC string
|
|
24
|
+
|
|
25
|
+
Under an IAB policy, a script, network rule or iframe that names a `vendorId`
|
|
26
|
+
or IAB purposes is an IAB target. It runs only while a confirmed TC string
|
|
27
|
+
grants what it declares: purpose and vendor consent for `iabPurposes`, purpose
|
|
28
|
+
and vendor legitimate interest for `iabLegIntPurposes`, and opt-ins for
|
|
29
|
+
`iabSpecialFeatures`. Every category it names must also be free of
|
|
30
|
+
restrictions, so GPC, an opt-out directive or strict scope blocks it.
|
|
31
|
+
|
|
32
|
+
A refused category is the one exception. It does not block an IAB target that
|
|
33
|
+
processes only on legitimate interest, after publisher restrictions, because
|
|
34
|
+
the TCF lets that processing run without consent. The visitor's control for it
|
|
35
|
+
is the objection, which the legitimate interest signals record. The category
|
|
36
|
+
itself stays refused: its `effectivePermissions` entry is `false`, and targets
|
|
37
|
+
that name only the category, or that also declare a consent purpose or special
|
|
38
|
+
feature, stay blocked.
|
|
39
|
+
|
|
23
40
|
## Record only explicit visitor actions
|
|
24
41
|
|
|
25
42
|
```ts
|
|
@@ -37,6 +54,45 @@ confirmation times. Do not call all three in one handler.
|
|
|
37
54
|
changes in effective permissions, including changes caused by expiry or privacy
|
|
38
55
|
signals. Hydration must not be counted as another visitor choice.
|
|
39
56
|
|
|
57
|
+
## When a choice is saved
|
|
58
|
+
|
|
59
|
+
A save records the choice in the browser first and sends it to the backend
|
|
60
|
+
afterwards. The stock banner, preference dialog and IAB surfaces in every
|
|
61
|
+
framework adapter, and the browser client's `acceptAll()`, `rejectAll()`
|
|
62
|
+
and `save()`, close without waiting for the backend. In order:
|
|
63
|
+
|
|
64
|
+
1. In the click task, the explicit choice and effective permissions change,
|
|
65
|
+
`onChoiceRecorded` and `onPermissionsChanged` run, gated scripts, iframes
|
|
66
|
+
and network rules follow the new permissions, and the surface leaves the
|
|
67
|
+
active state. Its exit animation still plays.
|
|
68
|
+
2. In the next task, the choice is written to the cookie and localStorage.
|
|
69
|
+
3. After that write is queued, the request to the backend starts.
|
|
70
|
+
4. When the request settles, the kernel emits `command:save:completed`. The
|
|
71
|
+
promise returned by `kernel.commands.save()`, React's `performAction()` and
|
|
72
|
+
Svelte's `saveConsents()` resolves or rejects only then.
|
|
73
|
+
5. If the save turned off a category or vendor that was granted, the page
|
|
74
|
+
reloads in the next task, after `onBeforeConsentRevocationReload` runs.
|
|
75
|
+
Removing a script cannot stop code that already ran, so the reload starts a
|
|
76
|
+
page with only permitted code. With several saves in flight, it waits for
|
|
77
|
+
the last one. Set `reloadOnConsentRevoked: false` to handle revocation
|
|
78
|
+
yourself.
|
|
79
|
+
|
|
80
|
+
A failed request does not reopen the surface or roll the choice back. The
|
|
81
|
+
kernel emits `command:error`, which reaches the `onError` callback in adapters
|
|
82
|
+
that accept one, and queues the payload in localStorage. The queue is replayed
|
|
83
|
+
after the next successful initialization and when the browser comes back
|
|
84
|
+
online, up to 10 attempts over 7 days. A replay carries the original action
|
|
85
|
+
time and policy snapshot token, so the backend records when the visitor
|
|
86
|
+
decided, and a duplicate submission resolves to the same consent record. A
|
|
87
|
+
backend that signs policy snapshot tokens rejects a replay made after the
|
|
88
|
+
token expires, which is 30 minutes by default for the self-hosted backend. A
|
|
89
|
+
save still queued by then is recorded only in the browser.
|
|
90
|
+
|
|
91
|
+
IAB surfaces close in the click task too, but an IAB choice is recorded only
|
|
92
|
+
after its TC string is encoded, which can wait for the TCF library to load.
|
|
93
|
+
If that local step records nothing, for example because the vendor list
|
|
94
|
+
failed to load, the surface comes back so the visitor can try again.
|
|
95
|
+
|
|
40
96
|
## Treat notices and privacy signals separately
|
|
41
97
|
|
|
42
98
|
`commands.dismissNotice()` acknowledges the current notice. It does not grant
|
|
@@ -58,3 +114,274 @@ would invent a grant and lose its original confirmation time.
|
|
|
58
114
|
Valid v2 records can be read without a startup rewrite. The next explicit action
|
|
59
115
|
writes the v3 format. See [migration](../upgrade-v3.md) for expiry, partial saves,
|
|
60
116
|
custom transports and backend contract changes.
|
|
117
|
+
|
|
118
|
+
## Keep open tabs in step
|
|
119
|
+
|
|
120
|
+
Tabs and windows on the same origin (scheme, host and port) share the consent
|
|
121
|
+
cookie and localStorage. When a visitor rejects in one tab, every other open tab
|
|
122
|
+
on that origin applies the rejection without a reload. Scripts and features
|
|
123
|
+
gated on `effectivePermissions` lose permission, subscribers are notified once
|
|
124
|
+
and `onPermissionsChanged` fires. Clearing records in one tab returns the others
|
|
125
|
+
to the active policy's defaults: an opt-in policy denies optional categories and
|
|
126
|
+
shows the prompt again.
|
|
127
|
+
|
|
128
|
+
Tabs on another subdomain that shares the consent cookie do not share
|
|
129
|
+
localStorage, so the browser sends them no `storage` event. They pick up the
|
|
130
|
+
change on their next focus or visibility change, or when you reconcile
|
|
131
|
+
yourself (see below).
|
|
132
|
+
|
|
133
|
+
The `storage` event only exists for localStorage. When localStorage is
|
|
134
|
+
unavailable (blocked by the browser, a sandboxed frame or a privacy mode) and
|
|
135
|
+
c15t stores in the cookie alone, another tab's change arrives only on the next
|
|
136
|
+
focus or visibility change, or when you call `runtime.reconcileStorage()` or
|
|
137
|
+
`persistence.reconcile()`. c15t does not poll the cookie or use a
|
|
138
|
+
`BroadcastChannel` for this.
|
|
139
|
+
|
|
140
|
+
Browser persistence reads stored records again at these moments:
|
|
141
|
+
|
|
142
|
+
| Moment | What happens |
|
|
143
|
+
| ------------------------------------------------------------------------ | --------------------------------- |
|
|
144
|
+
| Another tab or window on the same origin changes a c15t localStorage key | Reconciles on the `storage` event |
|
|
145
|
+
| The page becomes visible again | Reconciles on `visibilitychange` |
|
|
146
|
+
| The window regains focus | Reconciles on `focus` |
|
|
147
|
+
| The network reconnects | No storage read |
|
|
148
|
+
|
|
149
|
+
Several triggers in quick succession run one reconciliation in a later task.
|
|
150
|
+
Reconnecting does not read storage, because going online changes nothing in
|
|
151
|
+
browser storage. On reconnect the kernel retries saves the backend did not
|
|
152
|
+
accept and a failed initialization. Save retries do not write the cookie or
|
|
153
|
+
localStorage. A write that follows initialization, such as a Global Privacy
|
|
154
|
+
Control directive, obeys the ordering described below.
|
|
155
|
+
|
|
156
|
+
Every adapter that mounts browser persistence does this: the React, Next.js and
|
|
157
|
+
TanStack Start providers, Vue, Svelte, Astro, the script tag and
|
|
158
|
+
`createConsentRuntime`. With `persistence: false` nothing is stored or read.
|
|
159
|
+
|
|
160
|
+
### What a reconciliation applies
|
|
161
|
+
|
|
162
|
+
Stored records are merged into the ones in memory:
|
|
163
|
+
|
|
164
|
+
* Category decisions merge per category. Each category keeps the decision with
|
|
165
|
+
the newer confirmation time, so a tab that only changed marketing never
|
|
166
|
+
reverts another tab's newer measurement decision.
|
|
167
|
+
* Privacy directives, such as a Global Privacy Control opt-out, merge as a
|
|
168
|
+
union. A directive only restricts, so none is dropped.
|
|
169
|
+
* The notice dismissal and the vendor record are single decisions. A stored one
|
|
170
|
+
at least as new as the one in memory replaces it; an older one is ignored.
|
|
171
|
+
* When two tabs record decisions in the same millisecond, the one stored first
|
|
172
|
+
wins in both tabs.
|
|
173
|
+
* Tabs keep one subject. A stored subject is considered only when the record
|
|
174
|
+
that carries it changed, never on focus alone. It replaces a subject the tab
|
|
175
|
+
generated on its first save or copied from storage, so a tab that opened
|
|
176
|
+
before another tab stored a subject joins it. A subject id the server
|
|
177
|
+
resolved (at init, from a prefetch or in a save response) is replaced only by
|
|
178
|
+
a strictly newer stored choice, and an identity set with `identify()` is
|
|
179
|
+
never replaced.
|
|
180
|
+
* Under an IAB policy, the `@c15t/iab` module then loads the TC string the other
|
|
181
|
+
tab stored, together with its purpose, vendor and special-feature selections,
|
|
182
|
+
so `__tcfapi` and the preference controls show the choice now in force.
|
|
183
|
+
Selections the visitor changed in this tab without saving are kept. A missing
|
|
184
|
+
or older stored TC string leaves the current one in place, unless it conflicts
|
|
185
|
+
with the reconciled choice: then it is withdrawn and `__tcfapi` reports no
|
|
186
|
+
consent until the next save. A category denied after the TC string was saved
|
|
187
|
+
conflicts if the TC string grants any of its purposes. A partial selection
|
|
188
|
+
saved through IAB, which records its category as denied because not every
|
|
189
|
+
purpose is granted, keeps its TC string. A TC string confirmed before the
|
|
190
|
+
choice's newest decision no longer describes it and is withdrawn too, unless
|
|
191
|
+
a newer receipt replaces it. That receipt is adopted even when its TC string
|
|
192
|
+
is identical, since TC strings round their time to the day and custom-vendor
|
|
193
|
+
selections live only in the receipt; its expiry then applies. The TC
|
|
194
|
+
string and its receipt (`euconsent-v2`, `c15t-iab-authority-v1`) belong to one
|
|
195
|
+
origin, so after a save on a sibling subdomain that shares the consent cookie,
|
|
196
|
+
this subdomain reports no consent through `__tcfapi` until it saves again.
|
|
197
|
+
Vendors never receive the stale TC string in between: it is withdrawn, or
|
|
198
|
+
held back, before `__tcfapi` publishes. A tab also reloads the TC string
|
|
199
|
+
when another tab on the same origin stores a new one, which covers a save in
|
|
200
|
+
the same millisecond or one that changed only vendors. When two tabs save in
|
|
201
|
+
the same millisecond with different selections, the more restrictive TC
|
|
202
|
+
string wins in both, so a revoked vendor is never advertised again. If each
|
|
203
|
+
grants something the other denies, neither is published until the next save:
|
|
204
|
+
the stored receipt is removed, so a page opened later does not restore it.
|
|
205
|
+
When another tab removes the receipt, or clears localStorage, the TC string
|
|
206
|
+
is withdrawn here too, since a page opened now would find none, and a
|
|
207
|
+
receipt this tab was still decoding is not installed. The removal after a
|
|
208
|
+
tie checks that the stored receipt is the one it read, but localStorage has
|
|
209
|
+
no conditional removal, so a receipt another tab stored a moment earlier can
|
|
210
|
+
still be removed. Every tab then withdraws its TC string until the next save,
|
|
211
|
+
so the race only ever withholds consent.
|
|
212
|
+
* A record removed from readable storage since this tab last saw it present is
|
|
213
|
+
cleared, and the active policy decides again. A record this tab never saw in
|
|
214
|
+
storage, such as a receipt merged from the server after `identify()` or a
|
|
215
|
+
choice seeded while storage was blocked, stays.
|
|
216
|
+
* Blocked storage, or bytes that do not decode, change nothing. A failed read
|
|
217
|
+
never grants a category.
|
|
218
|
+
|
|
219
|
+
Expiry is not decided here. The applied records are evaluated at the time of the
|
|
220
|
+
read, the same way as at startup.
|
|
221
|
+
|
|
222
|
+
### Clearing records across tabs
|
|
223
|
+
|
|
224
|
+
`clearRecords()` removes every record and then stores the time of the clear,
|
|
225
|
+
the clear epoch, under its own key: `c15t-epoch` in localStorage and a cookie
|
|
226
|
+
of the same name (`<storageKey>-epoch` with a custom `storageKey`). Clearing
|
|
227
|
+
never removes it. Every consent record written afterwards also records the
|
|
228
|
+
epoch it was written under.
|
|
229
|
+
|
|
230
|
+
A decision confirmed before the epoch was made before the clear, so it is void
|
|
231
|
+
wherever it turns up. A decision stamped in the very millisecond of the clear
|
|
232
|
+
counts only if it comes from a tab that had already seen that clear, so a tab's
|
|
233
|
+
own choice right after its own clear stands while another tab's decision in the
|
|
234
|
+
same millisecond does not. A stored record left with no decisions after the
|
|
235
|
+
clear is void too, subject included.
|
|
236
|
+
|
|
237
|
+
* A tab that reconciles only after another tab cleared and saved again drops
|
|
238
|
+
its pre-clear decisions instead of merging them back. Its subject from before
|
|
239
|
+
the clear is dropped too.
|
|
240
|
+
* A tab that missed the clear writes only decisions it made after it. Its queued
|
|
241
|
+
write of an earlier decision is discarded, so it cannot bring back a cleared
|
|
242
|
+
record.
|
|
243
|
+
* Browser hydration and server reads (`readStoredRecordsFromCookieHeader`, used
|
|
244
|
+
by the Next.js, TanStack Start, Nuxt, SvelteKit and Astro helpers) apply the
|
|
245
|
+
same rule, so a server render agrees with the browser.
|
|
246
|
+
|
|
247
|
+
Records from before any clear, including v2 and legacy records, read as epoch 0
|
|
248
|
+
and are unaffected. A corrupt epoch, or one that cannot be read, also reads as
|
|
249
|
+
0: it voids nothing, so a failed read never grants a category. A consent record
|
|
250
|
+
whose own epoch field is corrupt is kept, and only its epoch is ignored.
|
|
251
|
+
|
|
252
|
+
Each clear moves the epoch forward, even when the device clock went back, so a
|
|
253
|
+
later clear never lets earlier decisions back in. Two clears in the same
|
|
254
|
+
millisecond therefore leave the epoch a millisecond ahead, and a decision saved
|
|
255
|
+
in that millisecond is void. An epoch up to one hour ahead
|
|
256
|
+
of the clock is kept, as it is when the clock was set back after a clear. Until
|
|
257
|
+
the clock catches up, decisions saved in that window are void too. An epoch more
|
|
258
|
+
than an hour ahead is treated as corrupt and reads as 0, so a clear never writes
|
|
259
|
+
one: after the clock went back more than an hour, the new epoch is capped at an
|
|
260
|
+
hour ahead of the clock.
|
|
261
|
+
|
|
262
|
+
That cap is a known limit. A cleared record carries times from before the clock
|
|
263
|
+
went back, and a runtime that missed the clear can write those times back. The
|
|
264
|
+
capped epoch is lower than them, so once the clock has recovered, such a
|
|
265
|
+
decision counts again. Leaving the epoch uncapped does not help: every tab
|
|
266
|
+
whose clock is still behind reads it as corrupt, which voids nothing. Times
|
|
267
|
+
alone cannot order a clear against decisions stamped by a clock that went back
|
|
268
|
+
more than an hour.
|
|
269
|
+
|
|
270
|
+
This changes the stored format. After a clear, the consent cookie carries
|
|
271
|
+
`&e=<time>` (16 bytes) and the localStorage record an `epoch` field (22 bytes),
|
|
272
|
+
and the epoch cookie itself holds a 13-digit time. Visitors who never cleared
|
|
273
|
+
their records store exactly what they did before. An older c15t build rejects
|
|
274
|
+
both the cookie and the localStorage record once they carry the epoch, so a page
|
|
275
|
+
still running one treats the visitor as undecided. Under an opt-out policy that
|
|
276
|
+
page grants optional categories by default until a new choice is saved, and the
|
|
277
|
+
configured prompt may appear again. Deploy the new build to every page of the
|
|
278
|
+
site before visitors can clear their records.
|
|
279
|
+
|
|
280
|
+
### When the cookie and localStorage disagree
|
|
281
|
+
|
|
282
|
+
When both copies hold a decision for a category from the same millisecond and
|
|
283
|
+
the two conflict, the denial wins.
|
|
284
|
+
|
|
285
|
+
The subject and IAB metadata come from the cookie. A server response, such as
|
|
286
|
+
server-side consent restoration, and a sibling subdomain sharing the cookie
|
|
287
|
+
with `crossSubdomain` can both rewrite it without touching this origin's
|
|
288
|
+
localStorage, so the local copy can be the older one. The local copy's subject
|
|
289
|
+
is used only when this browser's last consent write reached localStorage but
|
|
290
|
+
not the cookie, for example because the cookie grew past the size limit, and
|
|
291
|
+
the cookie has not changed since. Such a write stores the cookie as it stood
|
|
292
|
+
under `<storageKey>-cookie-miss` in localStorage; the next write that reaches
|
|
293
|
+
the cookie, or a clear, removes it. When localStorage rejects a write that the
|
|
294
|
+
cookie takes, for example because storage is full, the older localStorage copy
|
|
295
|
+
is removed.
|
|
296
|
+
|
|
297
|
+
When a server render seeded the page from the consent cookie
|
|
298
|
+
(`skipHydration`), that seed stays authoritative. A denial or privacy directive
|
|
299
|
+
that reached only localStorage is still applied on top of it when the page
|
|
300
|
+
mounts, since it can only restrict, if it is newer than the seeded decision or
|
|
301
|
+
from the same millisecond as a seeded grant. A stored grant is not.
|
|
302
|
+
|
|
303
|
+
The notice dismissal, privacy directives and vendor denials are stored twice
|
|
304
|
+
as well. Privacy directives from both copies all apply. A vendor list in
|
|
305
|
+
localStorage at least as new as the cookie's adds its denials but never lifts
|
|
306
|
+
one the cookie holds; a copy confirmed before the last clear is ignored, so its
|
|
307
|
+
denials never come back. The newer notice dismissal applies; it still only hides a
|
|
308
|
+
notice with the fingerprint it names. The consent record follows the rules
|
|
309
|
+
below.
|
|
310
|
+
|
|
311
|
+
The consent record is stored twice: as a cookie, which a server render reads,
|
|
312
|
+
and in localStorage. The cookie is authoritative. A well-formed cookie wins
|
|
313
|
+
even when it has expired, so a local copy can never bring back a grant the
|
|
314
|
+
cookie no longer carries.
|
|
315
|
+
|
|
316
|
+
A browser can still drop a cookie write, for example when the record grows past
|
|
317
|
+
the cookie size limit, while localStorage takes it. A denial in the local copy
|
|
318
|
+
that is newer than the cookie's decision for that category is therefore applied
|
|
319
|
+
on top of the cookie. A newer local grant is not, so a dropped cookie write only
|
|
320
|
+
ever leaves the visitor with less permission. A server render sees only the
|
|
321
|
+
cookie; after a dropped write that carried a denial, the browser is the stricter
|
|
322
|
+
of the two.
|
|
323
|
+
|
|
324
|
+
When the two copies were written under different clear epochs, each loses its
|
|
325
|
+
decisions from before the later epoch first, and the same rule applies to what
|
|
326
|
+
remains. A later epoch in the local copy never lets its grant replace a cookie
|
|
327
|
+
denial. The subject and IAB metadata come from the copy written under the later
|
|
328
|
+
epoch; a record written before the clear in force keeps its later decisions but
|
|
329
|
+
no subject.
|
|
330
|
+
|
|
331
|
+
A server render cannot know about a clear that never reached a cookie. If the
|
|
332
|
+
page could write localStorage but its cookie writes failed during
|
|
333
|
+
`clearRecords()` (cookies blocked for the page, or a cookie setter that
|
|
334
|
+
throws), the removal of the consent cookie failed too, and so did the epoch
|
|
335
|
+
cookie. The browser then applies the clear from localStorage, while a server
|
|
336
|
+
render still reads the old consent cookie until the next successful cookie
|
|
337
|
+
write. The same applies to a consent cookie set with a different `domain` than
|
|
338
|
+
the current `storageConfig` uses, which the clear cannot remove.
|
|
339
|
+
|
|
340
|
+
### Ordering with pending writes
|
|
341
|
+
|
|
342
|
+
A tab writes its own choices in a later task, not during the click. Before it
|
|
343
|
+
reads storage, it lands its own queued writes, so a reconciliation never undoes
|
|
344
|
+
the visitor's latest action in that tab. A queued write follows the same rules
|
|
345
|
+
as a read: it stores the per-category merge of its choice and the stored one,
|
|
346
|
+
the union of directives, and never replaces a newer notice or vendor record. It
|
|
347
|
+
keeps the stored subject unless this tab identified a different user.
|
|
348
|
+
When a slow save response returns a server subject id, the tab adds it only to
|
|
349
|
+
the record it wrote; it does not recreate records another tab cleared or
|
|
350
|
+
overwrite another tab's newer choice.
|
|
351
|
+
|
|
352
|
+
Two tabs that write at the same moment can both read storage before either
|
|
353
|
+
writes, and the later write can then drop the other tab's category or privacy
|
|
354
|
+
directive. The tab whose decision was dropped still holds it, and on its next
|
|
355
|
+
reconciliation it writes it back, merged with what storage holds. It does so
|
|
356
|
+
only for decisions it actually stored itself, never for one a clear voided or
|
|
357
|
+
another tab replaced with a newer one. A directive write stores the directives
|
|
358
|
+
it kept from storage too, so if another write drops one of those, this tab
|
|
359
|
+
restores it and applies it as well.
|
|
360
|
+
|
|
361
|
+
If another tab's change reaches this tab while one of its saves is pending, the
|
|
362
|
+
save depends on how far it got. A save not yet sent is dropped. A request
|
|
363
|
+
already sent still reaches the backend, which may record it; this tab ignores
|
|
364
|
+
the response, so it queues no retry and applies no subject id from it. A save
|
|
365
|
+
already queued for retry keeps its original decision time.
|
|
366
|
+
|
|
367
|
+
### Reconcile yourself or turn it off
|
|
368
|
+
|
|
369
|
+
Browsers send no event for a change made in the same document, or for a cookie
|
|
370
|
+
rewritten without a localStorage change, such as a `Set-Cookie` response header
|
|
371
|
+
or another subdomain sharing the cookie. The next focus or visibility change
|
|
372
|
+
picks it up. To apply it at once, call the method yourself:
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
// Runtime owners: returns true when any record changed.
|
|
376
|
+
runtime.reconcileStorage();
|
|
377
|
+
|
|
378
|
+
// Kernel owners with createPersistence from c15t/modules/persistence:
|
|
379
|
+
persistence.reconcile();
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
To keep storage but stop automatic reconciliation, pass `sync: false` in the
|
|
383
|
+
persistence options, for example `createConsentRuntime({ persistence: { sync:
|
|
384
|
+
false } })` or `createPersistence({ kernel, sync: false })`. The manual methods
|
|
385
|
+
still work.
|
|
386
|
+
|
|
387
|
+
`dispose()` removes the listeners and cancels a scheduled reconciliation.
|
|
@@ -30,6 +30,18 @@ in your application while consent writes still go to Inth. Regular backend
|
|
|
30
30
|
browser setup. See [data fetching and transports](./data-fetching.md) for
|
|
31
31
|
the comparison, including custom transports and offline mode.
|
|
32
32
|
|
|
33
|
+
Manifest resolution removes the per-visitor `/init` request, and with it the
|
|
34
|
+
backend's only count of visitors. The server adapters replace it with a session
|
|
35
|
+
report: after each resolution, on a server-rendered page or the same-origin
|
|
36
|
+
init route, the host posts a small report to the backend's `POST /sessions`,
|
|
37
|
+
server-to-server and detached from the response. The browser makes no request
|
|
38
|
+
and the report stores no identity; the visitor's IP and user agent travel as
|
|
39
|
+
forwarded headers under the backend's usual IP handling. Each report is one
|
|
40
|
+
resolution; a page view can produce a `render` and a `route` report, and the
|
|
41
|
+
consuming side groups them into sessions by address and user agent within a
|
|
42
|
+
window. Static output resolves in the browser and sends none. Set `reportSessions: false` on an adapter to turn
|
|
43
|
+
it off.
|
|
44
|
+
|
|
33
45
|
## Match initialization to your application output
|
|
34
46
|
|
|
35
47
|
| Application output | Initial state | Required setup |
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Share consent controls across frameworks
|
|
3
|
+
description: Use the same c15t script lifecycle, external consent source, and
|
|
4
|
+
event controls in every framework.
|
|
5
|
+
group: guides
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Choose one consent authority
|
|
9
|
+
|
|
10
|
+
c15t owns script loading, consent gates, lifecycle callbacks, and optional cleanup
|
|
11
|
+
through its framework-independent core. React, Vue, Svelte, Astro, and the browser
|
|
12
|
+
client connect to that core. Next.js and TanStack Start use the React provider;
|
|
13
|
+
Nuxt uses Vue; SvelteKit uses Svelte. None requires Astro for these controls.
|
|
14
|
+
|
|
15
|
+
With c15t as the consent authority, keep your framework's hosted, self-hosted, or
|
|
16
|
+
offline setup. Offline mode keeps choices in the browser; a hosted backend can
|
|
17
|
+
store records. The script SDK and event dispatcher work with either mode.
|
|
18
|
+
|
|
19
|
+
To keep another CMP, provide a `consentSource`. It owns the banner, preferences,
|
|
20
|
+
records, expiration, privacy signals, and category mapping. c15t follows its
|
|
21
|
+
current permissions without creating a second choice record. Optional categories
|
|
22
|
+
start denied, including during SSR. An unavailable source or a failed read denies
|
|
23
|
+
them again. Initialize the CMP independently, or register its loader as an
|
|
24
|
+
explicitly early-loading script so it does not wait for its own consent.
|
|
25
|
+
|
|
26
|
+
The source contract is exported from `@c15t/core/runtime`:
|
|
27
|
+
|
|
28
|
+
```ts title="src/consent-source.ts — adapter skeleton"
|
|
29
|
+
import type { ExternalConsentSource } from 'c15t/runtime';
|
|
30
|
+
import { provider } from './existing-cmp';
|
|
31
|
+
|
|
32
|
+
export const consentSource: ExternalConsentSource = {
|
|
33
|
+
getPermissions: () => provider.getCategoryPermissions(),
|
|
34
|
+
subscribe: (notify) => provider.subscribe(notify),
|
|
35
|
+
openPreferences: () => provider.openPreferences(),
|
|
36
|
+
};
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`provider` is your application's adapter for the existing CMP, not a c15t export.
|
|
40
|
+
Map its categories to c15t's `measurement`, `marketing`, `functionality`, and
|
|
41
|
+
`experience` booleans. Missing categories are denied; `necessary` stays true.
|
|
42
|
+
Return `null` until the CMP is ready. `subscribe` must notify on initialization,
|
|
43
|
+
changes, withdrawal, and expiration, and return a function that removes listeners.
|
|
44
|
+
Keep browser access inside these functions so importing the adapter during SSR is safe.
|
|
45
|
+
|
|
46
|
+
If `subscribe` throws, c15t reports the failure through `callbacks.onError` and
|
|
47
|
+
continues startup with optional categories denied. The failed connection ignores
|
|
48
|
+
later notifications and does not forward preference requests. Recreate the owner
|
|
49
|
+
after the CMP is available to connect again.
|
|
50
|
+
|
|
51
|
+
## Pass the controls to your framework
|
|
52
|
+
|
|
53
|
+
The `consentSource` option is available at these registration points. Register your SDK configurations through the same owner's
|
|
54
|
+
`scripts` option. Do not create an additional runtime beside an existing provider.
|
|
55
|
+
|
|
56
|
+
| Framework | Registration point |
|
|
57
|
+
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
58
|
+
| React | `ConsentProvider options={{ mode, consentSource, scripts }}` |
|
|
59
|
+
| Next.js / TanStack Start | `ConsentRoot options={{ consentSource }}` with `scripts` as a top-level prop; `ConsentProvider` also accepts the React options |
|
|
60
|
+
| Vue | `app.use(c15tVue, { consentSource, scripts })` |
|
|
61
|
+
| Nuxt | `app.config.ts` → `c15t: { consentSource, scripts }` |
|
|
62
|
+
| Svelte / SvelteKit | `ConsentManagerProvider` options or corresponding top-level props |
|
|
63
|
+
| Astro | `clientOptions.consentSource` |
|
|
64
|
+
| Browser client | `createConsentClient({ consentSource, scripts })` |
|
|
65
|
+
| Headless JavaScript / Solid | `createConsentRuntime({ mode, consentSource, scripts })` |
|
|
66
|
+
|
|
67
|
+
For example, a React client boundary can use the source directly:
|
|
68
|
+
|
|
69
|
+
```tsx title="src/Consent.tsx"
|
|
70
|
+
'use client';
|
|
71
|
+
|
|
72
|
+
import type { ReactNode } from 'react';
|
|
73
|
+
import { ConsentProvider, offline, useSetActiveUI } from 'c15t/react';
|
|
74
|
+
import { googleTagManager } from '@c15t/scripts/google-tag-manager';
|
|
75
|
+
import { consentSource } from './consent-source';
|
|
76
|
+
|
|
77
|
+
const mode = offline();
|
|
78
|
+
const scripts = [googleTagManager({ id: 'GTM-EXAMPLE' })];
|
|
79
|
+
|
|
80
|
+
function Preferences() {
|
|
81
|
+
const setActiveUI = useSetActiveUI();
|
|
82
|
+
return <button type="button" onClick={() => setActiveUI('dialog')}>Privacy settings</button>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function Consent({ children }: { children: ReactNode }) {
|
|
86
|
+
return (
|
|
87
|
+
<ConsentProvider options={{ mode, consentSource, scripts }}>
|
|
88
|
+
{children}
|
|
89
|
+
<Preferences />
|
|
90
|
+
</ConsentProvider>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Replace the container ID. `mode` remains required by the provider API, but an
|
|
96
|
+
external source bypasses c15t backend initialization and persistence. Omit c15t's
|
|
97
|
+
banner and dialog. Preference links, hooks, and `kernel.set.activeUI('dialog')`
|
|
98
|
+
delegate to the source. Preference-opening failures reach `callbacks.onError`;
|
|
99
|
+
`kernel.commands.save()` rejects instead of recording a second choice.
|
|
100
|
+
|
|
101
|
+
In Next.js, construct the source in the client boundary; do not pass functions
|
|
102
|
+
from a Server Component. Existing `ConsentRoot` state can stay serializable, but
|
|
103
|
+
c15t records do not establish external permissions. A client-owned
|
|
104
|
+
`ConsentProvider` avoids a redundant server consent fetch for external-only setups.
|
|
105
|
+
For Nuxt, use `app.config.ts` for the functions, not serialized public runtime
|
|
106
|
+
configuration; the module skips its consent fetch and record hydration when a
|
|
107
|
+
source is configured. Browser access remains deferred until mount.
|
|
108
|
+
|
|
109
|
+
Solid currently ships UI primitives, not a lifecycle provider. Create the core
|
|
110
|
+
runtime once, call `start()` from `onMount`, and `dispose()` from `onCleanup`.
|
|
111
|
+
Other frameworks' providers perform that lifecycle automatically. A provider given
|
|
112
|
+
an existing `runtime` borrows it: configure controls on the runtime owner, which
|
|
113
|
+
also owns starting and disposing it.
|
|
114
|
+
|
|
115
|
+
## Handle withdrawal and application events
|
|
116
|
+
|
|
117
|
+
When the source withdraws an optional category that was granted, c15t reloads
|
|
118
|
+
the page, as it does when a visitor revokes consent in c15t's own UI. Removing a
|
|
119
|
+
script element cannot stop JavaScript that already ran, so the reload starts a
|
|
120
|
+
page with only permitted code. The reload runs in the next task, after
|
|
121
|
+
synchronous consent callbacks and `onBeforeConsentRevocationReload`. Disposing
|
|
122
|
+
the owner cancels a pending reload.
|
|
123
|
+
|
|
124
|
+
c15t cannot tell a visitor's withdrawal from expiry or a reset in the CMP, so
|
|
125
|
+
any notification that turns off a granted category reloads. The CMP owns
|
|
126
|
+
persistence: store the new decision before notifying c15t, or the reloaded page
|
|
127
|
+
reads the old one. Set `reloadOnConsentRevoked: false` to handle withdrawal
|
|
128
|
+
yourself. c15t still updates gates and calls script consent callbacks.
|
|
129
|
+
|
|
130
|
+
`createEventDispatcher` from `@c15t/scripts/events` also has no framework dependency:
|
|
131
|
+
|
|
132
|
+
```ts title="src/events.ts — attach to an existing runtime"
|
|
133
|
+
import { createEventDispatcher } from '@c15t/scripts/events';
|
|
134
|
+
|
|
135
|
+
const events = createEventDispatcher({
|
|
136
|
+
scripts,
|
|
137
|
+
getSnapshot: () => runtime.kernel.getSnapshot(),
|
|
138
|
+
});
|
|
139
|
+
events.track('docs_search', { resultCount: 4 });
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Here `scripts` is the same configuration registered with the existing `runtime`.
|
|
143
|
+
For a provider-owned kernel, use that kernel's live `getSnapshot` instead. The
|
|
144
|
+
dispatcher sends only to configured integrations with supported event APIs when
|
|
145
|
+
measurement and the script's own consent condition allow it. It discards denied
|
|
146
|
+
events, isolates vendor failures, and deduplicates explicitly configured SPA
|
|
147
|
+
pageviews. It does not subscribe to a framework router: notify it from your
|
|
148
|
+
router's navigation hook. Use vendor-specific helpers for APIs outside the shared
|
|
149
|
+
event contract.
|
|
150
|
+
|
|
151
|
+
## Verify the integration
|
|
152
|
+
|
|
153
|
+
Test an undecided visitor, an existing grant, withdrawal, expiry, and a source
|
|
154
|
+
that cannot be read. Check that only permitted scripts execute, the preference
|
|
155
|
+
control opens the existing CMP, c15t does not render another banner or persist
|
|
156
|
+
another receipt, and unmounting removes the source listeners. Test your actual
|
|
157
|
+
CMP category mapping and tag-manager container; shared gates do not validate
|
|
158
|
+
those configurations or establish compliance by themselves.
|
|
@@ -196,7 +196,7 @@ clientEntrypoint: fileURLToPath(new URL('./src/c15t.client.ts', import.meta.url)
|
|
|
196
196
|
Export the scripts from that module:
|
|
197
197
|
|
|
198
198
|
```ts title="src/c15t.client.ts"
|
|
199
|
-
import type { C15tClientOptionsExtension } from '
|
|
199
|
+
import type { C15tClientOptionsExtension } from 'c15t/astro';
|
|
200
200
|
import { scripts } from './consent-scripts';
|
|
201
201
|
|
|
202
202
|
export default { scripts } satisfies C15tClientOptionsExtension;
|
|
@@ -194,7 +194,7 @@ clientEntrypoint: fileURLToPath(new URL('./src/c15t.client.ts', import.meta.url)
|
|
|
194
194
|
Export the scripts from that module:
|
|
195
195
|
|
|
196
196
|
```ts title="src/c15t.client.ts"
|
|
197
|
-
import type { C15tClientOptionsExtension } from '
|
|
197
|
+
import type { C15tClientOptionsExtension } from 'c15t/astro';
|
|
198
198
|
import { scripts } from './consent-scripts';
|
|
199
199
|
|
|
200
200
|
export default { scripts } satisfies C15tClientOptionsExtension;
|
|
@@ -194,7 +194,7 @@ clientEntrypoint: fileURLToPath(new URL('./src/c15t.client.ts', import.meta.url)
|
|
|
194
194
|
Export the scripts from that module:
|
|
195
195
|
|
|
196
196
|
```ts title="src/c15t.client.ts"
|
|
197
|
-
import type { C15tClientOptionsExtension } from '
|
|
197
|
+
import type { C15tClientOptionsExtension } from 'c15t/astro';
|
|
198
198
|
import { scripts } from './consent-scripts';
|
|
199
199
|
|
|
200
200
|
export default { scripts } satisfies C15tClientOptionsExtension;
|
|
@@ -31,6 +31,11 @@ Replace the example URL and implement the vendor's initialization. This is a
|
|
|
31
31
|
loader template, not a functioning analytics SDK. The script stays blocked
|
|
32
32
|
while measurement permission is denied.
|
|
33
33
|
|
|
34
|
+
Add `vendor: 'example-analytics'` and declare the vendor in the runtime's
|
|
35
|
+
`vendors` option or in the backend manifest when visitors should be able to
|
|
36
|
+
turn this vendor off inside a granted category. See
|
|
37
|
+
[granular consent](./granular-consent.md).
|
|
38
|
+
|
|
34
39
|
## Define revocation deliberately
|
|
35
40
|
|
|
36
41
|
`onConsentChange` receives current permission information. Use it to update the
|