@consentify/core 0.1.0 → 1.1.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,126 +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
- const manager = createConsentify({
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,
36
+ identifier: 'v1.0',
37
+ categories: defaultCategories, // ['preferences', 'analytics', 'marketing', 'functional', 'unclassified']
22
38
  },
23
- cookie: {
24
- name: 'consentify',
25
- path: '/',
26
- sameSite: 'Lax',
27
- secure: true,
28
- },
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
- manager.client.set({ analytics: true });
41
+ // Set user choices
42
+ consent.client.set({ analytics: true, marketing: false });
43
+
44
+ // Check consent
45
+ if (consent.client.get('analytics')) {
46
+ loadAnalytics();
47
+ }
35
48
 
36
- // Query on client
37
- const canAnalytics = manager.client.get('analytics'); // boolean
38
- const state = manager.client.get(); // { decision: 'decided' | 'unset', snapshot? }
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)
39
53
  ```
40
54
 
41
- ### SSR usage
55
+ ## React Integration
42
56
 
43
- Use the server API with a raw Cookie header. It never touches the DOM.
57
+ For React projects, use the [`@consentify/react`](https://www.npmjs.com/package/@consentify/react) package which provides a ready-to-use hook:
44
58
 
45
- ```ts
46
- // Read consent on the server
47
- const state = manager.server.get(request.headers.get('cookie'));
48
- const canAnalytics = state.decision === 'decided' && !!state.snapshot.choices.analytics;
59
+ ```bash
60
+ npm install @consentify/react
61
+ ```
49
62
 
50
- // Write consent on the server (e.g., after a POST to your consent endpoint)
51
- const setCookieHeader = manager.server.set({ analytics: true }, request.headers.get('cookie'));
52
- // Then attach header: res.setHeader('Set-Cookie', setCookieHeader)
63
+ ```tsx
64
+ import { createConsentify, defaultCategories, useConsentify } from '@consentify/react';
65
+
66
+ const consent = createConsentify({
67
+ policy: { identifier: 'v1.0', categories: defaultCategories },
68
+ });
53
69
 
54
- // Clear consent cookie on the server
55
- const clearCookieHeader = manager.server.clear();
70
+ function CookieBanner() {
71
+ const state = useConsentify(consent);
72
+
73
+ if (state.decision === 'decided') return null;
74
+
75
+ return (
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
+ })}>
83
+ Accept All
84
+ </button>
85
+ <button onClick={() => consent.client.set({
86
+ analytics: false,
87
+ marketing: false,
88
+ preferences: false
89
+ })}>
90
+ Essential Only
91
+ </button>
92
+ </div>
93
+ );
94
+ }
56
95
  ```
57
96
 
58
- ### API
97
+ <details>
98
+ <summary>Manual integration with useSyncExternalStore</summary>
59
99
 
60
- #### createConsentify(init)
100
+ If you prefer not to add the React package, you can use `useSyncExternalStore` directly:
61
101
 
62
- Creates a consent manager bound to a `policy`. Cookie is the canonical store; you can optionally mirror to `localStorage` for client-side speed. Returns:
102
+ ```tsx
103
+ import { useSyncExternalStore } from 'react';
104
+ import { createConsentify, defaultCategories } from '@consentify/core';
63
105
 
64
- - `policy`: `{ categories: string[]; identifier: string }`
65
- - `client`:
66
- - `get(): ConsentState<T>` — re-reads storage and returns `{ decision: 'decided', snapshot } | { decision: 'unset' }`.
67
- - `get(category: 'necessary' | T): boolean` — boolean check; `'necessary'` always returns `true`.
68
- - `set(choices: Partial<Choices<T>>): void` — merges and saves; writes only if changed.
69
- - `clear(): void` — removes stored consent (cookie and any mirror).
70
- - `server`:
71
- - `get(cookieHeader: string | null | undefined): ConsentState<T>` — raw Cookie header in, state out.
72
- - `set(choices: Partial<Choices<T>>, currentCookieHeader?: string): string` — returns `Set-Cookie` header string.
73
- - `clear(): string` — returns `Set-Cookie` header string to delete the cookie.
106
+ const consent = createConsentify({
107
+ policy: { identifier: 'v1.0', categories: defaultCategories },
108
+ });
74
109
 
75
- Options (init):
110
+ function useConsent() {
111
+ return useSyncExternalStore(
112
+ consent.client.subscribe,
113
+ consent.client.get,
114
+ consent.client.getServerSnapshot
115
+ );
116
+ }
117
+ ```
76
118
 
