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:
Ben Stull
2026-05-28 00:53:21 -07:00
parent f8e797ab09
commit 8aa65014b4
17 changed files with 1352 additions and 4 deletions
+154
View File
@@ -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()
})
}