Release 0.13.0: cookie/privacy consent banner + policy pages (instrumentation prep)
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>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
// 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()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user