dynalitics-consent 0.0.0-stage → 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dynalitics contributors
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.
package/README.md CHANGED
@@ -1,3 +1,143 @@
1
- # Temporary Holding Version
1
+ # dynalitics-consent
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An optional **consent banner** for [dynalitics](https://www.npmjs.com/package/dynalitics). It asks visitors whether they allow analytics, remembers the answer in one first-party cookie, and gives your server a one-line helper that turns that answer into the `consent` boolean `track()` expects.
4
+
5
+ `dynalitics` itself never asks for consent: it only honours the boolean you pass. Use this package if you want dynalitics to do the asking; skip it if you already have a consent manager (then pass its answer to `track()` yourself).
6
+
7
+ > **Early release (`0.1.x`; the API may still change before 1.0).** Tested in jsdom (plain DOM and React, including server rendering); not yet in real browsers.
8
+
9
+ ```sh
10
+ npm install dynalitics-consent # no runtime dependencies; react is an optional peer
11
+ ```
12
+
13
+ ## How it fits together
14
+
15
+ ```
16
+ browser your server
17
+ <ConsentBanner message="..." /> consentFromRequest(req) -> true / false
18
+ visitor clicks Allow / Reject |
19
+ -> cookie dynalitics_consent=granted|denied ---> dyn.track({ consent, ... })
20
+ ```
21
+
22
+ Until the visitor clicks, there is no cookie, the helper returns `false`, and dynalitics stores only the page and the time. Nothing personal is ever recorded before a choice.
23
+
24
+ ## 1. Show the banner
25
+
26
+ **React:**
27
+
28
+ ```tsx
29
+ import { ConsentBanner } from 'dynalitics-consent/react';
30
+ import 'dynalitics-consent/styles.css';
31
+
32
+ export function Layout({ children }) {
33
+ return (
34
+ <>
35
+ {children}
36
+ <ConsentBanner
37
+ enabled={true}
38
+ message="We count the pages you visit to improve this site. Do you allow analytics?"
39
+ acceptLabel="Allow"
40
+ rejectLabel="No thanks"
41
+ policyUrl="/privacy"
42
+ />
43
+ </>
44
+ );
45
+ }
46
+ ```
47
+
48
+ **Without React** (any site, any framework):
49
+
50
+ ```ts
51
+ import { mountConsentBanner } from 'dynalitics-consent';
52
+ import 'dynalitics-consent/styles.css'; // or copy dist/styles.css into your own stylesheet
53
+
54
+ const banner = mountConsentBanner({
55
+ message: 'We count the pages you visit to improve this site. Do you allow analytics?',
56
+ policyUrl: '/privacy',
57
+ });
58
+ ```
59
+
60
+ The banner only appears when the visitor has not chosen yet.
61
+
62
+ ### Options (the same for both)
63
+
64
+ | Option | Default | What it does |
65
+ | ------------- | ---------------------- | --------------------------------------------------------------------------------------------------- |
66
+ | `message` | **required** | the question, as plain text |
67
+ | `enabled` | `true` | `false` shows nothing and never touches the cookie |
68
+ | `title` | none | an optional heading, plain text |
69
+ | `acceptLabel` | `'Allow'` | text of the allow button |
70
+ | `rejectLabel` | `'Reject'` | text of the reject button |
71
+ | `policyUrl` | none | link to your privacy policy: a path like `/privacy` or an `https://` URL (anything else is ignored) |
72
+ | `policyLabel` | `'Privacy policy'` | text of that link |
73
+ | `cookieName` | `'dynalitics_consent'` | change it only if it clashes; use the same name on the server |
74
+ | `maxAgeDays` | `180` | how long the answer is remembered (1 to 395 days) |
75
+ | `onChange` | none | `(choice: 'granted' \| 'denied') => void`, called after a click |
76
+
77
+ All text is rendered as text, never as HTML, so it is safe to load it from a CMS.
78
+
79
+ ## 2. Read the answer on the server
80
+
81
+ ```ts
82
+ import { createDynalitics } from 'dynalitics';
83
+ import { postgres } from 'dynalitics-postgres';
84
+ import { consentFromRequest } from 'dynalitics-consent/server';
85
+
86
+ const dyn = await createDynalitics({ adapter: postgres /* ... */ });
87
+
88
+ app.use((req, res, next) => {
89
+ void dyn.track({
90
+ pageVisited: req.originalUrl,
91
+ consent: consentFromRequest(req), // true only after the visitor clicked "Allow"
92
+ ip: req.ip,
93
+ userId: req.user?.id,
94
+ });
95
+ next();
96
+ });
97
+ ```
98
+
99
+ `consentFromRequest` accepts a Node request (Express, Fastify, `node:http`), a Fetch `Request` (Next.js route handlers, Hono, Bun, Deno) or a raw `Cookie` header string. It returns `true` **only** for an explicit "Allow". No cookie, "Reject", an unknown value, a malformed header, or two conflicting cookies all give `false`. It never throws for a request.
100
+
101
+ ## 3. Let visitors change their mind
102
+
103
+ Consent must be as easy to withdraw as to give. Put a "Cookie settings" link somewhere permanent, such as your footer:
104
+
105
+ ```tsx
106
+ import { useConsent } from 'dynalitics-consent/react';
107
+
108
+ function CookieSettingsLink() {
109
+ const { reopen } = useConsent();
110
+ return (
111
+ <button type="button" onClick={reopen}>
112
+ Cookie settings
113
+ </button>
114
+ );
115
+ }
116
+ ```
117
+
118
+ Without React, call `banner.open()` on the handle `mountConsentBanner` returned.
119
+
120
+ `useConsent()` also gives you `choice` (`'granted'`, `'denied'` or `null`), `grant()` and `deny()`, if you prefer to build your own UI. Without React the same is available as `readConsent()`, `writeConsent(choice)` and `clearConsent()`. Every change also fires a `dynalitics:consent` event on `window` with `detail.choice`.
121
+
122
+ ## What it does and does not do
123
+
124
+ - **One cookie, nothing else.** `dynalitics_consent=granted|denied; Path=/; Max-Age=…; SameSite=Lax`, plus `Secure` on https pages, written only when the visitor clicks. No local storage, no network requests, no tracking of any kind.
125
+ - **Equal choices.** Allow and Reject are the same element with the same style; neither is pre-selected or highlighted.
126
+ - **Accessible.** A non-modal dialog (`role="dialog"`, `aria-modal="false"`) labelled by its title or message; it does not trap focus or block the page. Buttons are at least 44 px tall, with visible focus rings. Hidden when printing.
127
+ - **Strict-CSP friendly.** Styling is class-based in a separate stylesheet; no inline `style` attributes, no injected `<style>` tags.
128
+ - **Server rendering.** The React banner renders nothing on the server and decides in the browser, so hydration never mismatches.
129
+ - **Theming.** Override the CSS custom properties on `.dy-consent` (`--dy-consent-bg`, `--dy-consent-fg`, `--dy-consent-muted`, `--dy-consent-rule`, `--dy-consent-focus`, `--dy-consent-font`). It follows `prefers-color-scheme` by default.
130
+ - **Not legal advice.** The wording of your message, your privacy policy, and whether a banner is enough in your jurisdiction remain your responsibility.
131
+
132
+ ## Entry points
133
+
134
+ | Import | Contents |
135
+ | ------------------------------- | ------------------------------------------------------------------------- |
136
+ | `dynalitics-consent` | `mountConsentBanner`, `readConsent`, `writeConsent`, `clearConsent` (DOM) |
137
+ | `dynalitics-consent/react` | `<ConsentBanner>`, `useConsent()` |
138
+ | `dynalitics-consent/server` | `consentFromRequest`, `parseConsentCookie` (no DOM; runs anywhere) |
139
+ | `dynalitics-consent/styles.css` | the banner's stylesheet |
140
+
141
+ The full contract is in [`docs/SPEC.md` section 19](https://github.com/lucsoftsl/dynalitics/blob/main/docs/SPEC.md).
142
+
143
+ MIT licensed.
@@ -0,0 +1,46 @@
1
+ import { type ConsentChoice } from './shared.js';
2
+ export type { ConsentChoice } from './shared.js';
3
+ export interface ConsentBannerOptions {
4
+ /** Default true. When false nothing is rendered and the cookie is neither read nor written. */
5
+ readonly enabled?: boolean;
6
+ /** Plain text shown to the visitor. Required. */
7
+ readonly message: string;
8
+ readonly title?: string;
9
+ readonly acceptLabel?: string;
10
+ readonly rejectLabel?: string;
11
+ /** A same-site path (`/privacy`) or an http(s) URL; anything else is not rendered. */
12
+ readonly policyUrl?: string;
13
+ readonly policyLabel?: string;
14
+ /** Must equal the server helper's. Default 'dynalitics_consent'. */
15
+ readonly cookieName?: string | undefined;
16
+ /** How long the choice is remembered, 1..395 days. Default 180. */
17
+ readonly maxAgeDays?: number | undefined;
18
+ readonly onChange?: (choice: ConsentChoice) => void;
19
+ }
20
+ export interface CookieOptions {
21
+ readonly cookieName?: string | undefined;
22
+ readonly maxAgeDays?: number | undefined;
23
+ }
24
+ export interface ConsentBannerHandle {
25
+ /** The stored choice, or null when the visitor has not chosen (or the banner is disabled). */
26
+ readonly choice: () => ConsentChoice | null;
27
+ /** Shows the banner again whatever was stored: wire it to a "Cookie settings" link. */
28
+ readonly open: () => void;
29
+ /** Removes the banner from the page (the stored choice stays). */
30
+ readonly destroy: () => void;
31
+ }
32
+ export declare const CONSENT_EVENT = "dynalitics:consent";
33
+ /** The stored choice from `document.cookie`, or null. Safe to call during server rendering (returns null). */
34
+ export declare function readConsent(options?: CookieOptions): ConsentChoice | null;
35
+ /** Stores a choice, tells listeners (`dynalitics:consent` on window). Use it for your own UI instead of the banner. */
36
+ export declare function writeConsent(choice: ConsentChoice, options?: CookieOptions): void;
37
+ /** Deletes the stored choice: the visitor is asked again on the next page. */
38
+ export declare function clearConsent(options?: CookieOptions): void;
39
+ /** Builds the banner element. Every visitor-facing string goes in as text. */
40
+ export declare function buildBanner(o: ConsentBannerOptions, choose: (c: ConsentChoice) => void): HTMLElement;
41
+ /**
42
+ * Shows the banner when the visitor has not chosen yet. Returns a handle to reopen it (withdrawal) or remove it.
43
+ * `container` defaults to `document.body`.
44
+ */
45
+ export declare function mountConsentBanner(options: ConsentBannerOptions, container?: HTMLElement): ConsentBannerHandle;
46
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,115 @@
1
+ // 'dynalitics-consent': a consent banner in plain DOM (docs/SPEC.md section 19). Text only, no HTML parsing, no
2
+ // network requests; the only thing it stores is one first-party cookie holding the visitor's choice.
3
+ import { assertCookieName, assertMaxAgeDays, choiceFromCookieHeader, consentCookie, DEFAULT_COOKIE_NAME, DEFAULT_MAX_AGE_DAYS, safePolicyUrl, } from './shared.js';
4
+ export const CONSENT_EVENT = 'dynalitics:consent';
5
+ const isSecurePage = () => globalThis.location?.protocol === 'https:';
6
+ /** The stored choice from `document.cookie`, or null. Safe to call during server rendering (returns null). */
7
+ export function readConsent(options = {}) {
8
+ const name = assertCookieName(options.cookieName ?? DEFAULT_COOKIE_NAME);
9
+ if (typeof document === 'undefined')
10
+ return null;
11
+ return choiceFromCookieHeader(document.cookie, name);
12
+ }
13
+ /** Stores a choice, tells listeners (`dynalitics:consent` on window). Use it for your own UI instead of the banner. */
14
+ export function writeConsent(choice, options = {}) {
15
+ if (choice !== 'granted' && choice !== 'denied')
16
+ throw new TypeError("dynalitics-consent: choice must be 'granted' or 'denied'");
17
+ const name = assertCookieName(options.cookieName ?? DEFAULT_COOKIE_NAME);
18
+ const days = assertMaxAgeDays(options.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS);
19
+ if (typeof document === 'undefined')
20
+ return;
21
+ document.cookie = consentCookie(name, choice, days * 86_400, isSecurePage());
22
+ globalThis.dispatchEvent?.(new CustomEvent(CONSENT_EVENT, { detail: { choice } }));
23
+ }
24
+ /** Deletes the stored choice: the visitor is asked again on the next page. */
25
+ export function clearConsent(options = {}) {
26
+ const name = assertCookieName(options.cookieName ?? DEFAULT_COOKIE_NAME);
27
+ if (typeof document === 'undefined')
28
+ return;
29
+ document.cookie = consentCookie(name, '', 0, isSecurePage());
30
+ }
31
+ let ids = 0;
32
+ /** Builds the banner element. Every visitor-facing string goes in as text. */
33
+ export function buildBanner(o, choose) {
34
+ const id = `dy-consent-${++ids}`;
35
+ const root = document.createElement('div');
36
+ root.className = 'dy-consent';
37
+ root.setAttribute('role', 'dialog');
38
+ root.setAttribute('aria-modal', 'false');
39
+ root.setAttribute('aria-labelledby', o.title ? `${id}-t` : `${id}-m`);
40
+ if (o.title) {
41
+ const h = document.createElement('p');
42
+ h.className = 'dy-consent-title';
43
+ h.id = `${id}-t`;
44
+ h.textContent = o.title;
45
+ root.appendChild(h);
46
+ }
47
+ const msg = document.createElement('p');
48
+ msg.className = 'dy-consent-msg';
49
+ msg.id = `${id}-m`;
50
+ msg.textContent = o.message;
51
+ root.appendChild(msg);
52
+ const url = safePolicyUrl(o.policyUrl);
53
+ if (url) {
54
+ const a = document.createElement('a');
55
+ a.className = 'dy-consent-link';
56
+ a.href = url;
57
+ a.textContent = o.policyLabel ?? 'Privacy policy';
58
+ root.appendChild(a);
59
+ }
60
+ const actions = document.createElement('div');
61
+ actions.className = 'dy-consent-actions';
62
+ // Same element, same class, document order reject-then-accept: neither choice is visually favoured.
63
+ for (const [choice, label] of [
64
+ ['denied', o.rejectLabel ?? 'Reject'],
65
+ ['granted', o.acceptLabel ?? 'Allow'],
66
+ ]) {
67
+ const b = document.createElement('button');
68
+ b.type = 'button';
69
+ b.className = 'dy-consent-btn';
70
+ b.dataset['choice'] = choice;
71
+ b.textContent = label;
72
+ b.addEventListener('click', () => choose(choice));
73
+ actions.appendChild(b);
74
+ }
75
+ root.appendChild(actions);
76
+ return root;
77
+ }
78
+ function validate(o) {
79
+ if (typeof o.message !== 'string' || o.message.trim() === '')
80
+ throw new TypeError('dynalitics-consent: message is required');
81
+ assertCookieName(o.cookieName ?? DEFAULT_COOKIE_NAME);
82
+ assertMaxAgeDays(o.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS);
83
+ }
84
+ /**
85
+ * Shows the banner when the visitor has not chosen yet. Returns a handle to reopen it (withdrawal) or remove it.
86
+ * `container` defaults to `document.body`.
87
+ */
88
+ export function mountConsentBanner(options, container) {
89
+ validate(options);
90
+ const cookie = { cookieName: options.cookieName, maxAgeDays: options.maxAgeDays };
91
+ const enabled = options.enabled !== false;
92
+ let el = null;
93
+ const destroy = () => {
94
+ el?.remove();
95
+ el = null;
96
+ };
97
+ const open = () => {
98
+ if (!enabled || el || typeof document === 'undefined')
99
+ return;
100
+ el = buildBanner(options, (c) => {
101
+ writeConsent(c, cookie);
102
+ destroy();
103
+ options.onChange?.(c);
104
+ });
105
+ (container ?? document.body).appendChild(el);
106
+ };
107
+ if (enabled && readConsent(cookie) === null)
108
+ open();
109
+ return {
110
+ choice: () => (enabled ? readConsent(cookie) : null),
111
+ open,
112
+ destroy,
113
+ };
114
+ }
115
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,16 @@
1
+ import { type ReactElement } from 'react';
2
+ import { type ConsentBannerOptions, type ConsentChoice, type CookieOptions } from '../index.js';
3
+ export type { ConsentBannerOptions, ConsentChoice, CookieOptions } from '../index.js';
4
+ export interface UseConsent {
5
+ /** null until the visitor chose (and always null during server rendering). */
6
+ readonly choice: ConsentChoice | null;
7
+ readonly grant: () => void;
8
+ readonly deny: () => void;
9
+ /** Shows the banner again, whatever was stored. */
10
+ readonly reopen: () => void;
11
+ }
12
+ /** The visitor's choice, kept in sync with the banner and with other tabs' writes on this page. */
13
+ export declare function useConsent(options?: CookieOptions): UseConsent;
14
+ export type ConsentBannerProps = ConsentBannerOptions;
15
+ export declare function ConsentBanner(props: ConsentBannerProps): ReactElement | null;
16
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,56 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // 'dynalitics-consent/react' (docs/SPEC.md 19). Same DOM and class names as the plain-DOM banner, so one stylesheet
4
+ // serves both. Renders nothing on the server and on the first client render, so hydration never mismatches.
5
+ import { useCallback, useEffect, useId, useState } from 'react';
6
+ import { CONSENT_EVENT, readConsent, writeConsent, } from '../index.js';
7
+ import { safePolicyUrl } from '../shared.js';
8
+ /** Asks every mounted <ConsentBanner> to show itself again (withdrawal / "Cookie settings"). */
9
+ const OPEN_EVENT = 'dynalitics:consent-open';
10
+ /** The visitor's choice, kept in sync with the banner and with other tabs' writes on this page. */
11
+ export function useConsent(options = {}) {
12
+ const { cookieName, maxAgeDays } = options;
13
+ const [choice, setChoice] = useState(null);
14
+ useEffect(() => {
15
+ const sync = () => setChoice(readConsent({ cookieName }));
16
+ sync();
17
+ window.addEventListener(CONSENT_EVENT, sync);
18
+ return () => window.removeEventListener(CONSENT_EVENT, sync);
19
+ }, [cookieName]);
20
+ const write = useCallback((c) => writeConsent(c, { cookieName, maxAgeDays }), [cookieName, maxAgeDays]);
21
+ return {
22
+ choice,
23
+ grant: () => write('granted'),
24
+ deny: () => write('denied'),
25
+ reopen: () => window.dispatchEvent(new Event(OPEN_EVENT)),
26
+ };
27
+ }
28
+ function assertMessage(message) {
29
+ if (typeof message !== 'string' || message.trim() === '')
30
+ throw new TypeError('dynalitics-consent: message is required');
31
+ }
32
+ export function ConsentBanner(props) {
33
+ assertMessage(props.message);
34
+ const { enabled = true, cookieName, maxAgeDays, onChange } = props;
35
+ const id = useId();
36
+ const [open, setOpen] = useState(false);
37
+ useEffect(() => {
38
+ if (!enabled)
39
+ return;
40
+ if (readConsent({ cookieName }) === null)
41
+ setOpen(true);
42
+ const reopen = () => setOpen(true);
43
+ window.addEventListener(OPEN_EVENT, reopen);
44
+ return () => window.removeEventListener(OPEN_EVENT, reopen);
45
+ }, [enabled, cookieName]);
46
+ if (!enabled || !open)
47
+ return null;
48
+ const choose = (c) => {
49
+ writeConsent(c, { cookieName, maxAgeDays });
50
+ setOpen(false);
51
+ onChange?.(c);
52
+ };
53
+ const url = safePolicyUrl(props.policyUrl);
54
+ return (_jsxs("div", { className: "dy-consent", role: "dialog", "aria-modal": "false", "aria-labelledby": props.title ? `${id}-t` : `${id}-m`, children: [props.title ? (_jsx("p", { className: "dy-consent-title", id: `${id}-t`, children: props.title })) : null, _jsx("p", { className: "dy-consent-msg", id: `${id}-m`, children: props.message }), url ? (_jsx("a", { className: "dy-consent-link", href: url, children: props.policyLabel ?? 'Privacy policy' })) : null, _jsxs("div", { className: "dy-consent-actions", children: [_jsx("button", { type: "button", className: "dy-consent-btn", "data-choice": "denied", onClick: () => choose('denied'), children: props.rejectLabel ?? 'Reject' }), _jsx("button", { type: "button", className: "dy-consent-btn", "data-choice": "granted", onClick: () => choose('granted'), children: props.acceptLabel ?? 'Allow' })] })] }));
55
+ }
56
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,20 @@
1
+ export { choiceFromCookieHeader as parseConsentCookie } from './shared.js';
2
+ export type { ConsentChoice } from './shared.js';
3
+ /** Anything that carries a Cookie header: a Node IncomingMessage, a Fetch Request, or a plain headers object. */
4
+ export type RequestLike = string | {
5
+ readonly headers: {
6
+ readonly get: (name: string) => string | null;
7
+ };
8
+ } | {
9
+ readonly headers: Readonly<Record<string, string | readonly string[] | undefined>>;
10
+ };
11
+ export interface ConsentFromRequestOptions {
12
+ /** Must equal the banner's `cookieName`. Default 'dynalitics_consent'. */
13
+ readonly cookieName?: string;
14
+ }
15
+ /**
16
+ * `true` only when the visitor chose "allow". No choice, "reject", a malformed header or conflicting cookies are all
17
+ * `false`, which dynalitics stores as "no consent" (page and time only). Never throws for a request.
18
+ */
19
+ export declare function consentFromRequest(req: RequestLike, options?: ConsentFromRequestOptions): boolean;
20
+ //# sourceMappingURL=server.d.ts.map
package/dist/server.js ADDED
@@ -0,0 +1,33 @@
1
+ // 'dynalitics-consent/server': turns the banner's cookie into the boolean `track()` takes (docs/SPEC.md 19.4).
2
+ // No DOM and no Node imports, so it runs in Node, edge runtimes, Bun and Deno alike.
3
+ import { assertCookieName, choiceFromCookieHeader, DEFAULT_COOKIE_NAME } from './shared.js';
4
+ export { choiceFromCookieHeader as parseConsentCookie } from './shared.js';
5
+ function cookieHeader(req) {
6
+ if (typeof req === 'string')
7
+ return req;
8
+ const headers = req?.headers;
9
+ if (typeof headers !== 'object' || headers === null)
10
+ return null;
11
+ if (typeof headers.get === 'function') {
12
+ const value = headers.get('cookie');
13
+ return typeof value === 'string' ? value : null;
14
+ }
15
+ const raw = headers['cookie'];
16
+ if (typeof raw === 'string')
17
+ return raw;
18
+ return Array.isArray(raw) && raw.every((v) => typeof v === 'string') ? raw.join('; ') : null;
19
+ }
20
+ /**
21
+ * `true` only when the visitor chose "allow". No choice, "reject", a malformed header or conflicting cookies are all
22
+ * `false`, which dynalitics stores as "no consent" (page and time only). Never throws for a request.
23
+ */
24
+ export function consentFromRequest(req, options = {}) {
25
+ const name = assertCookieName(options.cookieName ?? DEFAULT_COOKIE_NAME);
26
+ try {
27
+ return choiceFromCookieHeader(cookieHeader(req), name) === 'granted';
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1,17 @@
1
+ export type ConsentChoice = 'granted' | 'denied';
2
+ export declare const DEFAULT_COOKIE_NAME = "dynalitics_consent";
3
+ export declare const DEFAULT_MAX_AGE_DAYS = 180;
4
+ export declare const MAX_AGE_DAYS_LIMIT = 395;
5
+ /** Throws on a cookie name that could break the `Cookie` header (it comes from host config, never from a visitor). */
6
+ export declare function assertCookieName(name: string): string;
7
+ export declare function assertMaxAgeDays(days: number): number;
8
+ /**
9
+ * The stored choice in a `Cookie` header. Fails closed: a missing cookie, an unknown value, or the same cookie twice
10
+ * with different values (e.g. one set on a parent domain) is "no choice" (null).
11
+ */
12
+ export declare function choiceFromCookieHeader(header: string | null | undefined, name?: string): ConsentChoice | null;
13
+ /** The `Set-Cookie`/`document.cookie` string that stores a choice (or deletes it with `maxAgeSeconds = 0`). */
14
+ export declare function consentCookie(name: string, value: ConsentChoice | '', maxAgeSeconds: number, secure: boolean): string;
15
+ /** A link target the banner may render: a same-site path or an http(s) URL. Anything else (javascript:, data:) is null. */
16
+ export declare function safePolicyUrl(url: string | undefined): string | null;
17
+ //# sourceMappingURL=shared.d.ts.map
package/dist/shared.js ADDED
@@ -0,0 +1,55 @@
1
+ // Pure helpers shared by the banner and the server helper (docs/SPEC.md section 19). No DOM, no Node APIs.
2
+ export const DEFAULT_COOKIE_NAME = 'dynalitics_consent';
3
+ export const DEFAULT_MAX_AGE_DAYS = 180;
4
+ export const MAX_AGE_DAYS_LIMIT = 395;
5
+ const COOKIE_NAME = /^[A-Za-z0-9_-]{1,64}$/;
6
+ /** Throws on a cookie name that could break the `Cookie` header (it comes from host config, never from a visitor). */
7
+ export function assertCookieName(name) {
8
+ if (!COOKIE_NAME.test(name))
9
+ throw new TypeError('dynalitics-consent: cookieName must match ^[A-Za-z0-9_-]{1,64}$');
10
+ return name;
11
+ }
12
+ export function assertMaxAgeDays(days) {
13
+ if (!Number.isInteger(days) || days < 1 || days > MAX_AGE_DAYS_LIMIT)
14
+ throw new TypeError(`dynalitics-consent: maxAgeDays must be an integer 1..${MAX_AGE_DAYS_LIMIT}`);
15
+ return days;
16
+ }
17
+ /**
18
+ * The stored choice in a `Cookie` header. Fails closed: a missing cookie, an unknown value, or the same cookie twice
19
+ * with different values (e.g. one set on a parent domain) is "no choice" (null).
20
+ */
21
+ export function choiceFromCookieHeader(header, name = DEFAULT_COOKIE_NAME) {
22
+ if (typeof header !== 'string' || header.length === 0)
23
+ return null;
24
+ const values = new Set();
25
+ for (const part of header.split(';')) {
26
+ const eq = part.indexOf('=');
27
+ if (eq === -1)
28
+ continue;
29
+ if (part.slice(0, eq).trim() === name)
30
+ values.add(part.slice(eq + 1).trim());
31
+ }
32
+ if (values.size !== 1)
33
+ return null;
34
+ const [value] = values;
35
+ return value === 'granted' || value === 'denied' ? value : null;
36
+ }
37
+ /** The `Set-Cookie`/`document.cookie` string that stores a choice (or deletes it with `maxAgeSeconds = 0`). */
38
+ export function consentCookie(name, value, maxAgeSeconds, secure) {
39
+ return `${name}=${value}; Path=/; Max-Age=${maxAgeSeconds}; SameSite=Lax${secure ? '; Secure' : ''}`;
40
+ }
41
+ /** A link target the banner may render: a same-site path or an http(s) URL. Anything else (javascript:, data:) is null. */
42
+ export function safePolicyUrl(url) {
43
+ if (typeof url !== 'string' || url.length === 0 || url.length > 2048)
44
+ return null;
45
+ if (/^\/(?!\/)/.test(url))
46
+ return url;
47
+ try {
48
+ const parsed = new URL(url);
49
+ return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.href : null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ //# sourceMappingURL=shared.js.map
@@ -0,0 +1,80 @@
1
+ /* dynalitics-consent banner. Classes only (works under a strict CSP without 'unsafe-inline' for attributes).
2
+ Theme it by overriding the custom properties on .dy-consent. */
3
+ .dy-consent {
4
+ --dy-consent-bg: #faf7f0;
5
+ --dy-consent-fg: #1a1915;
6
+ --dy-consent-muted: #4f4b43;
7
+ --dy-consent-rule: #8c8472;
8
+ --dy-consent-focus: #b03a0a;
9
+ --dy-consent-font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
10
+ position: fixed;
11
+ z-index: 2147483000;
12
+ inset: auto 16px 16px 16px;
13
+ max-width: 560px;
14
+ margin-inline: auto;
15
+ box-sizing: border-box;
16
+ display: grid;
17
+ gap: 12px;
18
+ padding: 20px;
19
+ background: var(--dy-consent-bg);
20
+ color: var(--dy-consent-fg);
21
+ border: 1px solid var(--dy-consent-rule);
22
+ border-radius: 4px;
23
+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
24
+ font: 400 15px/1.5 var(--dy-consent-font);
25
+ }
26
+ @media (prefers-color-scheme: dark) {
27
+ .dy-consent {
28
+ --dy-consent-bg: #1f1d18;
29
+ --dy-consent-fg: #f3eee4;
30
+ --dy-consent-muted: #b2ab9b;
31
+ --dy-consent-rule: #7a7360;
32
+ --dy-consent-focus: #ff8a5b;
33
+ }
34
+ }
35
+ .dy-consent-title {
36
+ margin: 0;
37
+ font-weight: 600;
38
+ }
39
+ .dy-consent-msg {
40
+ margin: 0;
41
+ overflow-wrap: anywhere;
42
+ }
43
+ .dy-consent-link {
44
+ color: inherit;
45
+ justify-self: start;
46
+ text-underline-offset: 3px;
47
+ }
48
+ .dy-consent-actions {
49
+ display: flex;
50
+ flex-wrap: wrap;
51
+ gap: 8px;
52
+ justify-content: flex-end;
53
+ }
54
+ /* Both choices share this one rule: neither is styled to look like the "default". */
55
+ .dy-consent-btn {
56
+ flex: 1 1 140px;
57
+ min-height: 44px;
58
+ padding: 0 16px;
59
+ font: inherit;
60
+ font-weight: 600;
61
+ color: var(--dy-consent-fg);
62
+ background: transparent;
63
+ border: 1px solid var(--dy-consent-fg);
64
+ border-radius: 4px;
65
+ cursor: pointer;
66
+ }
67
+ .dy-consent-btn:hover {
68
+ background: var(--dy-consent-fg);
69
+ color: var(--dy-consent-bg);
70
+ }
71
+ .dy-consent-btn:focus-visible,
72
+ .dy-consent-link:focus-visible {
73
+ outline: 3px solid var(--dy-consent-focus);
74
+ outline-offset: 2px;
75
+ }
76
+ @media print {
77
+ .dy-consent {
78
+ display: none;
79
+ }
80
+ }
package/package.json CHANGED
@@ -1,6 +1,76 @@
1
1
  {
2
2
  "name": "dynalitics-consent",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Optional consent banner for dynalitics (plain DOM and React) plus a server helper that turns the visitor's choice into the boolean track() takes.",
5
+ "keywords": [
6
+ "analytics",
7
+ "dynalitics",
8
+ "consent",
9
+ "cookie-banner",
10
+ "gdpr",
11
+ "privacy",
12
+ "react"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "dynalitics contributors",
16
+ "homepage": "https://github.com/lucsoftsl/dynalitics/tree/main/packages/dynalitics-consent#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/lucsoftsl/dynalitics/issues"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/lucsoftsl/dynalitics.git",
23
+ "directory": "packages/dynalitics-consent"
24
+ },
25
+ "type": "module",
26
+ "engines": {
27
+ "node": ">=22"
28
+ },
29
+ "sideEffects": [
30
+ "*.css"
31
+ ],
32
+ "main": "./dist/index.js",
33
+ "types": "./dist/index.d.ts",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "default": "./dist/index.js"
38
+ },
39
+ "./react": {
40
+ "types": "./dist/react/index.d.ts",
41
+ "default": "./dist/react/index.js"
42
+ },
43
+ "./server": {
44
+ "types": "./dist/server.d.ts",
45
+ "default": "./dist/server.js"
46
+ },
47
+ "./styles.css": "./dist/styles.css"
48
+ },
49
+ "files": [
50
+ "dist",
51
+ "!dist/**/*.map",
52
+ "README.md",
53
+ "LICENSE"
54
+ ],
55
+ "scripts": {
56
+ "build": "tsc -b && node scripts/copy-css.mjs"
57
+ },
58
+ "peerDependencies": {
59
+ "react": "^18.0.0 || ^19.0.0"
60
+ },
61
+ "peerDependenciesMeta": {
62
+ "react": {
63
+ "optional": true
64
+ }
65
+ },
66
+ "devDependencies": {
67
+ "@types/react": "19.3.0",
68
+ "@types/react-dom": "19.3.0",
69
+ "react": "19.3.1",
70
+ "react-dom": "19.3.1"
71
+ },
72
+ "publishConfig": {
73
+ "access": "public",
74
+ "provenance": true
75
+ }
76
+ }