77
- - `policy`: `{ categories: readonly T[]; identifier?: string }`
78
- - `identifier` is recommended and should come from your actual policy version/content. If omitted, a deterministic hash of the categories is used.
79
- - `cookie`:
80
- - `name?: string` (default: `consentify`)
81
- - `maxAgeSec?: number` (default: 1 year)
82
- - `sameSite?: 'Lax' | 'Strict' | 'None'` (default: `'Lax'`)
83
- - `secure?: boolean` (forced `true` when `sameSite==='None'`)
84
- - `path?: string` (default: `/`)
85
- - `domain?: string`
86
- - `storage?: ('cookie' | 'localStorage')[]` (default: `['cookie']`)
119
+ </details>
87
120
 
88
- Notes:
121
+ ## Server-Side Usage
89
122
 
90
- - `'necessary'` is always `true` and cannot be disabled.
91
- - The snapshot is invalidated automatically when the policy identity changes (identifier/hash).
123
+ The server API works with raw `Cookie` headers perfect for Next.js, Remix, or any Node.js framework:
92
124
 
93
- ### Types
125
+ ```ts
126
+ // Read consent from request
127
+ const state = consent.server.get(request.headers.get('cookie'));
128
+
129
+ if (state.decision === 'decided' && state.snapshot.choices.analytics) {
130
+ // User consented to analytics
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();
142
+ ```
143
+
144
+ ### Next.js App Router Example
145
+
146
+ ```tsx
147
+ // lib/consent.ts
148
+ import { createConsentify, defaultCategories } from '@consentify/core';
149
+
150
+ export const consent = createConsentify({
151
+ policy: { identifier: 'v1.0', categories: defaultCategories },
152
+ });
153
+
154
+ // app/layout.tsx
155
+ import { cookies } from 'next/headers';
156
+ import { consent } from '@/lib/consent';
157
+
158
+ export default async function RootLayout({ children }) {
159
+ const cookieStore = await cookies();
160
+ const state = consent.server.get(cookieStore.toString());
161
+
162
+ return (
163
+ <html>
164
+ <body>
165
+ {children}
166
+ {state.decision === 'decided' && state.snapshot.choices.analytics && (
167
+ <Analytics />
168
+ )}
169
+ </body>
170
+ </html>
171
+ );
172
+ }
173
+ ```
174
+
175
+ ## Custom Categories
94
176
 
95
- - `Policy<T>` `{ identifier?: string; categories: readonly T[] }`.
96
- - `Snapshot<T>` — `{ policy: string; givenAt: string; choices: Record<'necessary'|T, boolean> }`.
97
- - `Choices<T>` — map of consent by category plus `'necessary'`.
98
- - `ConsentState<T>` — `{ decision: 'decided', snapshot } | { decision: 'unset' }`.
99
- - `defaultCategories`/`DefaultCategory` — reusable defaults.
177
+ Define your own consent categories with full type safety:
100
178
 
101
- ### Example: custom categories
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
102
194
 
103
195
  ```ts
104
- type Cat = 'analytics' | 'ads' | 'functional';
105
- const manager = createConsentify({
106
- policy: { categories: ['analytics','ads','functional'] as const, identifier: 'policy-v1' },
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']
107
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)
108
223
 
109
- manager.client.set({ analytics: true, ads: false });
110
- if (manager.client.get('analytics')) {
111
- // load analytics
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 }
112
252
  }
253
+
254
+ type Choices<T> = Record<'necessary' | T, boolean>;
113
255
  ```
114
256
 
115
- ### Support
257
+ ### Default Categories
116
258
 
