@consentify/core 0.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/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Roman Denysov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ ## @consentify/core
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.
4
+
5
+ ### Install
6
+
7
+ ```bash
8
+ npm install @consentify/core
9
+ ```
10
+
11
+ ### Quick start
12
+
13
+ ```ts
14
+ import { createConsentify, defaultCategories } from '@consentify/core';
15
+
16
+ const manager = createConsentify({
17
+ 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,
28
+ },
29
+ // Cookie is canonical. Optionally mirror to localStorage for fast client reads.
30
+ // storage: ['localStorage', 'cookie']
31
+ });
32
+
33
+ // Ask user... then set decisions
34
+ manager.client.set({ analytics: true });
35
+
36
+ // Query on client
37
+ const canAnalytics = manager.client.get('analytics'); // boolean
38
+ const state = manager.client.get(); // { decision: 'decided' | 'unset', snapshot? }
39
+ ```
40
+
41
+ ### SSR usage
42
+
43
+ Use the server API with a raw Cookie header. It never touches the DOM.
44
+
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;
49
+
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)
53
+
54
+ // Clear consent cookie on the server
55
+ const clearCookieHeader = manager.server.clear();
56
+ ```
57
+
58
+ ### API
59
+
60
+ #### createConsentify(init)
61
+
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:
63
+
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.
74
+
75
+ Options (init):
76
+
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']`)
87
+
88
+ Notes:
89
+
90
+ - `'necessary'` is always `true` and cannot be disabled.
91
+ - The snapshot is invalidated automatically when the policy identity changes (identifier/hash).
92
+
93
+ ### Types
94
+
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.
100
+
101
+ ### Example: custom categories
102
+
103
+ ```ts
104
+ type Cat = 'analytics' | 'ads' | 'functional';
105
+ const manager = createConsentify({
106
+ policy: { categories: ['analytics','ads','functional'] as const, identifier: 'policy-v1' },
107
+ });
108
+
109
+ manager.client.set({ analytics: true, ads: false });
110
+ if (manager.client.get('analytics')) {
111
+ // load analytics
112
+ }
113
+ ```
114
+
115
+ ### Support
116
+
117
+ If you find this library useful, consider supporting its development:
118
+
119
+ - ⭐ [GitHub Sponsors](https://github.com/sponsors/RomanDenysov)
120
+ - ☕ [Ko-fi](https://ko-fi.com/romandenysov)
121
+
122
+ ### License
123
+
124
+ MIT © 2025 [Roman Denysov](https://github.com/RomanDenysov)
125
+
126
+
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Literal type for the non-optional category that is always enabled.
3
+ */
4
+ export type Necessary = 'necessary';
5
+ /**
6
+ * User-defined category identifier (e.g., 'analytics', 'marketing').
7
+ */
8
+ 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
+ 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
+ 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
+ identifier?: string;
26
+ categories: readonly T[];
27
+ }
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
+ export interface Snapshot<T extends UserCategory> {
33
+ policy: string;
34
+ givenAt: string;
35
+ choices: Choices<T>;
36
+ }
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
+ export type ConsentState<T extends UserCategory> = {
42
+ decision: 'unset';
43
+ } | {
44
+ decision: 'decided';
45
+ snapshot: Snapshot<T>;
46
+ };
47
+ type ArrToUnion<T extends readonly string[]> = T[number];
48
+ export type StorageKind = 'cookie' | 'localStorage';
49
+ export interface CreateConsentifyInit<Cs extends readonly string[]> {
50
+ policy: {
51
+ categories: Cs;
52
+ identifier?: string;
53
+ };
54
+ cookie?: {
55
+ name?: string;
56
+ maxAgeSec?: number;
57
+ sameSite?: 'Lax' | 'Strict' | 'None';
58
+ secure?: boolean;
59
+ path?: string;
60
+ domain?: string;
61
+ };
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
+ */
67
+ storage?: StorageKind[];
68
+ }
69
+ export declare function createConsentify<Cs extends readonly string[]>(init: CreateConsentifyInit<Cs>): {
70
+ readonly policy: {
71
+ readonly categories: Cs;
72
+ readonly identifier: string;
73
+ };
74
+ readonly server: {
75
+ get: (cookieHeader: string | null | undefined) => ConsentState<ArrToUnion<Cs>>;
76
+ set: (choices: Partial<Choices<ArrToUnion<Cs>>>, currentCookieHeader?: string) => string;
77
+ clear: () => string;
78
+ };
79
+ readonly client: {
80
+ get: {
81
+ (): ConsentState<ArrToUnion<Cs>>;
82
+ (category: "necessary" | ArrToUnion<Cs>): boolean;
83
+ };
84
+ set: (choices: Partial<Choices<ArrToUnion<Cs>>>) => void;
85
+ clear: () => void;
86
+ };
87
+ };
88
+ export declare const defaultCategories: readonly ["preferences", "analytics", "marketing", "functional", "unclassified"];
89
+ export type DefaultCategory = typeof defaultCategories[number];
90
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,230 @@
1
+ function stableStringify(o) {
2
+ if (o === null || typeof o !== 'object')
3
+ return JSON.stringify(o);
4
+ if (Array.isArray(o))
5
+ return `[${o.map(stableStringify).join(',')}]`;
6
+ const e = Object.entries(o).sort((a, b) => a[0].localeCompare(b[0]));
7
+ return `{${e.map(([k, v]) => JSON.stringify(k) + ':' + stableStringify(v)).join(',')}}`;
8
+ }
9
+ function fnv1a(str) {
10
+ let h = 0x811c9dc5 >>> 0;
11
+ for (let i = 0; i < str.length; i++) {
12
+ h ^= str.charCodeAt(i);
13
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
14
+ }
15
+ return ('00000000' + h.toString(16)).slice(-8);
16
+ }
17
+ function hashPolicy(categories, identifier) {
18
+ // Deterministic identity for the policy. If you provide `identifier`, it is folded into the hash,
19
+ // but consider using `identifier` itself as the canonical version key for clarity.
20
+ return fnv1a(stableStringify({ categories: [...categories].sort(), identifier: identifier ?? null }));
21
+ }
22
+ // --- Internals ---
23
+ const DEFAULT_COOKIE = 'consentify';
24
+ const enc = (o) => encodeURIComponent(JSON.stringify(o));
25
+ const dec = (s) => { try {
26
+ return JSON.parse(decodeURIComponent(s));
27
+ }
28
+ catch {
29
+ return null;
30
+ } };
31
+ const toISO = () => new Date().toISOString();
32
+ function readCookie(name, cookieStr) {
33
+ const src = cookieStr ?? (typeof document !== 'undefined' ? document.cookie : '');
34
+ if (!src)
35
+ return null;
36
+ const m = src.split(';').map(v => v.trim()).find(v => v.startsWith(name + '='));
37
+ return m ? m.slice(name.length + 1) : null;
38
+ }
39
+ function writeCookie(name, value, opt) {
40
+ if (typeof document === 'undefined')
41
+ return;
42
+ let c = `${name}=${value}; Path=${opt.path}; Max-Age=${opt.maxAgeSec}; SameSite=${opt.sameSite}`;
43
+ if (opt.domain)
44
+ c += `; Domain=${opt.domain}`;
45
+ if (opt.secure)
46
+ c += `; Secure`;
47
+ document.cookie = c;
48
+ }
49
+ // --- Unified Factory (single entry point) ---
50
+ export function createConsentify(init) {
51
+ const policyHash = init.policy.identifier ?? hashPolicy(init.policy.categories);
52
+ const cookieName = init.cookie?.name ?? DEFAULT_COOKIE;
53
+ const sameSite = init.cookie?.sameSite ?? 'Lax';
54
+ const cookieCfg = {
55
+ path: init.cookie?.path ?? '/',
56
+ maxAgeSec: init.cookie?.maxAgeSec ?? 60 * 60 * 24 * 365,
57
+ sameSite,
58
+ secure: sameSite === 'None' ? true : (init.cookie?.secure ?? true),
59
+ domain: init.cookie?.domain,
60
+ };
61
+ const storageOrder = (init.storage && init.storage.length > 0) ? init.storage : ['cookie'];
62
+ const allowed = new Set(['necessary', ...init.policy.categories]);
63
+ const normalize = (choices) => {
64
+ const base = { necessary: true };
65
+ for (const c of init.policy.categories)
66
+ base[c] = false;
67
+ if (choices) {
68
+ for (const [k, v] of Object.entries(choices)) {
69
+ if (allowed.has(k))
70
+ base[k] = !!v;
71
+ }
72
+ }
73
+ base.necessary = true;
74
+ return base;
75
+ };
76
+ // --- client-side storage helpers ---
77
+ const isBrowser = () => typeof window !== 'undefined' && typeof document !== 'undefined';
78
+ const canLocal = () => { try {
79
+ return isBrowser() && !!window.localStorage;
80
+ }
81
+ catch {
82
+ return false;
83
+ } };
84
+ const readFromStore = (kind) => {
85
+ switch (kind) {
86
+ case 'cookie': return readCookie(cookieName);
87
+ case 'localStorage': return canLocal() ? window.localStorage.getItem(cookieName) : null;
88
+ default: return null;
89
+ }
90
+ };
91
+ const writeToStore = (kind, value) => {
92
+ switch (kind) {
93
+ case 'cookie':
94
+ writeCookie(cookieName, value, cookieCfg);
95
+ break;
96
+ case 'localStorage':
97
+ if (canLocal())
98
+ window.localStorage.setItem(cookieName, value);
99
+ break;
100
+ }
101
+ };
102
+ const clearStore = (kind) => {
103
+ switch (kind) {
104
+ case 'cookie':
105
+ if (isBrowser())
106
+ document.cookie = `${cookieName}=; Path=${cookieCfg.path}; Max-Age=0; SameSite=${cookieCfg.sameSite}${cookieCfg.domain ? `; Domain=${cookieCfg.domain}` : ''}${cookieCfg.secure ? '; Secure' : ''}`;
107
+ break;
108
+ case 'localStorage':
109
+ if (canLocal())
110
+ window.localStorage.removeItem(cookieName);
111
+ break;
112
+ }
113
+ };
114
+ const firstAvailableStore = () => {
115
+ for (const k of storageOrder) {
116
+ if (k === 'cookie')
117
+ return 'cookie';
118
+ if (k === 'localStorage' && canLocal())
119
+ return 'localStorage';
120
+ }
121
+ return 'cookie';
122
+ };
123
+ const readClientRaw = () => {
124
+ for (const k of storageOrder) {
125
+ const v = readFromStore(k);
126
+ if (v)
127
+ return v;
128
+ }
129
+ return null;
130
+ };
131
+ const writeClientRaw = (value) => {
132
+ const primary = firstAvailableStore();
133
+ writeToStore(primary, value);
134
+ // Mirror to cookie if requested anywhere in order and not already writing cookie
135
+ if (primary !== 'cookie' && storageOrder.includes('cookie'))
136
+ writeToStore('cookie', value);
137
+ };
138
+ // --- read helpers ---
139
+ const readClient = () => {
140
+ const raw = readClientRaw();
141
+ const s = raw ? dec(raw) : null;
142
+ if (!s)
143
+ return null;
144
+ if (s.policy !== policyHash)
145
+ return null;
146
+ return s;
147
+ };
148
+ const writeClientIfChanged = (next) => {
149
+ const prev = readClient();
150
+ const same = !!(prev && prev.policy === next.policy && JSON.stringify(prev.choices) === JSON.stringify(next.choices));
151
+ if (!same)
152
+ writeClientRaw(enc(next));
153
+ return !same;
154
+ };
155
+ function buildSetCookieHeader(name, value, opt) {
156
+ let header = `${name}=${value}; Path=${opt.path}; Max-Age=${opt.maxAgeSec}; SameSite=${opt.sameSite}`;
157
+ if (opt.domain)
158
+ header += `; Domain=${opt.domain}`;
159
+ if (opt.secure)
160
+ header += `; Secure`;
161
+ return header;
162
+ }
163
+ // ---- server API
164
+ const server = {
165
+ get: (cookieHeader) => {
166
+ const raw = cookieHeader ? readCookie(cookieName, cookieHeader) : null;
167
+ const s = raw ? dec(raw) : null;
168
+ if (!s)
169
+ return { decision: 'unset' };
170
+ if (s.policy !== policyHash)
171
+ return { decision: 'unset' };
172
+ return { decision: 'decided', snapshot: s };
173
+ },
174
+ set: (choices, currentCookieHeader) => {
175
+ const prev = currentCookieHeader ? server.get(currentCookieHeader) : { decision: 'unset' };
176
+ const base = prev.decision === 'decided' ? prev.snapshot.choices : normalize();
177
+ const snapshot = {
178
+ policy: policyHash,
179
+ givenAt: toISO(),
180
+ choices: normalize({ ...base, ...choices }),
181
+ };
182
+ return buildSetCookieHeader(cookieName, enc(snapshot), cookieCfg);
183
+ },
184
+ clear: () => {
185
+ let h = `${cookieName}=; Path=${cookieCfg.path}; Max-Age=0; SameSite=${cookieCfg.sameSite}`;
186
+ if (cookieCfg.domain)
187
+ h += `; Domain=${cookieCfg.domain}`;
188
+ if (cookieCfg.secure)
189
+ h += `; Secure`;
190
+ return h;
191
+ }
192
+ };
193
+ function clientGet(category) {
194
+ const s = readClient();
195
+ const state = s ? { decision: 'decided', snapshot: s } : { decision: 'unset' };
196
+ if (typeof category === 'undefined')
197
+ return state;
198
+ if (category === 'necessary')
199
+ return true;
200
+ return state.decision === 'decided' ? !!state.snapshot.choices[category] : false;
201
+ }
202
+ const client = {
203
+ get: clientGet,
204
+ set: (choices) => {
205
+ const prev = client.get();
206
+ const base = prev.decision === 'decided' ? prev.snapshot.choices : normalize();
207
+ const next = {
208
+ policy: policyHash,
209
+ givenAt: toISO(),
210
+ choices: normalize({ ...base, ...choices }),
211
+ };
212
+ writeClientIfChanged(next);
213
+ },
214
+ clear: () => {
215
+ for (const k of new Set([...storageOrder, 'cookie']))
216
+ clearStore(k);
217
+ },
218
+ };
219
+ // ---- single entry (DX aliases)
220
+ return {
221
+ policy: {
222
+ categories: init.policy.categories,
223
+ identifier: policyHash,
224
+ },
225
+ server,
226
+ client,
227
+ };
228
+ }
229
+ // Common predefined category names you can reuse in your policy.
230
+ export const defaultCategories = ['preferences', 'analytics', 'marketing', 'functional', 'unclassified'];
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@consentify/core",
3
+ "version": "0.1.0",
4
+ "description": "Minimal headless cookie consent SDK (TypeScript, SSR-ready, lazy-init).",
5
+ "author": {
6
+ "name": "Roman Denysov",
7
+ "url": "https://github.com/RomanDenysov"
8
+ },
9
+ "funding": [
10
+ {
11
+ "type": "github",
12
+ "url": "https://github.com/sponsors/RomanDenysov"
13
+ },
14
+ {
15
+ "type": "ko-fi",
16
+ "url": "https://ko-fi.com/romandenysov"
17
+ }
18
+ ],
19
+ "engines": {
20
+ "node": ">=20.0.0",
21
+ "pnpm": ">=9"
22
+ },
23
+ "keywords": [
24
+ "cookie",
25
+ "consent",
26
+ "gdpr",
27
+ "ccpa",
28
+ "privacy",
29
+ "typescript",
30
+ "ssr"
31
+ ],
32
+ "sideEffects": false,
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/RomanDenysov/consentify.git",
36
+ "directory": "packages/core"
37
+ },
38
+ "bugs": {
39
+ "url": "https://github.com/RomanDenysov/consentify/issues"
40
+ },
41
+ "homepage": "https://github.com/RomanDenysov/consentify#readme",
42
+ "exports": {
43
+ ".": {
44
+ "import": "./dist/index.js",
45
+ "types": "./dist/index.d.ts"
46
+ }
47
+ },
48
+ "module": "./dist/index.js",
49
+ "types": "./dist/index.d.ts",
50
+ "files": [
51
+ "dist/**/*",
52
+ "README.md",
53
+ "LICENSE"
54
+ ],
55
+ "license": "MIT",
56
+ "type": "module",
57
+ "publishConfig": {
58
+ "access": "public"
59
+ },
60
+ "devDependencies": {
61
+ "rimraf": "6.0.1",
62
+ "typescript": "5.9.3",
63
+ "@types/node": "24.7.0"
64
+ },
65
+ "scripts": {
66
+ "build": "rimraf dist && tsc -p tsconfig.build.json"
67
+ }
68
+ }