@cookieyes/core 0.1.0 → 0.2.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 CHANGED
@@ -1,8 +1,58 @@
1
- # @cookieyes/core
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/cookieyes/cookieyes/main/.github/assets/banner-dark.svg">
4
+ <img src="https://raw.githubusercontent.com/cookieyes/cookieyes/main/.github/assets/banner-light.svg" alt="CookieYes consent banner — powered by @cookieyes/core" width="820">
5
+ </picture>
6
+ </p>
2
7
 
3
- The headless consent engine powering the CookieYes SDK. Zero UI, zero runtime dependencies. This is the single source of truth for all consent logic — every framework adapter imports from this package exclusively.
8
+ <h1 align="center">@cookieyes/core</h1>
4
9
 
5
- ## Install
10
+ <p align="center"><strong>The headless consent engine powering the CookieYes SDK.</strong></p>
11
+
12
+ <p align="center">Zero UI, zero runtime dependencies — the single source of truth for all consent logic. Every framework adapter imports from this package.</p>
13
+
14
+ <p align="center">
15
+ <a href="https://www.npmjs.com/package/@cookieyes/core"><img src="https://img.shields.io/npm/v/@cookieyes/core" alt="npm version"></a>
16
+ <a href="https://www.npmjs.com/package/@cookieyes/core"><img src="https://img.shields.io/npm/dw/@cookieyes/core" alt="npm downloads"></a>
17
+ <a href="https://github.com/cookieyes/cookieyes/actions/workflows/ci.yml"><img src="https://github.com/cookieyes/cookieyes/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
18
+ <a href="./LICENSE"><img src="https://img.shields.io/npm/l/@cookieyes/core" alt="license"></a>
19
+ </p>
20
+
21
+ <p align="center">
22
+ <a href="#quick-start">Quick start</a> ·
23
+ <a href="#api">API</a> ·
24
+ <a href="#troubleshooting">Troubleshooting</a> ·
25
+ <a href="https://github.com/cookieyes/cookieyes/blob/main/docs/configuration.md">Docs</a>
26
+ </p>
27
+
28
+ ---
29
+
30
+ ## Building a UI? Use an adapter
31
+
32
+ `@cookieyes/core` is the headless engine — it has **no components**. Most developers want a
33
+ framework adapter, which bundles core plus ready-made banner/dialog UI:
34
+
35
+ - **[`@cookieyes/react`](https://github.com/cookieyes/cookieyes/tree/main/sdk/react)** — React components + hooks.
36
+ - **[`@cookieyes/nextjs`](https://github.com/cookieyes/cookieyes/tree/main/sdk/nextjs)** — Next.js App Router / Pages Router.
37
+
38
+ Use `@cookieyes/core` directly only for vanilla JS, a custom framework, or your own UI.
39
+
40
+ ## Key features
41
+
42
+ - **Headless engine** — all consent logic, no UI, no framework assumptions.
43
+ - **Zero dependencies** — nothing pulled into your bundle but the engine itself.
44
+ - **Offline or self-hosted** — cookie-only, or POST every decision to your own backend.
45
+ - **GDPR & CCPA** — regulation-aware consent state and payloads.
46
+ - **Cookie utilities** — read/write/parse the consent cookie directly if you need to.
47
+
48
+ ## Prerequisites
49
+
50
+ - **Node.js** ≥ 20
51
+ - A JavaScript environment with `document`/`window` (browser or SSR with a DOM). No framework required.
52
+
53
+ ## Quick start
54
+
55
+ **1. Install**
6
56
 
7
57
  ```bash
8
58
  npm install @cookieyes/core
@@ -11,57 +61,66 @@ yarn add @cookieyes/core
11
61
  bun add @cookieyes/core
12
62
  ```
13
63
 
14
- ## Usage
64
+ **2. Initialise the runtime**
65
+
66
+ > **Which API should I use?** `consentStore.subscribe` is the recommended way to
67
+ > read consent outside React. See the [shared decision tree](../../docs/which-api-should-i-use.md)
68
+ > if you're not sure which API applies to your situation — core also exposes a
69
+ > handful of lower-level options (see [Low-level / advanced API](#low-level--advanced-api))
70
+ > for specific edge cases.
15
71
 
16
- The recommended entry point is `getOrCreateConsentRuntime()`. It returns a
17
- process-wide singleton with a `consentStore` (reactive state) and a
18
- `consentManager` (imperative API).
72
+ `initCookieYes()` (an alias of `getOrCreateConsentRuntime()`) returns a process-wide singleton
73
+ with a `consentStore` (reactive state) and a `consentManager` (imperative API).
19
74
 
20
75
  ```ts
21
- import { getOrCreateConsentRuntime } from "@cookieyes/core";
76
+ import { initCookieYes } from "@cookieyes/core";
22
77
 
23
- const { consentManager, consentStore } = getOrCreateConsentRuntime({
24
- mode: "offline", // "offline" (cookie-only) | "self-hosted"
25
- overrides: { regulation: "GDPR" }, // "GDPR" | "CCPA" | "DEFAULT"
26
- colorScheme: "system", // "light" | "dark" | "system"
78
+ const { consentManager, consentStore } = initCookieYes({
79
+ mode: "cookie-only", // "cookie-only" | "self-hosted"
80
+ regulation: "GDPR", // "GDPR" | "CCPA" | "DEFAULT"
81
+ colorScheme: "system", // "light" | "dark" | "system"
27
82
  });
83
+ ```
84
+
85
+ **3. React to consent changes**
28
86
 
29
- // React to every saved state change
87
+ ```ts
88
+ // consentStore.subscribe fires on every state change — category saves,
89
+ // transient preference-dialog toggles, and the dialog opening/closing.
90
+ // That's the right level for "should this script run right now?" checks:
30
91
  const unsubscribe = consentStore.subscribe((state) => {
31
92
  if (state.has("analytics")) {
32
93
  // load analytics scripts (gtag, Mixpanel, …)
33
94
  }
34
- if (state.has("advertisement")) {
35
- // load ad scripts (Meta Pixel, Google Ads, …)
36
- }
37
95
  });
38
96
 
39
- // React only to saved preference changes (not transient UI toggles)
97
+ // Only saved preference changes (not transient UI toggles)
40
98
  consentStore
41
99
  .getState()
42
100
  .subscribeToConsentChanges(({ allowedCategories, deniedCategories }) => {
43
- console.log("Allowed:", allowedCategories);
44
- console.log("Denied:", deniedCategories);
101
+ console.log("Allowed:", allowedCategories, "Denied:", deniedCategories);
45
102
  });
103
+ ```
46
104
 
47
- // Imperative actions
48
- consentStore.getState().has("analytics"); // → boolean
49
- consentStore.getState().saveConsents("all"); // accept all
50
- consentStore.getState().saveConsents("necessary"); // reject all (necessary only)
105
+ **4. Drive it imperatively**
106
+
107
+ ```ts
108
+ consentStore.getState().has("analytics"); // → boolean
109
+ consentStore.getState().saveConsents("all"); // accept all
110
+ consentStore.getState().saveConsents("necessary"); // reject all (necessary only)
51
111
  consentStore.getState().setConsent("analytics", true);
52
112
  consentManager.showPreferences(); // open the preferences dialog
53
113
  consentManager.resetConsent(); // clear + re-prompt
54
-
55
114
  unsubscribe();
56
115
  ```
57
116
 
58
117
  ### Self-hosted mode
59
118
 
60
- Pass `mode: "self-hosted"` with either a `backendURL` (the SDK POSTs a
61
- `ConsentPayload` to it) or a custom `backend` adapter for full control:
119
+ `mode: "self-hosted"` POSTs a `ConsentPayload` to your endpoint on every decision. Provide
120
+ either an `apiUrl` or a custom `backend` adapter for full control:
62
121
 
63
122
  ```ts
64
- getOrCreateConsentRuntime({
123
+ initCookieYes({
65
124
  mode: "self-hosted",
66
125
  backend: {
67
126
  async persist(payload) {
@@ -75,58 +134,323 @@ getOrCreateConsentRuntime({
75
134
  });
76
135
  ```
77
136
 
137
+ ### Deprecated: `mode: "offline"`
138
+
139
+ `"offline"` was renamed to `"cookie-only"` — same behavior, clearer name. It
140
+ still works today and logs a one-time console warning, and will be removed
141
+ 3 releases from now.
142
+
143
+ ```diff
144
+ getOrCreateConsentRuntime({
145
+ - mode: "offline",
146
+ + mode: "cookie-only",
147
+ });
148
+ ```
149
+
78
150
  ## API
79
151
 
80
- ### `getOrCreateConsentRuntime(options)`
152
+ ### `initCookieYes(config)` / `getOrCreateConsentRuntime(config)`
81
153
 
82
- Returns `{ consentManager, consentStore }` (a singleton — call
83
- `resetConsentRuntime()` to clear it, primarily for tests).
154
+ Both accept the canonical `CookieYesConfig` and return `{ consentManager, consentStore }` (a
155
+ singleton — call `resetConsentRuntime()` to clear it, mainly for tests). `initCookieYes` is an
156
+ alias provided so one setup name reads across every package. Every option is documented once in
157
+ **[Configuration](https://github.com/cookieyes/cookieyes/blob/main/docs/configuration.md)**.
158
+ Migrating off the deprecated `overrides.regulation` / `backendURL` keys? See the
159
+ **[migration guide](https://github.com/cookieyes/cookieyes/blob/main/docs/migration/builder-to-config.md)**.
84
160
 
85
- **`options`** (`ConsentRuntimeOptions`):
161
+ **`config`** (`CookieYesConfig`):
86
162
 
87
163
  | Option | Type | Notes |
88
164
  |--------|------|-------|
89
- | `mode` | `"offline" \| "self-hosted"` | **Required.** |
90
- | `backendURL` | `string` | Self-hosted: endpoint the payload is POSTed to. |
165
+ | `mode` | `"cookie-only" \| "self-hosted"` | **Required.** See [Deprecated](#deprecated-mode-offline) for the retired `"offline"` name. |
166
+ | `regulation` | `"GDPR" \| "CCPA" \| "DEFAULT"` | Force the applicable regulation. (The deprecated `overrides.regulation` alias still works.) |
167
+ | `apiUrl` | `string` | Self-hosted: endpoint the payload is POSTed to. (The deprecated `backendURL` alias still works.) |
91
168
  | `backend` | `ConsentBackend` | Self-hosted: custom `persist(payload)` adapter. |
92
169
  | `apiKey` | `string` | Optional auth key. |
93
- | `overrides.regulation` | `"GDPR" \| "CCPA" \| "DEFAULT"` | Force the applicable regulation. |
94
170
  | `colorScheme` | `"light" \| "dark" \| "system"` | |
95
171
  | `theme` | `ThemeConfig` | Color / spacing tokens. |
96
172
  | `i18n` | `I18nConfig` | Translation messages / locale. |
97
173
  | `networkBlocker` | `NetworkBlockerConfig` | Block network requests by category. |
98
174
  | `reloadOnRevoke` | `boolean` | Reload the page when consent is revoked. |
99
- | `onConsentReady` / `onConsentUpdate` | `(state) => void` | Lifecycle callbacks. |
175
+ | `onConsentReady` / `onConsentUpdate` | `(state) => void` | Low-level lifecycle callbacks — see below. |
100
176
 
101
177
  **`consentStore`** — `subscribe(listener)` and `getState()`. State
102
178
  (`ConsentStoreState`) includes `consentId`, `hasActed`, `categories`,
103
179
  `regulation`, `lastRenewed`, `activeUI`, plus the methods `has()`,
104
- `saveConsents()`, `setConsent()`, and `subscribeToConsentChanges()`.
180
+ `saveConsents()`, `setConsent()`, and the low-level `subscribeToConsentChanges()`
181
+ (below).
105
182
 
106
183
  ### `createConsentManager(config)` (low-level)
107
184
 
108
- The underlying manager, if you want to bypass the store. Returns a
109
- `ConsentManager` with:
185
+ The underlying manager, if you want to bypass the store. Returns a `ConsentManager` with state
186
+ (`consentId`, `hasActed`, `categories`, `regulation`, `lastRenewed`, `isPreferencesOpen`) and
187
+ methods (`acceptAll()`, `rejectAll()`, `acceptSelected(cats)`, `updateCategory(cat, val)`,
188
+ `savePreferences()`, `resetConsent()`, `showPreferences()`, `hidePreferences()`, `subscribe(fn)`,
189
+ `registerScript(entry)`).
190
+
191
+ > The applicable regulation comes from your top-level `regulation` config (the deprecated
192
+ > `overrides.regulation` alias still works) and defaults to `"DEFAULT"`. The core engine does not
193
+ > perform IP-based geo-detection.
194
+
195
+ ## Low-level / advanced API
196
+
197
+ You shouldn't need these for a typical integration — each exists for a
198
+ specific narrower situation than `consentStore.subscribe`:
199
+
200
+ | API | Use when |
201
+ |---|---|
202
+ | `subscribeToConsentChanges(listener)` (on `consentStore.getState()`) | You only care about *saved* consent decisions (accept/reject/save), not every transient toggle or dialog open/close that `subscribe` also reports. |
203
+ | `onConsentReady` (config option) | You need a one-time callback right after the initial state is known — e.g. conditionally loading analytics on first load — rather than an ongoing subscription. |
204
+ | `onConsentUpdate` (config option) | Like `subscribeToConsentChanges`, scoped to saved changes only, but registered once at config time instead of dynamically after mount. Prefer `subscribeToConsentChanges` unless you specifically need a config-time callback. |
205
+ | `createConsentManager(config)` | Bypasses `consentStore` entirely for direct access to the manager: `acceptAll()`, `rejectAll()`, `acceptSelected(cats)`, `updateCategory(cat, val)`, `savePreferences()`, `resetConsent()`, `showPreferences()`, `hidePreferences()`, `subscribe(fn)`, `registerScript(entry)`. `config` (`ConsentConfig`) accepts `regulation`, `colorScheme`, `theme`, `apiUrl`, `apiKey`, `backend`, `reloadOnRevoke`, `onConsentReady`, `onConsentUpdate`. |
206
+ | `parseCookie` / `serializeCookie` | Reading or writing the raw `cookieyes-consent` cookie directly — e.g. in a Next.js Server Component or route handler, where no live runtime or React hooks are available. |
207
+
208
+ ## Stopping tracking when consent is withdrawn
209
+
210
+ When a visitor revokes consent, the SDK stops tracking **without reloading the
211
+ page** — nothing they were doing (form input, scroll position, an open dialog)
212
+ is lost. There are three layers:
213
+
214
+ 1. **Network blocking** (`networkBlocker` / `blockNetwork`) — intercepts
215
+ `fetch`, `XMLHttpRequest`, **and `navigator.sendBeacon`** to blocked domains,
216
+ in real time, for as long as the page is open. `sendBeacon` matters because
217
+ GA4/Meta use it for exit/unload tracking that fetch/XHR interception misses.
218
+ 2. **Integration stop-handlers** (`integrations`) — call a vendor's own
219
+ documented "stop" API on revoke, and resume it on re-accept:
220
+
221
+ ```ts
222
+ getOrCreateConsentRuntime({
223
+ mode: "cookie-only",
224
+ integrations: [
225
+ { vendor: "meta" }, // fbq('consent','revoke'|'grant')
226
+ ],
227
+ });
228
+ ```
229
+
230
+ > **Google Analytics & Tag Manager are handled automatically** — you don't
231
+ > list them here. The SDK broadcasts Google Consent Mode v2 whenever a
232
+ > `dataLayer` is present (see [Google Consent Mode](#google-consent-mode-v2)
233
+ > below). You still set the **deny-by-default** state in your gtag snippet.
234
+ 3. **Your own scripts** (`customStopHandlers`) — for anything without a built-in
235
+ integration. Provide a clean `stop()`/`resume()`, or register it as
236
+ reload-only so revoking it shows the reload notice rather than silently
237
+ continuing to track:
238
+
239
+ ```ts
240
+ customStopHandlers: [
241
+ { id: "my-tool", category: "analytics", stop: () => window.myTool?.disable() },
242
+ { id: "legacy-widget", category: "advertisement", needsReload: true },
243
+ ]
244
+ ```
245
+
246
+ ### Vendor audit — which stop cleanly, which need a reload
247
+
248
+ | Vendor | Runtime stop | How |
249
+ |--------|-------------|-----|
250
+ | **Google Analytics 4 / Tag Manager** | ✅ automatic | Consent Mode v2 broadcast — no `integrations` entry needed (see [below](#google-consent-mode-v2)). |
251
+ | **Meta Pixel** | ✅ clean | `fbq('consent', 'revoke')` / `'grant'` |
252
+ | TikTok Pixel | ⚠️ reload | No runtime stop we could confidently verify; modelled as reload-only. |
253
+ | LinkedIn Insight Tag | ⚠️ reload | No documented runtime opt-out after load. |
254
+ | Hotjar | ⚠️ reload | No documented "stop after load"; gate before load instead. |
255
+ | Segment (analytics.js) | ⚠️ reload | No documented runtime "stop all"; gate `analytics.load()`. |
110
256
 
111
- - **State**: `consentId`, `hasActed`, `categories`, `regulation`, `lastRenewed`, `isPreferencesOpen`
112
- - **Methods**: `acceptAll()`, `rejectAll()`, `acceptSelected(cats)`, `updateCategory(cat, val)`, `savePreferences()`, `resetConsent()`, `showPreferences()`, `hidePreferences()`, `subscribe(fn)`, `registerScript(entry)`
257
+ "Reload" vendors surface the reload notice (below) on a genuine revoke — the SDK
258
+ never continues tracking them silently. Any of them can be upgraded to a clean
259
+ stop later (in `resolveBuiltInIntegration`) once a real runtime API is confirmed.
113
260
 
114
- `config` (`ConsentConfig`) accepts: `regulation`, `colorScheme`, `theme`,
115
- `apiUrl`, `apiKey`, `backend`, `reloadOnRevoke`, `onConsentReady`,
116
- `onConsentUpdate`.
261
+ ### Reload notice
117
262
 
118
- > The applicable regulation comes from your configuration
119
- > (`overrides.regulation` / `config.regulation`) and defaults to `"DEFAULT"`.
120
- > The core engine does not perform IP-based geo-detection.
263
+ If a revoked tool has no clean runtime stop, the manager computes
264
+ `manager.reloadNotice` (`{ required, reasons }`) automatically on revoke, with
265
+ `manager.dismissReloadNotice()` to clear it. The *state* is automatic; showing
266
+ it is up to you.
267
+
268
+ **If you configure any reload-only tool, surface this state to the visitor** —
269
+ otherwise a revoke that needs a reload is silent and that tool keeps running.
270
+ In React that means rendering the built-in `<ReloadNotice />` (dismissible,
271
+ `role="alert"`, wording via translations); it never reloads on its own. Outside
272
+ React, read `manager.reloadNotice.required` and render your own prompt.
273
+
274
+ ### `reloadOnRevoke` (legacy, off by default)
275
+
276
+ `reloadOnRevoke` performs a full page reload on revoke. It is **off by default**
277
+ — the clean stop-handlers above are the safe path. Turn it on only if you
278
+ explicitly want the old behavior; note it erases whatever the visitor was doing.
121
279
 
122
280
  ## Consent categories
123
281
 
282
+ By default the SDK ships the familiar five:
283
+
124
284
  `necessary` (always on), `functional`, `analytics`, `performance`, `advertisement`.
125
285
 
286
+ Configure nothing and you get exactly these, unchanged.
287
+
288
+ ### Defining your own categories
289
+
290
+ Pass a `categories` array to use your own taxonomy — rename, add, remove, or
291
+ restructure. Each entry is a [`CategoryDef`](./src/categories.ts):
292
+
293
+ ```ts
294
+ getOrCreateConsentRuntime({
295
+ mode: "cookie-only",
296
+ categories: [
297
+ { id: "essential", required: true, label: "Strictly Necessary" },
298
+ { id: "marketing", label: "Marketing & Ads",
299
+ gcm: ["ad_storage", "ad_user_data", "ad_personalization"] },
300
+ { id: "insights", label: "Product Insights",
301
+ gcm: ["analytics_storage"] },
302
+ ],
303
+ });
304
+ ```
305
+
306
+ - **`id`** — the stable key stored in the cookie and used everywhere (banner,
307
+ preferences UI, read APIs, `gate`/integration category names, events). Pick it
308
+ once and keep it stable; renaming an `id` is a taxonomy change (see below).
309
+ - **`required`** — the always-on, non-optional category. **Mark it explicitly** —
310
+ it is *never* inferred from the name `necessary`, so you can rename it freely.
311
+ At least one category must be `required: true`.
312
+ - **`label` / `description`** — shown in the preferences UI. For the five
313
+ built-in ids these fall back to the translation strings if omitted; for a
314
+ custom id with no `label`, the UI falls back to the `id` itself.
315
+ - **`gcm`** — which Google Consent Mode signals this category governs (see
316
+ [below](#google-consent-mode-v2)).
317
+
318
+ **Id rules.** An `id` must be a non-empty string, unique within the list, and
319
+ must not contain `,` or `:` or be one of the cookie's reserved keys (`consentid`,
320
+ `consent`, `action`, `tax`, `lastRenewedDate`) — those would corrupt the stored
321
+ cookie. Otherwise any string is fine (spaces and unicode are OK).
322
+
323
+ **Invalid config is safe.** If the array is empty, has duplicate/reserved/invalid
324
+ ids, or has no `required` category, the SDK logs a `console.warn` and falls back
325
+ to the built-in five rather than leaving you a broken or unprotected banner.
326
+
327
+ ### Changing your taxonomy later (upgrade behaviour)
328
+
329
+ Every stored consent record is stamped with a **taxonomy signature** (a hash of
330
+ the ids, `required` flags, and `gcm` mappings — visible as `taxonomyHash` on the
331
+ snapshot and `tax:` in the cookie). This lets the SDK tell what a returning
332
+ visitor actually agreed to.
333
+
334
+ - **Signature unchanged** → the returning visitor's stored consent is reused
335
+ silently. No re-prompt.
336
+ - **Signature changed** (you renamed/added/removed a category or changed a `gcm`
337
+ mapping) → the SDK **re-requests consent**: it discards the stale record and
338
+ shows the banner again, so the visitor consents against the taxonomy that's
339
+ actually in effect. This is the one documented outcome for a taxonomy change.
340
+ - **Legacy cookies** written before this feature (no `tax:` stamp) are treated
341
+ as the built-in five: if you're still on the default taxonomy they're honoured
342
+ as-is (returning visitors are **never** silently reset by upgrading the SDK);
343
+ if you've since moved to a custom taxonomy they re-request like any other
344
+ change.
345
+
346
+ ## Google Consent Mode v2
347
+
348
+ If a Google `dataLayer` is present on the page, the SDK **broadcasts** all seven
349
+ Consent Mode v2 signals — on load and on every consent change — for every
350
+ visitor. This is what governs Google Analytics 4 and Tag Manager; you do **not**
351
+ register them under `integrations`.
352
+
353
+ Each signal is `granted` when any granted category maps to it (via its `gcm`
354
+ field), otherwise `denied`. `security_storage` is always `granted`. The built-in
355
+ five map like this:
356
+
357
+ | Category | GCM signals |
358
+ |----------|-------------|
359
+ | `necessary` | *(none — `security_storage` is always granted)* |
360
+ | `functional` | `functionality_storage`, `personalization_storage` |
361
+ | `analytics` | `analytics_storage` |
362
+ | `performance` | *(none)* |
363
+ | `advertisement` | `ad_storage`, `ad_user_data`, `ad_personalization` |
364
+
365
+ Under the hood the broadcast does the equivalent of:
366
+
367
+ ```js
368
+ dataLayer.push(["consent", "update", {
369
+ ad_storage: "denied",
370
+ ad_user_data: "denied",
371
+ ad_personalization: "denied",
372
+ analytics_storage: "granted",
373
+ functionality_storage: "granted",
374
+ personalization_storage: "granted",
375
+ security_storage: "granted",
376
+ }]);
377
+ ```
378
+
379
+ > **You still own the default.** Consent Mode requires a **deny-by-default**
380
+ > state set *before* your Google tags load — the SDK can't set it because it
381
+ > doesn't control that load order. Put it in your gtag bootstrap snippet:
382
+ >
383
+ > ```js
384
+ > gtag('consent', 'default', {
385
+ > ad_storage: 'denied',
386
+ > ad_user_data: 'denied',
387
+ > ad_personalization: 'denied',
388
+ > analytics_storage: 'denied',
389
+ > functionality_storage: 'denied',
390
+ > personalization_storage: 'denied',
391
+ > security_storage: 'granted',
392
+ > wait_for_update: 500,
393
+ > });
394
+ > ```
395
+ >
396
+ > Set all seven signals explicitly: deny the six consent-gated ones and grant
397
+ > `security_storage` (it's strictly necessary). Leaving any signal unspecified
398
+ > makes Google treat it as granted until the SDK's `update` fires, leaking it for
399
+ > that first moment. The SDK owns the `update`; you own the `default`.
400
+
401
+ To wire Consent Mode to a **custom** taxonomy, put the `gcm` field on whichever
402
+ of your categories should drive each signal — see the example under
403
+ [Defining your own categories](#defining-your-own-categories) (the `marketing`
404
+ and `insights` entries carry `gcm` mappings). A signal no category maps to
405
+ simply stays `denied`.
406
+
126
407
  ## Cookie
127
408
 
128
409
  Consent is persisted in the `cookieyes-consent` cookie (`SameSite=Lax`, `path=/`).
129
- Use `parseCookie` / `serializeCookie` from this package to read or write it directly.
410
+ It stores each category id as `id:yes|no`, plus a `tax:` stamp recording the
411
+ [taxonomy signature](#changing-your-taxonomy-later-upgrade-behaviour) that was in
412
+ effect when the consent was recorded. Use `parseCookie` / `serializeCookie` from
413
+ this package to read or write it directly.
414
+
415
+ ## Troubleshooting
416
+
417
+ **The runtime isn't initialising (or hooks/consumers see no state).**
418
+ `initCookieYes()` returns a **singleton** — the first call wins, later calls return the same
419
+ instance. Call it once at startup before anything reads consent. In tests, call
420
+ `resetConsentRuntime()` between cases or state leaks across them.
421
+
422
+ **Mode / config type errors.**
423
+ `CookieYesConfig` is a discriminated union on `mode`. Backend keys (`apiUrl`, `apiKey`,
424
+ `backend`) are only valid with `mode: "self-hosted"` — supplying them under `mode: "cookie-only"`
425
+ is a compile error. `mode: "self-hosted"` needs either `apiUrl` or a `backend` adapter.
426
+
427
+ **Consent doesn't persist between reloads.**
428
+ State lives in the `cookieyes-consent` cookie. Confirm it isn't blocked by a browser privacy
429
+ setting or extension, that you're on a `document`-bearing environment (not a bare Node worker),
430
+ and that you aren't calling `resetConsentRuntime()` on every load.
431
+
432
+ Still stuck? [Open an issue](https://github.com/cookieyes/cookieyes/issues).
433
+
434
+ ## Community & support
435
+
436
+ - [Open an issue](https://github.com/cookieyes/cookieyes/issues) — bug reports and feature requests.
437
+ - Email — [support@cookieyes.com](mailto:support@cookieyes.com).
438
+ - [Full documentation](https://github.com/cookieyes/cookieyes/blob/main/docs/configuration.md).
439
+
440
+ _(A community chat channel is on the roadmap.)_
441
+
442
+ ## Contributing
443
+
444
+ Contributions are welcome. Read our
445
+ [Contributing Guidelines](https://github.com/cookieyes/cookieyes/blob/main/CONTRIBUTING.md) and
446
+ [Code of Conduct](https://github.com/cookieyes/cookieyes/blob/main/CODE_OF_CONDUCT.md), then open
447
+ a pull request.
448
+
449
+ ### Security
450
+
451
+ Found a vulnerability? **Do not open a public issue** — follow our
452
+ [Security Policy](https://github.com/cookieyes/cookieyes/blob/main/SECURITY.md) and use GitHub's
453
+ private vulnerability reporting.
130
454
 
131
455
  ## License
132
456