// consent.js — cookie / privacy consent state (v0.13.0, SPEC §14.5). // // The framework's analytics SDK gating (roadmap item #13, target v0.15.0) // will read from this module. v0.13.0 ships the storage + the banner + // the on-change pub/sub; no analytics SDK ships yet. // // Shape of a consent record: // // { essential: true, analytics: bool, other: bool, recorded_at: string | null } // // `essential` is always true at the API surface; it's included for // symmetry. `recorded_at` is null when the user has not yet made a // choice — the banner is shown until it's non-null. // // Precedence: // - Anonymous viewer: localStorage is the only source. // - Authenticated viewer: on sign-in, the server row (if any) overrides // local; if the server has no row, the local choice is uploaded. // // The fan-out is intentionally tiny — three flags. The banner writes // once; subscribers re-read on demand via `getConsent()` and can // register `onConsentChange(cb)` to be notified of subsequent updates. // // IMPORTANT: don't import this from analytics SDKs that themselves // set cookies on load. Read consent first, then conditionally `import()` // the SDK module — that's the contract item #13 will follow. const LS_KEY = 'rfc-app.cookie-consent.v1' const DEFAULT = Object.freeze({ essential: true, analytics: false, other: false, recorded_at: null, }) const listeners = new Set() function readLocal() { try { const raw = localStorage.getItem(LS_KEY) if (!raw) return null const parsed = JSON.parse(raw) if (!parsed || typeof parsed !== 'object') return null return normalize(parsed) } catch { return null } } function writeLocal(record) { try { localStorage.setItem(LS_KEY, JSON.stringify(normalize(record))) } catch { // localStorage may be unavailable (private mode, disabled storage). // In that case we behave as if no choice was ever made — the banner // shows on every load. Acceptable per §14.5: the user can still // refuse to consent on each visit. } } function normalize(record) { return { essential: true, analytics: !!record.analytics, other: !!record.other, recorded_at: record.recorded_at || null, } } // In-memory snapshot. Initialised lazily on first read so the module // import order doesn't matter; refreshed by `setConsent` and // `hydrateFromServer`. let _snapshot = null function snapshot() { if (_snapshot == null) { _snapshot = readLocal() || { ...DEFAULT } } return _snapshot } function emit() { for (const cb of listeners) { try { cb(snapshot()) } catch {} } } /** Read the current consent record. Always returns a normalized object; * `recorded_at: null` means the user has not yet chosen. */ export function getConsent() { return snapshot() } /** True if the user has made a choice. The banner uses this to decide * whether to render itself on load. */ export function hasChosen() { return snapshot().recorded_at != null } /** Subscribe to consent updates. Returns an unsubscribe function. */ export function onConsentChange(cb) { listeners.add(cb) return () => listeners.delete(cb) } /** Write a new choice locally and emit. Returns the new snapshot. The * server-side persistence path is handled separately by the banner / * settings surface via the API client; this helper is for both anon * and authenticated callers because localStorage is the always-on * layer (the server row is a backup that survives sign-out). */ export function setConsent({ analytics = false, other = false } = {}) { const next = normalize({ analytics, other, recorded_at: new Date().toISOString(), }) _snapshot = next writeLocal(next) emit() return next } /** Adopt a server-side record as authoritative. Called by the banner / * settings surface after sign-in when the server returns a non-null * recorded_at. Updates local + memory + emits to subscribers. */ export function hydrateFromServer(record) { if (!record || !record.recorded_at) return snapshot() const next = normalize(record) _snapshot = next writeLocal(next) emit() return next } /** Reset local state — used by the settings "Change" affordance to * re-prompt the banner. Does not touch the server row; the user must * re-confirm a choice and the banner uploads on save. */ export function clearLocal() { try { localStorage.removeItem(LS_KEY) } catch {} _snapshot = { ...DEFAULT } emit() return _snapshot } // Cross-tab sync: if another tab writes the key, mirror the change here. // Wrapped in a guard so SSR / non-browser test contexts don't blow up. if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { window.addEventListener('storage', e => { if (e.key !== LS_KEY) return _snapshot = readLocal() || { ...DEFAULT } emit() }) }