117
- If you find this library useful, consider supporting its development:
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
+ ```
118
268
 
119
- - [GitHub Sponsors](https://github.com/sponsors/RomanDenysov)
120
- - ☕ [Ko-fi](https://ko-fi.com/romandenysov)
269
+ ## How It Works
121
270
 
122
- ### License
271
+ 1. **Policy versioning** — Consent is tied to a policy identifier. When you update your policy (change `identifier`), previous consent is invalidated.
123
272
 
124
- MIT © 2025 [Roman Denysov](https://github.com/RomanDenysov)
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).
125
280
 
281
+ ## Support
126
282
 
283
+ If you find this library useful:
284
+
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)
289
+
290
+ ## License
291
+
292
+ MIT © 2025 [Roman Denysov](https://github.com/RomanDenysov)
package/dist/index.d.ts CHANGED
@@ -59,6 +59,11 @@ export interface CreateConsentifyInit<Cs extends readonly string[]> {
59
59
  path?: string;
60
60
  domain?: string;
61
61
  };
62
+ /**
63
+ * Maximum age of consent in days. If set, consent older than this
64
+ * will be treated as expired, requiring re-consent.
65
+ */
66
+ consentMaxAgeDays?: number;
62
67
  /**
63
68
  * Client-side storage priority. Server-side access is cookie-only.
64
69
  * Supported: 'cookie' (canonical), 'localStorage' (optional mirror for fast reads)
@@ -83,6 +88,8 @@ export declare function createConsentify<Cs extends readonly string[]>(init: Cre
83
88
  };
84
89
  set: (choices: Partial<Choices<ArrToUnion<Cs>>>) => void;
85
90
  clear: () => void;
91
+ subscribe: (callback: () => void) => (() => void);
92
+ getServerSnapshot: () => ConsentState<ArrToUnion<Cs>>;
86
93
  };
87
94
  };
88
95
  export declare const defaultCategories: readonly ["preferences", "analytics", "marketing", "functional", "unclassified"];
package/dist/index.js CHANGED
@@ -29,6 +29,13 @@ catch {
29
29
  return null;
30
30
  } };
31
31
  const toISO = () => new Date().toISOString();
32
+ function isValidSnapshot(s) {
33
+ return (typeof s === 'object' && s !== null &&
34
+ typeof s.policy === 'string' &&
35
+ typeof s.givenAt === 'string' &&
36
+ typeof s.choices === 'object' &&
37
+ s.choices !== null);
38
+ }
32
39
  function readCookie(name, cookieStr) {
33
40
  const src = cookieStr ?? (typeof document !== 'undefined' ? document.cookie : '');
34
41
  if (!src)
@@ -59,6 +66,16 @@ export function createConsentify(init) {
59
66
  domain: init.cookie?.domain,
60
67
  };
61
68
  const storageOrder = (init.storage && init.storage.length > 0) ? init.storage : ['cookie'];
69
+ const consentMaxAgeDays = init.consentMaxAgeDays;
70
+ const isExpired = (givenAt) => {
71
+ if (!consentMaxAgeDays)
72
+ return false;
73
+ const givenTime = new Date(givenAt).getTime();
74
+ if (isNaN(givenTime))
75
+ return true; // Invalid date = expired
76
+ const maxAgeMs = consentMaxAgeDays * 24 * 60 * 60 * 1000;
77
+ return Date.now() - givenTime > maxAgeMs;
78
+ };
62
79
  const allowed = new Set(['necessary', ...init.policy.categories]);
63
80
  const normalize = (choices) => {
64
81
  const base = { necessary: true };
@@ -131,7 +148,6 @@ export function createConsentify(init) {
131
148
  const writeClientRaw = (value) => {
132
149
  const primary = firstAvailableStore();
133
150
  writeToStore(primary, value);
134
- // Mirror to cookie if requested anywhere in order and not already writing cookie
135
151
  if (primary !== 'cookie' && storageOrder.includes('cookie'))
136
152
  writeToStore('cookie', value);
137
153
  };
@@ -139,10 +155,12 @@ export function createConsentify(init) {
139
155
  const readClient = () => {
140
156
  const raw = readClientRaw();
141
157
  const s = raw ? dec(raw) : null;
142
- if (!s)
158
+ if (!s || !isValidSnapshot(s))
143
159
  return null;
144
160
  if (s.policy !== policyHash)
145
161
  return null;
162
+ if (isExpired(s.givenAt))
163
+ return null;
146
164
  return s;
147
165
  };
148
166
  const writeClientIfChanged = (next) => {
@@ -165,10 +183,12 @@ export function createConsentify(init) {
165
183
  get: (cookieHeader) => {
166
184
  const raw = cookieHeader ? readCookie(cookieName, cookieHeader) : null;
167
185
  const s = raw ? dec(raw) : null;
168
- if (!s)
186
+ if (!s || !isValidSnapshot(s))
169
187
  return { decision: 'unset' };
170
188
  if (s.policy !== policyHash)
171
189
  return { decision: 'unset' };
190
+ if (isExpired(s.givenAt))
191
+ return { decision: 'unset' };
172
192
  return { decision: 'decided', snapshot: s };
173
193
  },
174
194
  set: (choices, currentCookieHeader) => {
@@ -190,14 +210,35 @@ export function createConsentify(init) {
190
210
  return h;
191
211
  }
192
212
  };
193
- function clientGet(category) {
213
+ // ========== NEW: Subscribe pattern for React ==========
214
+ const listeners = new Set();
215
+ const unsetState = { decision: 'unset' };
216
+ let cachedState = unsetState;
217
+ const syncState = () => {
194
218
  const s = readClient();
195
- const state = s ? { decision: 'decided', snapshot: s } : { decision: 'unset' };
219
+ if (!s) {
220
+ cachedState = unsetState;
221
+ }
222
+ else {
223
+ cachedState = { decision: 'decided', snapshot: s };
224
+ }
225
+ };
226
+ const notifyListeners = () => {
227
+ listeners.forEach(cb => cb());
228
+ };
229
+ // Init cache on browser
230
+ if (isBrowser()) {
231
+ syncState();
232
+ }
233
+ function clientGet(category) {
234
+ // Return cached state for React compatibility
196
235
  if (typeof category === 'undefined')
197
- return state;
236
+ return cachedState;
198
237
  if (category === 'necessary')
199
238
  return true;
200
- return state.decision === 'decided' ? !!state.snapshot.choices[category] : false;
239
+ return cachedState.decision === 'decided'
240
+ ? !!cachedState.snapshot.choices[category]
241
+ : false;
201
242
  }
202
243
  const client = {
203
244
  get: clientGet,
@@ -209,14 +250,26 @@ export function createConsentify(init) {
209
250
  givenAt: toISO(),
210
251
  choices: normalize({ ...base, ...choices }),
211
252
  };
212
- writeClientIfChanged(next);
253
+ const changed = writeClientIfChanged(next);
254
+ if (changed) {
255
+ syncState();
256
+ notifyListeners();
257
+ }
213
258
  },
214
259
  clear: () => {
215
260
  for (const k of new Set([...storageOrder, 'cookie']))
216
261
  clearStore(k);
262
+ syncState();
263
+ notifyListeners();
264
+ },
265
+ // NEW: Subscribe for React useSyncExternalStore
266
+ subscribe: (callback) => {
267
+ listeners.add(callback);
268
+ return () => listeners.delete(callback);
217
269
  },
270
+ // NEW: Server snapshot for SSR (always unset)
271
+ getServerSnapshot: () => unsetState,
218
272
  };
219
- // ---- single entry (DX aliases)
220
273
  return {
221
274
  policy: {
222
275
  categories: init.policy.categories,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consentify/core",
3
- "version": "0.1.0",
3
+ "version": "1.1.0",
4
4
  "description": "Minimal headless cookie consent SDK (TypeScript, SSR-ready, lazy-init).",
5
5
  "author": {
6
6
  "name": "Roman Denysov",
@@ -14,6 +14,10 @@
14
14
  {
15
15
  "type": "ko-fi",
16
16
  "url": "https://ko-fi.com/romandenysov"
17
+ },
18
+ {
19
+ "type": "buymeacoffee",
20
+ "url": "https://buymeacoffee.com/romandenysov"
17
21
  }
18
22
  ],
19
23
  "engines": {
@@ -63,6 +67,7 @@
63
67
  "@types/node": "24.7.0"
64
68
  },
65
69
  "scripts": {
66
- "build": "rimraf dist && tsc -p tsconfig.build.json"
70
+ "build": "rimraf dist && tsc -p tsconfig.build.json",
71
+ "check": "tsc --noEmit"
67
72
  }
68
73
  }