8aa65014b4
Roadmap item #11. Ships the non-modal bottom-of-page cookie consent banner, the default /privacy and /cookies policy pages, the `cookie_consent` table + two §17 endpoints for server-side persistence, the localStorage fallback for anonymous viewers, the /settings "Privacy & cookies" tab for revisiting the choice, and the `frontend/src/lib/consent.js` helper that roadmap item #13's analytics SDK (v0.15.0) will gate against. No analytics SDK ships in this release — the consent infrastructure goes in first so the gate is already in place. Adds SPEC §14.5 / §14.6, lists two new endpoints in §17, names the new table in §5, and surfaces four §19.2 candidates (content-repo file vs env-var policy, GPC / DNT headers, i18n, item-#13 dependency). Two new optional env vars (`VITE_PRIVACY_POLICY_URL`, `VITE_COOKIES_POLICY_URL`) — defaults render the framework's stub pages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
155 lines
4.8 KiB
JavaScript
155 lines
4.8 KiB
JavaScript
// 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()
|
|
})
|
|
}
|