@codeluum/compliance-web
v0.1.2
Published
Browser helpers for Codeluum Content API (cookie consent, publishable key)
Maintainers
Readme
@codeluum/compliance-web
Browser + React widgets for Codeluum consent and privacy compliance (GDPR / NDPR — Nigeria Data Protection Act). Drop-in cookie banner, preference center, JIT consent prompts, DSAR buttons, policy links, sensitive-field protection, and the statutory NDPR notices (SNAG / Article 40, cross-border transfer disclosure, pre-install statement) — all driven by the Codeluum Content API.
Every widget authenticates with your publishable key (pk_…), which is safe to ship to the browser. For server-side calls with the secret key (sk_…), use @codeluum/compliance.
- Framework-free core (vanilla DOM) and first-class React bindings.
- Tree-shakeable subpath exports — import only what you use.
- Ships TypeScript types. ESM only.
- React is an optional peer dependency (only needed for
@codeluum/compliance-web/react).
Installation
npm install @codeluum/compliance-web
# React is only required if you use the /react entry point:
npm install react react-domKey concepts
- Publishable key (
pk_…) — the only credential this package needs. It can record consent and read public config/policies, but can't enumerate audit history. Never put your secret key (sk_…) in browser code. externalUserId— a stable identifier for the current visitor/user, used to attribute consent records. Can be a string or a (sync/async) function returning one.- Consent cookie — a single cookie (
cl_consent_v1by default) stores the visitor's decision. The consent-state bus reads it synchronously and notifies subscribers (including across tabs) on change. - Purposes vs. categories — categories are the toggles shown in the banner; purposes are the granular consent records derived from them.
Quick start — vanilla cookie banner
import { mountCodeluumBanner } from '@codeluum/compliance-web';
const banner = await mountCodeluumBanner({
publishableKey: 'pk_live_…',
apiBaseUrl: 'https://api.codeluum.com',
externalUserId: () => getCurrentUserId() ?? crypto.randomUUID(),
position: 'bottom',
gate: { gtm: {} }, // auto-activate gated scripts + Google Consent Mode v2
onDecision: (purposes) => console.log('granted:', purposes),
});
// Later — e.g. from a "Cookie settings" footer link:
banner.openPreferences();The banner injects its own styles by default (injectDefaultStyles: false to opt out), writes the consent cookie, POSTs the decision, and fires the consent bus so the rest of the page reacts.
Quick start — React
import {
CodeluumBannerProvider,
useConsent,
RetentionNotice,
} from '@codeluum/compliance-web/react';
function App() {
return (
<CodeluumBannerProvider
publishableKey="pk_live_…"
apiBaseUrl="https://api.codeluum.com"
externalUserId={userId}
gate
>
<Analytics />
</CodeluumBannerProvider>
);
}
function Analytics() {
const { granted } = useConsent('analytics'); // re-renders on accept/withdraw, even cross-tab
return granted ? <Tracker /> : null;
}Entry points
Import widgets from the main barrel or from focused subpaths (better tree-shaking):
| Import | Exports |
|---|---|
| @codeluum/compliance-web | mountCodeluumBanner, createCodeluumContentClient, initCodeluumConsent, purposesFromCategories, recordConsentFromToggles, CodeluumContentError, and re-exports of everything below |
| …/react | React components + hooks (see React) |
| …/consent-state | getConsentState, getConsentSnapshot, subscribeConsent, notifyConsentChange, CONSENT_COOKIE |
| …/gate | applyConsentGates |
| …/i18n | resolveStrings, detectLocale, isRtlLocale, SUPPORTED_LOCALES |
| …/request-consent | requestConsent |
| …/submit-with-consent | submitWithConsent |
| …/purpose-registry | getPurposeRegistry, refreshPurposeRegistry, getPurposeById, subscribePurposeRegistry |
| …/policy | mountCodeluumPolicyLink, mountCodeluumPolicyText |
| …/consent-receipt | mountCodeluumConsentReceipt |
| …/badge | mountCodeluumComplianceBadge |
| …/dsar | createDsarRequest, pollDsarStatus, TERMINAL_STATUSES |
| …/dsar-button | mountCodeluumDsarButton |
| …/preference-center | mountCodeluumPreferenceCenter |
| …/retention-notice | mountCodeluumRetentionNotice |
| …/field | mountCodeluumField |
| …/sensitive-field | mountCodeluumSensitiveField, SENSITIVE_PATTERNS |
| …/snag-form | mountCodeluumSnagForm, SCHEDULE_9_VIOLATION_CODES |
| …/transfer-disclosure | mountCodeluumTransferDisclosure |
| …/pre-install-statement | generatePreInstallStatement, PRE_INSTALL_FIELDS |
All
mount*functions accept a target (stringselector orHTMLElement) and return a handle withunmount()(plus widget-specific methods). They inject default styles unless you passinjectDefaultStyles: false, and every visible string / CSS class is overridable viastrings/classNames.
Cookie banner — mountCodeluumBanner
const banner = await mountCodeluumBanner(options): Promise<BannerHandle>;Renders a sticky (bottom/top) or centered-modal banner with Accept All / Essentials Only / Customize. Manages its own DOM, cookie, and the POST to record the decision.
Key options (MountBannerOptions):
| Option | Type | Notes |
|---|---|---|
| publishableKey | string | Required. |
| apiBaseUrl | string | Required. |
| externalUserId | string \| () => string \| Promise<string> | Required. Stable visitor id. |
| position | 'bottom' \| 'top' | Default 'bottom'. |
| gate | boolean \| { gtm?: GtmGateOptions } | Auto-activate gated scripts and (optionally) update Google Consent Mode v2. |
| locale | string | e.g. 'en', 'fr-FR', 'ar-EG'. Defaults to <html lang> → navigator.language → 'en'. |
| policyLinks | { cookiePolicyUrl?, privacyPolicyUrl?, external? } | Render policy links in the body. |
| linkRenderer | (props) => HTMLElement | Custom anchor (React-Router / Next Link). |
| constructiveDismiss | { mode: 'centered' } | Article 17(8)(b) centered modal + dismiss-to-required. |
| strings / classNames | Partial<…> | Override copy and CSS hooks. |
| cookieName, cookieMaxAgeDays, cookieDomain, cookiePath, cookieSameSite, cookieSecure | | Cookie attributes. |
| render | (host, api) => (() => void) \| void | Full UI escape hatch — you own the DOM, api gives categories, strings, decide(), dismiss(). |
| onDecision / onError | callbacks | |
BannerHandle: unmount(), openPreferences(), hasDecided(), getDecision(). The default cookie name is exported as BANNER_COOKIE_NAME.
React bindings — @codeluum/compliance-web/react
Components (each is a thin wrapper over the matching mount* function and tears down on unmount):
<CodeluumBanner />, <CodeluumBannerProvider>, <CodeluumPreferenceCenter />, <CodeluumComplianceBadge />, <CodeluumPolicyLink />, <CodeluumPolicyText />, <CodeluumConsentReceipt />, <CodeluumDsarButton />, <CodeluumField>, <CodeluumSensitiveField />, <RetentionNotice />, <CodeluumSnagForm />, <CodeluumTransferDisclosure />. Components that expose an imperative handle accept a handleRef.
Hooks:
| Hook | Returns | Purpose |
|---|---|---|
| useConsent(purposeId, opts?) | ConsentState | Subscribe to one purpose; re-renders on accept/withdraw (cross-tab). |
| useConsentSnapshot(opts?) | ConsentSnapshot \| null | Subscribe to the full decision. |
| useCodeluumBanner() | { openPreferences, hasDecided, getDecision } | Control the banner mounted by CodeluumBannerProvider. |
| usePurposeRegistry(arg) | PurposeRegistryEntry[] | Module-cached purpose catalog; updates live on refresh. |
| useDsarStatus(ticketId, opts) | DsarStatusSnapshot \| null | Poll a DSAR ticket to completion. |
const { granted, decided, decidedAt } = useConsent('marketing');
const { openPreferences } = useCodeluumBanner();Consent state bus — @codeluum/compliance-web/consent-state
Framework-free, synchronous reads of the consent cookie plus a change subscription (cross-tab via BroadcastChannel where available).
import {
getConsentState, getConsentSnapshot, subscribeConsent, notifyConsentChange, CONSENT_COOKIE,
} from '@codeluum/compliance-web/consent-state';
const { granted, decided, decidedAt } = getConsentState('analytics'); // pure, render-safe
const unsubscribe = subscribeConsent((snap) => { /* snap: { purposes, decidedAt } | null */ });
// If you write the cookie yourself, tell subscribers:
notifyConsentChange();getConsentState(purposeId, opts?) → { granted: boolean; decided: boolean; decidedAt: string | null }. opts.cookieName overrides the default (CONSENT_COOKIE).
Script gating — @codeluum/compliance-web/gate
Activate every <script type="text/plain" data-codeluum-purpose="…" data-src="…"> whose purpose is granted. Idempotent. Optionally maps purposes to Google Consent Mode v2.
<script type="text/plain" data-codeluum-purpose="analytics" data-src="https://…/ga.js"></script>import { applyConsentGates } from '@codeluum/compliance-web/gate';
applyConsentGates(['necessary', 'analytics'], {
gtm: { purposeMap: { analytics: 'analytics_storage', marketing: 'ad_storage' } },
});(The banner can do this for you automatically via its gate option.)
Internationalization — @codeluum/compliance-web/i18n
Built-in dictionaries for SUPPORTED_LOCALES = ['en', 'es', 'fr', 'de', 'pt', 'ar'] (Arabic renders RTL).
import { resolveStrings, detectLocale, isRtlLocale, SUPPORTED_LOCALES } from '@codeluum/compliance-web/i18n';
const locale = detectLocale(); // explicit → <html lang> → navigator.language → 'en'
const strings = resolveStrings(locale, { title: 'Cookies 🍪' }); // exact → language-prefix → English, then overrides
const rtl = isRtlLocale(locale);Just-in-time consent — @codeluum/compliance-web/request-consent
Promise-returning inline prompt for a single purpose at the moment it's needed. Resolves true on accept, false on reject.
import { requestConsent } from '@codeluum/compliance-web/request-consent';
const ok = await requestConsent({
publishableKey: 'pk_live_…',
apiBaseUrl: 'https://api.codeluum.com',
externalUserId: userId,
purpose: 'newsletter_signup',
reason: 'We’ll email you product updates. Unsubscribe anytime.',
alwaysInclude: ['necessary'],
});
if (ok) await subscribe();Gated form submit — @codeluum/compliance-web/submit-with-consent
Collects every required purpose (prompting via requestConsent for any that aren't granted) and only runs submit(values) once all are granted. Stops on the first rejection.
import { submitWithConsent } from '@codeluum/compliance-web/submit-with-consent';
const result = await submitWithConsent(formValues, {
publishableKey: 'pk_live_…',
apiBaseUrl: 'https://api.codeluum.com',
externalUserId: userId,
requires: [
'newsletter_signup',
{ purpose: 'profiling', reason: 'To personalize recommendations.' },
],
submit: (values) => api.createAccount(values),
});
if (result.ok) {
console.log(result.submitResult, result.granted);
} else {
console.warn('blocked by', result.blockedBy); // purposes the user declined
}Content API client — createCodeluumContentClient
A browser HTTP client for the public …/compliance/content/* endpoints. Most widgets create one for you, but you can share a single instance via the client option to avoid duplicate fetches.
import { createCodeluumContentClient } from '@codeluum/compliance-web';
const client = createCodeluumContentClient({
publishableKey: 'pk_live_…',
apiBaseUrl: 'https://api.codeluum.com',
});
await client.getConfig(); // banner categories
await client.getPolicies(); // published policies
await client.getPurposes(); // purpose registry
await client.consents.record({ subject, purpose, state: 'granted', basis: 'consent' });
await client.consents.has({ subject, purpose });Failed requests throw CodeluumContentError (status, message, body).
Helpers on the barrel: initCodeluumConsent(options) prefetches config + policies (no UI); purposesFromCategories(selectedIds, opts?) derives purpose strings from category toggles; recordConsentFromToggles(client, params) records consent from a toggle selection.
DSAR (data-subject access requests) — @codeluum/compliance-web/dsar
Open and track export/delete/rectify requests. The browser never sees the secret key — requests go through your backend proxy (proxyUrl) or a custom createTicket callback.
import { createDsarRequest, pollDsarStatus, TERMINAL_STATUSES } from '@codeluum/compliance-web/dsar';
const ticket = await createDsarRequest({
externalUserId: userId,
type: 'export', // 'export' | 'delete' | 'rectify'
proxyUrl: '/api/codeluum/dsar', // your endpoint POSTs to the Content API with sk_…
});
const poll = pollDsarStatus(ticket.id, {
publishableKey: 'pk_live_…',
apiBaseUrl: 'https://api.codeluum.com',
intervalMs: 5000,
onChange: (snap) => {
console.log(snap.status);
if (TERMINAL_STATUSES.has(snap.status)) poll.stop();
},
});DSAR button — @codeluum/compliance-web/dsar-button
import { mountCodeluumDsarButton } from '@codeluum/compliance-web/dsar-button';
mountCodeluumDsarButton('#export-data', {
type: 'export',
externalUserId: userId,
proxyUrl: '/api/codeluum/dsar',
onTerminal: (snap) => console.log('done:', snap.status),
});Compliance badge — @codeluum/compliance-web/badge
A transparency icon that opens a modal with tabs for Cookies, Purposes, Policies, optional DSAR Requests, and an optional "Your data" inventory.
import { mountCodeluumComplianceBadge } from '@codeluum/compliance-web/badge';
const badge = mountCodeluumComplianceBadge('#badge', {
publishableKey: 'pk_live_…',
apiBaseUrl: 'https://api.codeluum.com',
externalUserId: userId,
tabs: ['cookies', 'purposes', 'policies', 'requests', 'data'],
dataInventory: [{ field: 'email', why: 'Account login', retentionDays: 365 }],
dsar: { proxyUrl: '/api/codeluum/dsar' },
});
badge.open();Preference center — @codeluum/compliance-web/preference-center
A full preferences page: decision summary, per-purpose toggles, DSAR buttons, and policy links — all live-updated via the consent bus.
import { mountCodeluumPreferenceCenter } from '@codeluum/compliance-web/preference-center';
mountCodeluumPreferenceCenter('#privacy', {
publishableKey: 'pk_live_…',
apiBaseUrl: 'https://api.codeluum.com',
externalUserId: userId,
sections: ['summary', 'purposes', 'requests', 'policies'],
dsar: { proxyUrl: '/api/codeluum/dsar' },
});Policy links & text — @codeluum/compliance-web/policy
import { mountCodeluumPolicyLink, mountCodeluumPolicyText } from '@codeluum/compliance-web/policy';
mountCodeluumPolicyLink('#privacy-link', {
publishableKey: 'pk_live_…', apiBaseUrl: 'https://api.codeluum.com',
type: 'privacy', // 'privacy' | 'terms' | 'data_policy' | 'cookie'
mode: 'modal', // 'modal' opens an <a>+dialog; 'link' navigates to href
});
mountCodeluumPolicyText('#cookie-policy', {
publishableKey: 'pk_live_…', apiBaseUrl: 'https://api.codeluum.com', type: 'cookie',
}); // inlines the published policy HTMLConsent receipt — @codeluum/compliance-web/consent-receipt
Shows "Saved on — manage in privacy settings" once a decision exists for a purpose.
import { mountCodeluumConsentReceipt } from '@codeluum/compliance-web/consent-receipt';
mountCodeluumConsentReceipt('#receipt', { purpose: 'newsletter' });Retention notice — @codeluum/compliance-web/retention-notice
import { mountCodeluumRetentionNotice } from '@codeluum/compliance-web/retention-notice';
mountCodeluumRetentionNotice('#note', { purpose: 'support tickets', days: 365 }); // days: null → indefiniteForm fields
Field chrome — @codeluum/compliance-web/field
Wraps an input with a label, optional tooltip (purpose description), and retention notice sourced from the purpose registry.
import { mountCodeluumField } from '@codeluum/compliance-web/field';
mountCodeluumField('#email-field', {
purpose: 'newsletter_signup',
publishableKey: 'pk_live_…', apiBaseUrl: 'https://api.codeluum.com',
});Sensitive field — @codeluum/compliance-web/sensitive-field
Mask/show toggle, paste validation, and clipboard wipe for high-risk inputs. Built-in patterns for bvn, nin, ssn, phone (exported as SENSITIVE_PATTERNS); pass a pattern for type: 'custom'.
import { mountCodeluumSensitiveField } from '@codeluum/compliance-web/sensitive-field';
const field = mountCodeluumSensitiveField('#bvn', { type: 'bvn', clipboardWipeMs: 10000 });
field.getValue();NDPR statutory notices
SNAG form — @codeluum/compliance-web/snag-form
Standard Notice to Address Grievance (Article 40 + Schedule 9, NDP Act / GAID 2025). Posts to /compliance/content/snag (publishable key + server-side Origin allowlist). The 14 statutory violation codes are exported as SCHEDULE_9_VIOLATION_CODES.
import { mountCodeluumSnagForm } from '@codeluum/compliance-web/snag-form';
mountCodeluumSnagForm('#grievance', {
publishableKey: 'pk_live_…', apiBaseUrl: 'https://api.codeluum.com',
defaultRespondentName: 'Acme Ltd',
onServed: (r) => console.log('ref', r.id, 'deadline', r.deadlineAt),
});Transfer disclosure — @codeluum/compliance-web/transfer-disclosure
Article 27(3)(f) public list of cross-border destinations and their legal bases. Fetches from /compliance/content/transfers.
import { mountCodeluumTransferDisclosure } from '@codeluum/compliance-web/transfer-disclosure';
mountCodeluumTransferDisclosure('#transfers', {
publishableKey: 'pk_live_…', apiBaseUrl: 'https://api.codeluum.com',
});Pre-install statement — @codeluum/compliance-web/pre-install-statement
Pure function that assembles the Article 31(2)(e) pre-install statement (9 fields) into { text, html, json } for app-store listings or onboarding screens.
import { generatePreInstallStatement } from '@codeluum/compliance-web/pre-install-statement';
const stmt = generatePreInstallStatement({
purposes: [{ purposeId: 'analytics', label: 'Usage analytics', retentionDays: 365 }],
deviceType: 'mobile', // 'mobile' | 'desktop' | 'kiosk' | 'wearable'
controllerName: 'Acme Ltd',
controllerContact: '[email protected]',
crossBorderDestinations: ['us', 'gb'], // ISO-3166 alpha-2
});
element.innerHTML = stmt.html;Purpose registry — @codeluum/compliance-web/purpose-registry
A module-cached catalog of your purposes (label, description, lawful basis, retention, sensitivity, required).
import {
getPurposeRegistry, refreshPurposeRegistry, getPurposeById, subscribePurposeRegistry,
} from '@codeluum/compliance-web/purpose-registry';
const purposes = await getPurposeRegistry({ publishableKey: 'pk_live_…', apiBaseUrl: '…' });
getPurposeById('analytics'); // sync; null until first load resolves
const off = subscribePurposeRegistry((list) => { /* re-render */ });Styling
Every widget injects scoped default styles (disable with injectDefaultStyles: false) and exposes a classNames map so you can attach your own CSS to each part. Visible copy is overridable via strings, and locale selects a built-in translation.
TypeScript
All entry points ship .d.ts declarations. Notable exported types: MountBannerOptions, BannerHandle, ConsentState, ConsentSnapshot, ContentClientConfig, PurposeRegistryEntry, DsarType, DsarStatusSnapshot, CreateDsarRequestOptions, PolicyType, SensitiveType, SchedulNineViolationCode, TransferBasis, PreInstallStatement, LocaleCode, and the per-widget *Options / *Strings / *ClassNames / *Handle types.
Note: the top-level
ConsentStateis the cookie-state object{ granted, decided, decidedAt }. The Content API's consent decision union ('granted' | 'withdrawn' | 'acknowledged') is exported asContentConsentState.
License
MIT
