@consentify/core 1.0.0 → 2.0.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,200 +1,292 @@
1
- ## @consentify/core
1
+ # @consentify/core
2
2
 
3
- Headless cookie consent SDK — zero-deps, TypeScript-first, SSR-safe. Stores a compact snapshot in a cookie and provides a minimal, strongly-typed API.
3
+ [![npm version](https://img.shields.io/npm/v/@consentify/core.svg)](https://www.npmjs.com/package/@consentify/core)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@consentify/core.svg)](https://www.npmjs.com/package/@consentify/core)
5
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/@consentify/core)](https://bundlephobia.com/package/@consentify/core)
6
+ [![license](https://img.shields.io/npm/l/@consentify/core.svg)](./LICENSE)
4
7
 
5
- ### Install
8
+ > Headless cookie consent SDK — zero dependencies, TypeScript-first, SSR-ready.
9
+
10
+ ## Why Consentify?
11
+
12
+ - **🪶 Lightweight** — Zero runtime dependencies, ~2KB minified + gzipped
13
+ - **🔒 Type-safe** — Full TypeScript support with inference for your categories
14
+ - **⚡ SSR-ready** — Separate server/client APIs that never touch the DOM on server
15
+ - **⚛️ React-ready** — Built-in `useSyncExternalStore` support for React 18+
16
+ - **🎯 Headless** — Bring your own UI, we handle the state
17
+ - **📋 Compliant** — Built for GDPR, CCPA, and similar regulations
18
+
19
+ ## Install
6
20
 
7
21
  ```bash
8
22
  npm install @consentify/core
23
+ # or
24
+ pnpm add @consentify/core
25
+ # or
26
+ yarn add @consentify/core
9
27
  ```
10
28
 
11
- ### Quick start
29
+ ## Quick Start
12
30
 
13
31
  ```ts
14
32
  import { createConsentify, defaultCategories } from '@consentify/core';
15
33
 
16
34
  const consent = createConsentify({
17
35
  policy: {
18
- // Prefer to set a stable identifier derived from your policy document/version.
19
- // If omitted, a deterministic hash of categories is used.
20
- identifier: 'policy-v1',
21
- categories: defaultCategories,
22
- },
23
- cookie: {
24
- name: 'consentify',
25
- path: '/',
26
- sameSite: 'Lax',
27
- secure: true,
36
+ identifier: 'v1.0',
37
+ categories: defaultCategories, // ['preferences', 'analytics', 'marketing', 'functional', 'unclassified']
28
38
  },
29
- // Cookie is canonical. Optionally mirror to localStorage for fast client reads.
30
- // storage: ['localStorage', 'cookie']
31
39
  });
32
40
 
33
- // Ask user... then set decisions
34
- consent.client.set({ analytics: true });
35
-
36
- // Query on client
37
- const canAnalytics = consent.client.get('analytics'); // boolean
38
- const state = consent.client.get(); // { decision: 'decided' | 'unset', snapshot? }
39
- ```
41
+ // Set user choices
42
+ consent.client.set({ analytics: true, marketing: false });
40
43
 
41
- ### SSR usage
44
+ // Check consent
45
+ if (consent.client.get('analytics')) {
46
+ loadAnalytics();
47
+ }
42
48
 
43
- Use the server API with a raw Cookie header. It never touches the DOM.
49
+ // Get full state
50
+ const state = consent.client.get();
51
+ // → { decision: 'decided', snapshot: { policy: '...', givenAt: '...', choices: {...} } }
52
+ // → { decision: 'unset' } (if no consent given yet)
53
+ ```
44
54
 
45
- ```ts
46
- // Read consent on the server
47
- const state = consent.server.get(request.headers.get('cookie'));
48
- const canAnalytics = state.decision === 'decided' && !!state.snapshot.choices.analytics;
55
+ ## React Integration
49
56
 
50
- // Write consent on the server (e.g., after a POST to your consent endpoint)
51
- const setCookieHeader = consent.server.set({ analytics: true }, request.headers.get('cookie'));
52
- // Then attach header: res.setHeader('Set-Cookie', setCookieHeader)
57
+ For React projects, use the [`@consentify/react`](https://www.npmjs.com/package/@consentify/react) package which provides a ready-to-use hook:
53
58
 
54
- // Clear consent cookie on the server
55
- const clearCookieHeader = consent.server.clear();
59
+ ```bash
60
+ npm install @consentify/react
56
61
  ```
57
62
 
58
- ### React integration
59
-
60
- The client API is designed to work seamlessly with React 18+ `useSyncExternalStore`:
61
-
62
63
  ```tsx
63
- import { useSyncExternalStore } from 'react';
64
- import { createConsentify, defaultCategories, type ConsentState, type DefaultCategory } from '@consentify/core';
64
+ import { createConsentify, defaultCategories, useConsentify } from '@consentify/react';
65
65
 
66
- // Create once at module level
67
66
  const consent = createConsentify({
68
- policy: { categories: defaultCategories, identifier: 'policy-v1' },
67
+ policy: { identifier: 'v1.0', categories: defaultCategories },
69
68
  });
70
69
 
71
- // Custom hook for React
72
- function useConsent(): ConsentState<DefaultCategory> {
73
- return useSyncExternalStore(
74
- consent.client.subscribe,
75
- consent.client.get,
76
- consent.client.getServerSnapshot
77
- );
78
- }
79
-
80
- // Usage in components
81
- function ConsentBanner() {
82
- const state = useConsent();
70
+ function CookieBanner() {
71
+ const state = useConsentify(consent);
83
72
 
84
73
  if (state.decision === 'decided') return null;
85
74
 
86
75
  return (
87
- <div>
88
- <p>We use cookies to improve your experience.</p>
89
- <button onClick={() => consent.client.set({ analytics: true, marketing: true })}>
76
+ <div className="cookie-banner">
77
+ <p>We use cookies to enhance your experience.</p>
78
+ <button onClick={() => consent.client.set({
79
+ analytics: true,
80
+ marketing: true,
81
+ preferences: true
82
+ })}>
90
83
  Accept All
91
84
  </button>
92
- <button onClick={() => consent.client.set({ analytics: false, marketing: false })}>
93
- Reject Optional
85
+ <button onClick={() => consent.client.set({
86
+ analytics: false,
87
+ marketing: false,
88
+ preferences: false
89
+ })}>
90
+ Essential Only
94
91
  </button>
95
92
  </div>
96
93
  );
97
94
  }
98
95
  ```
99
96
 
100
- ### API
101
-
102
- #### createConsentify(init)
103
-
104
- Creates a consent manager bound to a `policy`. Cookie is the canonical store; you can optionally mirror to `localStorage` for client-side speed. Returns:
97
+ <details>
98
+ <summary>Manual integration with useSyncExternalStore</summary>
105
99
 
106
- - `policy`: `{ categories: string[]; identifier: string }`
107
- - `client`:
108
- - `get(): ConsentState<T>` — returns cached consent state `{ decision: 'decided', snapshot } | { decision: 'unset' }`.
109
- - `get(category: 'necessary' | T): boolean` — boolean check; `'necessary'` always returns `true`.
110
- - `set(choices: Partial<Choices<T>>): void` — merges and saves; writes only if changed; notifies subscribers.
111
- - `clear(): void` — removes stored consent (cookie and any mirror); notifies subscribers.
112
- - `subscribe(callback: () => void): () => void` — subscribe to state changes (for React `useSyncExternalStore`).
113
- - `getServerSnapshot(): ConsentState<T>` — returns `{ decision: 'unset' }` for SSR hydration.
114
- - `server`:
115
- - `get(cookieHeader: string | null | undefined): ConsentState<T>` — raw Cookie header in, state out.
116
- - `set(choices: Partial<Choices<T>>, currentCookieHeader?: string): string` — returns `Set-Cookie` header string.
117
- - `clear(): string` — returns `Set-Cookie` header string to delete the cookie.
100
+ If you prefer not to add the React package, you can use `useSyncExternalStore` directly:
118
101
 
119
- Options (init):
120
-
121
- - `policy`: `{ categories: readonly T[]; identifier?: string }`
122
- - `identifier` is recommended and should come from your actual policy version/content. If omitted, a deterministic hash of the categories is used.
123
- - `cookie`:
124
- - `name?: string` (default: `consentify`)
125
- - `maxAgeSec?: number` (default: 1 year)
126
- - `sameSite?: 'Lax' | 'Strict' | 'None'` (default: `'Lax'`)
127
- - `secure?: boolean` (forced `true` when `sameSite==='None'`)
128
- - `path?: string` (default: `/`)
129
- - `domain?: string`
130
- - `storage?: ('cookie' | 'localStorage')[]` (default: `['cookie']`)
102
+ ```tsx
103
+ import { useSyncExternalStore } from 'react';
104
+ import { createConsentify, defaultCategories } from '@consentify/core';
131
105
 
132
- Notes:
106
+ const consent = createConsentify({
107
+ policy: { identifier: 'v1.0', categories: defaultCategories },
108
+ });
133
109
 
134
- - `'necessary'` is always `true` and cannot be disabled.
135
- - The snapshot is invalidated automatically when the policy identity changes (identifier/hash).
136
- - Client state is cached and subscribers are notified on changes for optimal React performance.
110
+ function useConsent() {
111
+ return useSyncExternalStore(
112
+ consent.client.subscribe,
113
+ consent.client.get,
114
+ consent.client.getServerSnapshot
115
+ );
116
+ }
117
+ ```
137
118
 
138
- ### Types
119
+ </details>
139
120
 
140
- - `Policy<T>` — `{ identifier?: string; categories: readonly T[] }`.
141
- - `Snapshot<T>` — `{ policy: string; givenAt: string; choices: Record<'necessary'|T, boolean> }`.
142
- - `Choices<T>` — map of consent by category plus `'necessary'`.
143
- - `ConsentState<T>` — `{ decision: 'decided', snapshot } | { decision: 'unset' }`.
144
- - `defaultCategories`/`DefaultCategory` — reusable defaults.
145
- - `StorageKind` — `'cookie' | 'localStorage'`.
121
+ ## Server-Side Usage
146
122
 
147
- ### Example: custom categories
123
+ The server API works with raw `Cookie` headers — perfect for Next.js, Remix, or any Node.js framework:
148
124
 
149
125
  ```ts
150
- type Cat = 'analytics' | 'ads' | 'functional';
151
- const consent = createConsentify({
152
- policy: { categories: ['analytics', 'ads', 'functional'] as const, identifier: 'policy-v1' },
153
- });
126
+ // Read consent from request
127
+ const state = consent.server.get(request.headers.get('cookie'));
154
128
 
155
- consent.client.set({ analytics: true, ads: false });
156
- if (consent.client.get('analytics')) {
157
- // load analytics
129
+ if (state.decision === 'decided' && state.snapshot.choices.analytics) {
130
+ // User consented to analytics
158
131
  }
132
+
133
+ // Set consent (returns Set-Cookie header string)
134
+ const setCookieHeader = consent.server.set(
135
+ { analytics: true },
136
+ request.headers.get('cookie')
137
+ );
138
+ response.headers.set('Set-Cookie', setCookieHeader);
139
+
140
+ // Clear consent
141
+ const clearHeader = consent.server.clear();
159
142
  ```
160
143
 
161
- ### Example: Next.js App Router
144
+ ### Next.js App Router Example
162
145
 
163
146
  ```tsx
164
147
  // lib/consent.ts
165
148
  import { createConsentify, defaultCategories } from '@consentify/core';
166
149
 
167
150
  export const consent = createConsentify({
168
- policy: { categories: defaultCategories, identifier: 'policy-v1' },
151
+ policy: { identifier: 'v1.0', categories: defaultCategories },
169
152
  });
170
153
 
171
- // app/layout.tsx (Server Component)
154
+ // app/layout.tsx
172
155
  import { cookies } from 'next/headers';
173
156
  import { consent } from '@/lib/consent';
174
157
 
175
158
  export default async function RootLayout({ children }) {
176
159
  const cookieStore = await cookies();
177
160
  const state = consent.server.get(cookieStore.toString());
178
- const canLoadAnalytics = state.decision === 'decided' && state.snapshot.choices.analytics;
179
-
161
+
180
162
  return (
181
163
  <html>
182
164
  <body>
183
165
  {children}
184
- {canLoadAnalytics && <AnalyticsScript />}
166
+ {state.decision === 'decided' && state.snapshot.choices.analytics && (
167
+ <Analytics />
168
+ )}
185
169
  </body>
186
170
  </html>
187
171
  );
188
172
  }
189
173
  ```
190
174
 
191
- ### Support
175
+ ## Custom Categories
176
+
177
+ Define your own consent categories with full type safety:
178
+
179
+ ```ts
180
+ const consent = createConsentify({
181
+ policy: {
182
+ identifier: 'v1.0',
183
+ categories: ['analytics', 'ads', 'personalization'] as const,
184
+ },
185
+ });
186
+
187
+ // TypeScript knows your categories!
188
+ consent.client.set({ analytics: true, ads: false });
189
+ consent.client.get('personalization'); // ✓ valid
190
+ consent.client.get('unknown'); // ✗ type error
191
+ ```
192
+
193
+ ## Configuration
194
+
195
+ ```ts
196
+ createConsentify({
197
+ policy: {
198
+ identifier: 'v1.0', // Recommended: version your policy
199
+ categories: defaultCategories,
200
+ },
201
+ // Consent validity (when to re-prompt user)
202
+ consentMaxAgeDays: 365, // Optional: re-consent after N days
203
+ // Cookie storage settings (browser retention)
204
+ cookie: {
205
+ name: 'consent', // Default: 'consentify'
206
+ maxAgeSec: 60 * 60 * 24 * 365, // Default: 1 year (browser storage)
207
+ sameSite: 'Lax', // 'Lax' | 'Strict' | 'None'
208
+ secure: true, // Forced true when sameSite='None'
209
+ path: '/',
210
+ domain: '.example.com', // Optional: for cross-subdomain
211
+ },
212
+ storage: ['cookie'], // ['cookie'] | ['localStorage', 'cookie']
213
+ });
214
+ ```
215
+
216
+ ## API Reference
217
+
218
+ ### `createConsentify(options)`
219
+
220
+ Returns an object with `policy`, `client`, and `server` properties.
221
+
222
+ #### `client` (browser)
223
+
224
+ | Method | Description |
225
+ |--------|-------------|
226
+ | `get()` | Returns `ConsentState` — `{ decision: 'decided', snapshot }` or `{ decision: 'unset' }` |
227
+ | `get(category)` | Returns `boolean` — `true` if category is consented (`'necessary'` always returns `true`) |
228
+ | `set(choices)` | Merges choices and persists; notifies subscribers if changed |
229
+ | `clear()` | Removes stored consent; notifies subscribers |
230
+ | `subscribe(cb)` | Subscribe to changes; returns unsubscribe function |
231
+ | `getServerSnapshot()` | Returns `{ decision: 'unset' }` for SSR hydration |
232
+
233
+ #### `server` (Node.js)
234
+
235
+ | Method | Description |
236
+ |--------|-------------|
237
+ | `get(cookieHeader)` | Parse consent from `Cookie` header string |
238
+ | `set(choices, cookieHeader?)` | Returns `Set-Cookie` header string |
239
+ | `clear()` | Returns `Set-Cookie` header string to delete cookie |
240
+
241
+ ### Types
242
+
243
+ ```ts
244
+ type ConsentState<T> =
245
+ | { decision: 'unset' }
246
+ | { decision: 'decided'; snapshot: Snapshot<T> };
247
+
248
+ interface Snapshot<T> {
249
+ policy: string; // Policy identifier/hash
250
+ givenAt: string; // ISO timestamp
251
+ choices: Choices<T>; // { necessary: true, ...categories }
252
+ }
253
+
254
+ type Choices<T> = Record<'necessary' | T, boolean>;
255
+ ```
256
+
257
+ ### Default Categories
258
+
259
+ ```ts
260
+ const defaultCategories = [
261
+ 'preferences', // User preferences (language, theme)
262
+ 'analytics', // Analytics and performance
263
+ 'marketing', // Advertising and marketing
264
+ 'functional', // Enhanced functionality
265
+ 'unclassified', // Uncategorized cookies
266
+ ] as const;
267
+ ```
268
+
269
+ ## How It Works
270
+
271
+ 1. **Policy versioning** — Consent is tied to a policy identifier. When you update your policy (change `identifier`), previous consent is invalidated.
272
+
273
+ 2. **Necessary cookies** — The `'necessary'` category is always `true` and cannot be disabled.
274
+
275
+ 3. **Storage** — Cookie is the canonical store (works on server). Optionally mirror to `localStorage` for faster client reads.
276
+
277
+ 4. **Compact format** — Consent is stored as a URL-encoded JSON snapshot in a single cookie.
278
+
279
+ 5. **Consent expiration** — Optional `consentMaxAgeDays` invalidates consent after N days, requiring users to re-consent. This is independent of `cookie.maxAgeSec` (which controls how long the browser stores the cookie).
280
+
281
+ ## Support
192
282
 
193
- If you find this library useful, consider supporting its development:
283
+ If you find this library useful:
194
284
 
195
- - ⭐ [GitHub Sponsors](https://github.com/sponsors/RomanDenysov)
196
- - [Ko-fi](https://ko-fi.com/romandenysov)
285
+ - ⭐ Star the repo on [GitHub](https://github.com/RomanDenysov/consentify)
286
+ - 💖 [Sponsor on GitHub](https://github.com/sponsors/RomanDenysov)
287
+ - ☕ [Buy me a coffee on Ko-fi](https://ko-fi.com/romandenysov)
288
+ - ☕ [Buy me a coffee](https://buymeacoffee.com/romandenysov)
197
289
 
198
- ### License
290
+ ## License
199
291
 
200
292
  MIT © 2025 [Roman Denysov](https://github.com/RomanDenysov)
package/dist/index.d.ts CHANGED
@@ -1,43 +1,15 @@
1
- /**
2
- * Literal type for the non-optional category that is always enabled.
3
- */
4
1
  export type Necessary = 'necessary';
5
- /**
6
- * User-defined category identifier (e.g., 'analytics', 'marketing').
7
- */
8
2
  export type UserCategory = string;
9
- /**
10
- * Map of consent choices for all categories, including the 'necessary' category.
11
- * A value of `true` means the user granted consent for the category.
12
- */
13
3
  export type Choices<T extends UserCategory> = Record<Necessary | T, boolean>;
14
- /**
15
- * Describes a cookie policy and its consent categories.
16
- * @template T Category string union used by this policy.
17
- */
18
4
  export interface Policy<T extends UserCategory> {
19
- /**
20
- * Optional stable identifier for your policy. Prefer supplying a value derived from
21
- * your actual policy content/version (e.g., a hash of the policy document).
22
- * If omitted, a deterministic hash of the provided categories (and this identifier when present)
23
- * will be used to key snapshots.
24
- */
25
5
  identifier?: string;
26
6
  categories: readonly T[];
27
7
  }
28
- /**
29
- * Immutable snapshot of a user's consent decision for a specific policy version.
30
- * @template T Category string union captured in the snapshot.
31
- */
32
8
  export interface Snapshot<T extends UserCategory> {
33
9
  policy: string;
34
10
  givenAt: string;
35
11
  choices: Choices<T>;
36
12
  }
37
- /**
38
- * High-level consent state derived from the presence of a valid snapshot.
39
- * When no valid snapshot exists for the current policy version, the state is `unset`.
40
- */
41
13
  export type ConsentState<T extends UserCategory> = {
42
14
  decision: 'unset';
43
15
  } | {
@@ -59,13 +31,14 @@ export interface CreateConsentifyInit<Cs extends readonly string[]> {
59
31
  path?: string;
60
32
  domain?: string;
61
33
  };
62
- /**
63
- * Client-side storage priority. Server-side access is cookie-only.
64
- * Supported: 'cookie' (canonical), 'localStorage' (optional mirror for fast reads)
65
- * Default: ['cookie']
66
- */
34
+ consentMaxAgeDays?: number;
67
35
  storage?: StorageKind[];
68
36
  }
37
+ export interface ConsentifySubscribable<T extends UserCategory> {
38
+ subscribe: (callback: () => void) => () => void;
39
+ get: () => ConsentState<T>;
40
+ getServerSnapshot: () => ConsentState<T>;
41
+ }
69
42
  export declare function createConsentify<Cs extends readonly string[]>(init: CreateConsentifyInit<Cs>): {
70
43
  readonly policy: {
71
44
  readonly categories: Cs;
@@ -79,14 +52,50 @@ export declare function createConsentify<Cs extends readonly string[]>(init: Cre
79
52
  readonly client: {
80
53
  get: {
81
54
  (): ConsentState<ArrToUnion<Cs>>;
82
- (category: "necessary" | ArrToUnion<Cs>): boolean;
55
+ (category: Necessary | ArrToUnion<Cs>): boolean;
83
56
  };
84
57
  set: (choices: Partial<Choices<ArrToUnion<Cs>>>) => void;
85
58
  clear: () => void;
86
59
  subscribe: (callback: () => void) => (() => void);
87
60
  getServerSnapshot: () => ConsentState<ArrToUnion<Cs>>;
61
+ guard: (category: Necessary | ArrToUnion<Cs>, onGrant: () => void, onRevoke?: () => void) => (() => void);
62
+ };
63
+ readonly get: {
64
+ (): ConsentState<ArrToUnion<Cs>>;
65
+ (cookieHeader: string): ConsentState<ArrToUnion<Cs>>;
66
+ (cookieHeader: null): ConsentState<ArrToUnion<Cs>>;
67
+ };
68
+ readonly isGranted: (category: Necessary | ArrToUnion<Cs>) => boolean;
69
+ readonly set: {
70
+ (choices: Partial<Choices<ArrToUnion<Cs>>>): void;
71
+ (choices: Partial<Choices<ArrToUnion<Cs>>>, cookieHeader: string): string;
72
+ };
73
+ readonly clear: {
74
+ (): void;
75
+ (serverMode: string): string;
88
76
  };
77
+ readonly subscribe: (callback: () => void) => (() => void);
78
+ readonly getServerSnapshot: () => ConsentState<ArrToUnion<Cs>>;
79
+ readonly guard: (category: Necessary | ArrToUnion<Cs>, onGrant: () => void, onRevoke?: () => void) => (() => void);
89
80
  };
90
81
  export declare const defaultCategories: readonly ["preferences", "analytics", "marketing", "functional", "unclassified"];
91
82
  export type DefaultCategory = typeof defaultCategories[number];
83
+ export type GoogleConsentType = 'ad_storage' | 'ad_user_data' | 'ad_personalization' | 'analytics_storage' | 'functionality_storage' | 'personalization_storage' | 'security_storage';
84
+ export interface ConsentModeOptions<T extends string> {
85
+ mapping: Partial<Record<'necessary' | T, GoogleConsentType[]>>;
86
+ waitForUpdate?: number;
87
+ }
88
+ export declare const defaultConsentModeMapping: {
89
+ readonly necessary: readonly ["security_storage"];
90
+ readonly analytics: readonly ["analytics_storage"];
91
+ readonly marketing: readonly ["ad_storage", "ad_user_data", "ad_personalization"];
92
+ readonly preferences: readonly ["functionality_storage", "personalization_storage"];
93
+ };
94
+ declare global {
95
+ interface Window {
96
+ dataLayer: unknown[];
97
+ gtag: (...args: unknown[]) => void;
98
+ }
99
+ }
100
+ export declare function enableConsentMode<T extends string>(instance: ConsentifySubscribable<T>, options: ConsentModeOptions<T>): () => void;
92
101
  export {};