Compare commits

...

1 Commits

Author SHA1 Message Date
Ben Stull 3ddeb5c1cd 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>
2026-05-28 00:53:21 -07:00
17 changed files with 1348 additions and 4 deletions
+98
View File
@@ -23,6 +23,104 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond
that.
## 0.13.0 — 2026-05-28
**Minor — schema migration required; new optional env vars.** This
release ships the cookie / privacy consent surface (roadmap item #11,
SPEC §14.5 / §14.6). Every viewer — authenticated and anonymous alike —
now sees a non-modal bottom-of-page banner on first visit asking which
categories of cookies they allow (essential / essential + analytics /
essential + analytics + other). The choice persists in `localStorage`
for anonymous viewers and in a new `cookie_consent` table for
authenticated viewers, with server-side overriding local on sign-in.
The framework also ships default `/privacy` and `/cookies` policy pages
that deployments can layer their own policy URL on top of via two new
optional env vars. No analytics SDK ships in this release — the
consent infrastructure is wired so item #13 (v0.15.0) can read from
`frontend/src/lib/consent.js` when the SDK lands.
### Added
- **Cookie consent banner** (`frontend/src/components/CookieConsentBanner.jsx`).
Non-modal, bottom of viewport. Three single-select choices with
inline descriptions. Visible until the user makes a choice; hides
thereafter. Reachable for revision via the settings surface.
- **Consent helper** (`frontend/src/lib/consent.js`). Exports
`getConsent()`, `hasChosen()`, `onConsentChange(cb)`, `setConsent()`,
`hydrateFromServer()`, `clearLocal()`. Cross-tab sync via the
`storage` event. Item #13's analytics SDK reads consent here before
importing.
- **Privacy and cookies policy pages**
(`frontend/src/pages/Privacy.jsx`, `frontend/src/pages/Cookies.jsx`).
Default minimal policies that describe the framework's stance and
list the cookies the framework sets. Deployments override via the
two new env vars below; the framework's stub always renders above
the link so the framework-level contract stays visible.
- **"Privacy & cookies" tab** in `/settings/notifications` showing
the current consent choice, the recorded-at stamp, and a "Change"
button that re-opens the banner via a custom DOM event.
- **`§17` endpoints** —
- `GET /api/users/me/cookie-consent` — read the current consent
record.
- `PUT /api/users/me/cookie-consent` — write a new consent record.
Upserts a single row per user, stamps `recorded_at` to now,
accepts `essential` for symmetry but always persists it as true.
- **Schema migration** `012_cookie_consent.sql` — new
`cookie_consent` table keyed by `user_id`, three flags
(`essential`, `analytics`, `other_cookies`), and `recorded_at`.
- **SPEC `§14.5` Cookie / privacy consent** — settles the banner
shape, the three-category single-select, the storage shape (local
for anon, server row for authenticated), the precedence rule on
sign-in, and the `consent.js` helper surface for downstream
callers including item #13.
- **SPEC `§14.6` Privacy and cookies policy pages** — settles the
`/privacy` and `/cookies` routes, the framework's stub content, and
the `VITE_PRIVACY_POLICY_URL` / `VITE_COOKIES_POLICY_URL` override
shape.
- **SPEC `§5`** — names the `cookie_consent` table in the canonical
app-tables list.
- **SPEC `§17`** — lists the two new cookie-consent endpoints.
- **SPEC `§19.2`** — surfaces four candidates: policy content via
content-repo file vs env var, GPC / DNT headers, multi-language
consent text, and the item #13 analytics-SDK gating dependency.
### Changed
- **`frontend/.env.example`** — documents the two new optional env
vars `VITE_PRIVACY_POLICY_URL` and `VITE_COOKIES_POLICY_URL`. Unset
is supported; defaults render the framework's stub.
- **`backend/app/api_notifications.py`** — module docstring grew two
endpoint lines; the new endpoints sit alongside the existing
`/api/users/me/*` neighbors.
- **`frontend/src/App.jsx`** — registers `/privacy` and `/cookies`
routes (anonymous-reachable), wires `<CookieConsentBanner>` into
the global chrome, and listens for a `rfc-app:cookie-consent-reopen`
custom event to re-open the banner from the settings surface.
### Upgrade steps (from 0.7.0)
- You **MUST** rebuild the frontend and restart the backend after
upgrading. `frontend/package.json#version` and `VERSION` both move
to `0.13.0` and the build embeds the new env-var contract.
- You **MUST** apply schema migration `012_cookie_consent.sql`. The
migration creates a single new table keyed by `user_id` with three
flag columns and a `recorded_at` stamp. The framework runs
migrations automatically at process start; no manual step is
required beyond restarting the backend so the migration runner
picks the file up.
- You **MAY** set `VITE_PRIVACY_POLICY_URL` to an http(s) URL that
points at your deployment's full privacy policy. The framework's
`/privacy` page renders its built-in stub above a link to the
configured URL. Unset is supported — the stub is sufficient for a
default-config deployment.
- You **MAY** set `VITE_COOKIES_POLICY_URL` to an http(s) URL that
points at your deployment's full cookies policy. Same shape as the
privacy URL.
- You **MAY** announce the new consent banner to your users. Existing
authenticated users will see the banner on their next visit
(because their `cookie_consent` row does not yet exist); their
current sessions remain valid.
## 0.5.0 — 2026-05-27
**Minor — no operator action required.** This release wires the
+136
View File
@@ -332,6 +332,13 @@ and exact columns are illustrative; the implementing session can adjust.
- `actions` — append-only audit log for every state transition, every
graduation, every grant change. Includes the acting user, the bot
commit hash if any, and the on-behalf-of trailer applied.
- `cookie_consent` — per-user record of the §14.5 cookie consent
choice. One row per user. Columns: `user_id` (PK, FK users), three
flags (`essential`, `analytics`, `other_cookies`), and
`recorded_at`. `essential` is permanently 1; `recorded_at` is set
on first write and updated on every change. Absence of a row means
"no choice yet" — the banner shows. Anonymous viewers persist their
choice in `localStorage` only, with no corresponding row here.
**Super-draft scoping.** For rows in `threads` and `changes` where the
entry referenced by `rfc_slug` is in state `super-draft`, `branch_name`
@@ -1973,6 +1980,92 @@ The visual design of the landing page and the `/philosophy` route —
typography, layout, illustrations if any — is deferred. The structural
decisions above are the binding part.
### 14.5 Cookie / privacy consent (v0.13.0)
The framework ships a non-modal cookie consent banner reachable by
every viewer — authenticated and anonymous alike. The banner appears
at the bottom of the viewport on first load and stays visible until
the user makes a choice, after which it hides and the choice is
persisted. The `/settings/notifications` page carries a "Privacy &
cookies" tab that surfaces the current choice and re-opens the banner
on demand.
The choice has three categories, presented as a single-select:
- **Essential only** — the framework's strictly-necessary cookies
(sign-in session, signed payloads, the consent-choice record
itself). Always on; the user cannot switch this off because the
app cannot function without it.
- **Essential + analytics** — adds the optional analytics layer
gated by this choice. As of v0.13.0 no analytics SDK ships in the
framework; roadmap item #13 (v0.15.0) lands one behind this gate.
Off by default — the user has to opt in.
- **Essential + analytics + other** — adds third-party embeds or
social widgets a deployment may configure. The framework ships no
such cookies by default; this category exists so deployments that
add them have a categorized opt-in to wire them behind.
Storage shape:
- **Anonymous viewer** — choice persists in `localStorage` only
(`rfc-app.cookie-consent.v1`). The same browser carries the choice
forward; a different browser, or cleared storage, re-prompts.
- **Authenticated viewer** — choice persists in the `cookie_consent`
row keyed by `user_id`. On sign-in, the server row (if present)
overrides the local snapshot; if the server has no row, the local
choice is uploaded.
The `essential` flag is permanently true at the API surface. The
endpoint accepts it for symmetry but never persists a false value.
A deployment that wants strictly-necessary cookies to be optional
must change the framework contract, not flip a flag.
The framework exports a small JavaScript helper (`frontend/src/lib/
consent.js`) for downstream surfaces:
- `getConsent()` — current snapshot.
- `hasChosen()` — true once the user has made a choice.
- `onConsentChange(cb)` — subscribe to updates.
- `setConsent({analytics, other})` — record a new choice locally
(the banner / settings surface handles server persistence on top).
Roadmap item #13's analytics SDK (v0.15.0) will read from this helper:
read consent, then conditionally `import()` the SDK module. The gate
is wired before the SDK lands so the contract is already in place.
### 14.6 Privacy and cookies policy pages
The framework ships two policy routes:
- `/privacy` — a minimal default privacy policy that describes the
framework's stance (what is stored, why, how to revoke consent,
how to reach the deployment operator). The page is reachable by
anonymous and authenticated viewers alike.
- `/cookies` — the framework's cookies policy, listing exactly which
cookies the framework sets, by category. Self-documenting: a future
framework release that adds or removes a cookie updates this page
as part of the change.
Each page links to the other and to the §14.5 banner. The consent
banner links to both.
Deployments override the policy content via two optional build-time
env vars documented in `frontend/.env.example`:
- `VITE_PRIVACY_POLICY_URL` — an http(s) URL the `/privacy` page
links to as the "full deployment policy". The framework's stub
always renders above the link so the framework-level contract is
always visible; the link layers deployment-specific content on
top.
- `VITE_COOKIES_POLICY_URL` — same shape for `/cookies`.
Both are optional. Unset is the supported default; the stub pages are
sufficient for a default-config deployment that has nothing
deployment-specific to add. The framework chose the env-var path over
a content-repo file because it composes with the existing build-time
config layer; the content-repo-file alternative is the §19.2
candidate.
---
## 15. Notifications
@@ -2716,6 +2809,16 @@ The follow-up session will refine this. A minimal starting set:
short confirmation page.
- `POST /api/webhooks/email-bounce` — bounce and complaint receiver
per §15.4; sets the recipient's global email opt-out.
- `GET /api/users/me/cookie-consent` — read the signed-in user's
cookie consent record per §14.5. Returns `{essential, analytics,
other, recorded_at}`. `recorded_at: null` means "no choice yet"
and the banner should be shown; the framework treats absence of a
row as equivalent to that. `essential` is permanently true.
- `PUT /api/users/me/cookie-consent` — write the signed-in user's
cookie consent record per §14.5. Body: `{essential, analytics,
other}`. The `essential` flag is accepted for symmetry but always
persisted as true. Upserts (a single row per user) and stamps
`recorded_at` to now.
Plus all the chat / streaming / model-picker endpoints, scoped to
per-RFC and per-branch threads.
@@ -3448,6 +3551,39 @@ the new §15 (Notifications, in full), and §17 (the notification
endpoints — list, mark-read, stream, watch mutation, preferences,
quiet-hours, per-user mute, unsubscribe, bounce webhook).
Candidates surfaced during v0.13.0 (cookie / privacy consent, §14.5
and §14.6):
- **Policy content via content-repo file vs env var.** v0.13.0
shipped the deployment-policy-override path as two env vars
(`VITE_PRIVACY_POLICY_URL`, `VITE_COOKIES_POLICY_URL`) that the
framework's stub pages link out to. The alternative — accepting
a markdown file path the framework renders inline, parallel to
`PHILOSOPHY_PATH` per §14.2 — was deferred. The two compose:
a deployment could carry both an inline file (rendered above
the fold) and an external link (rendered below). Earns its own
topic when a real deployment ships a policy long enough that
the link-out shape bites and renders the link unread.
- **Global Privacy Control / Do-Not-Track headers.** v0.13.0
scoped the consent surface to the in-app banner and did not
honor browser-side GPC or DNT signals. The framework's stance
is that the in-app banner is the authoritative gesture — a
user who clears their consent in the banner has expressed
intent, and the GPC header is a coarser signal layered on top.
Earns its own topic if a regulatory regime emerges that treats
GPC as the legally-binding gesture, in which case the framework
would honor GPC as an automatic "essential only" choice unless
the user explicitly broadened it in-app.
- **Multi-language consent text.** The banner ships English-only.
i18n of the framework's user-facing strings is a broader topic
than the consent banner; carrying the work in that future topic
rather than as a per-surface translation pass.
- **Analytics SDK gating against `consent.js`.** Roadmap item #13
(target v0.15.0) lands the analytics SDK behind
`lib/consent.js`'s `getConsent().analytics` gate. The framework
contract is already in place; the SDK integration is the work
the item ships. Listed here so the dependency is documented.
### 19.3 Working agreement for the queue
Pre-build sessions ran on the queue agreement from prior versions
+1 -1
View File
@@ -1 +1 @@
0.5.0
0.13.0
+79
View File
@@ -14,6 +14,8 @@ The endpoints in this module are:
- `POST /api/users/me/quiet-hours` — set / clear
- `POST /api/users/<id>/notification-mute` — §15.8
- `DELETE /api/users/<id>/notification-mute` — §15.8
- `GET /api/users/me/cookie-consent` — §14.5
- `PUT /api/users/me/cookie-consent` — §14.5
- `GET /api/email/unsubscribe` — §15.4 one-click
- `POST /api/webhooks/email-bounce` — §15.4 receiver
@@ -73,6 +75,15 @@ class BounceBody(BaseModel):
kind: str = Field(default="hard") # 'hard' or 'complaint'
class CookieConsentBody(BaseModel):
# `essential` is always true at the surface; we accept it for symmetry
# but never persist a false value (the framework's strictly-necessary
# cookies are not user-optional per SPEC §14.5).
essential: bool = True
analytics: bool = False
other: bool = False
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
@@ -362,6 +373,74 @@ def make_router(config: Config) -> APIRouter:
)
return {"ok": True}
# ----- Cookie consent (v0.13.0 / roadmap item #11; SPEC §14.5) -----
#
# The shape is intentionally small: three flags + a recorded-at stamp.
# The banner's local-vs-server precedence rule lives in the frontend
# (`consent.js`): on sign-in, the server row (if any) overrides local;
# otherwise local is uploaded.
@router.get("/api/users/me/cookie-consent")
async def get_cookie_consent(request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
row = db.conn().execute(
"""
SELECT essential, analytics, other_cookies, recorded_at
FROM cookie_consent WHERE user_id = ?
""",
(viewer.user_id,),
).fetchone()
if row is None:
return {
"essential": True,
"analytics": False,
"other": False,
"recorded_at": None,
}
return {
"essential": bool(row["essential"]),
"analytics": bool(row["analytics"]),
"other": bool(row["other_cookies"]),
"recorded_at": row["recorded_at"],
}
@router.put("/api/users/me/cookie-consent")
async def set_cookie_consent(body: CookieConsentBody, request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
# `essential` is the framework's strictly-necessary set; the
# surface accepts the flag for symmetry but never persists a
# false value. SPEC §14.5: a deployment that wants to make
# session-cookie storage optional must change the framework
# contract, not flip a flag here.
db.conn().execute(
"""
INSERT INTO cookie_consent
(user_id, essential, analytics, other_cookies, recorded_at)
VALUES (?, 1, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
essential = 1,
analytics = excluded.analytics,
other_cookies = excluded.other_cookies,
recorded_at = excluded.recorded_at
""",
(
viewer.user_id,
1 if body.analytics else 0,
1 if body.other else 0,
),
)
row = db.conn().execute(
"SELECT recorded_at FROM cookie_consent WHERE user_id = ?",
(viewer.user_id,),
).fetchone()
return {
"ok": True,
"essential": True,
"analytics": bool(body.analytics),
"other": bool(body.other),
"recorded_at": row["recorded_at"] if row else None,
}
# ----- Email: one-click unsubscribe + bounce webhook -----
@router.get("/api/email/unsubscribe")
+35
View File
@@ -0,0 +1,35 @@
-- v0.13.0 / roadmap item #11 — cookie consent.
--
-- The framework now ships a non-modal cookie consent banner per the
-- privacy-and-cookies UX (SPEC §14.5 / §14.6). Authenticated viewers
-- get their choice persisted server-side so it survives sign-out /
-- sign-in across devices; anonymous viewers persist their choice in
-- localStorage only.
--
-- Shape: a single row per user, three flags, plus a recorded-at stamp.
-- The flags are:
-- - essential: the framework's strictly-necessary cookies (session,
-- itsdangerous-signed payloads, CSRF if any). Permanently
-- true at the API surface — included in the row for
-- symmetry with the analytics / other flags rather than
-- because the user can switch it off.
-- - analytics: reserved for the §13 analytics SDK gating that lands
-- in v0.15.0. Off by default; opt-in via the banner.
-- - other: everything else (third-party embeds, social widgets).
-- Off by default; opt-in via the banner.
--
-- A NULL recorded_at means "no choice yet" — the banner should re-prompt
-- the next time the user signs in on a fresh device. Once recorded_at is
-- set, the banner is hidden until the user re-opens it from the
-- /settings/notifications "Privacy & cookies" tab.
--
-- The row is created lazily on first PUT. Absence of a row is equivalent
-- to NULL recorded_at — the banner shows.
CREATE TABLE cookie_consent (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
essential INTEGER NOT NULL DEFAULT 1 CHECK (essential IN (0, 1)),
analytics INTEGER NOT NULL DEFAULT 0 CHECK (analytics IN (0, 1)),
other_cookies INTEGER NOT NULL DEFAULT 0 CHECK (other_cookies IN (0, 1)),
recorded_at TEXT
);
@@ -0,0 +1,205 @@
"""End-to-end tests for v0.13.0 / roadmap item #11 — cookie / privacy consent.
Covers the §17 endpoints (`GET` / `PUT /api/users/me/cookie-consent`) and
the §14.5 storage contract:
* GET on a fresh user returns no-choice-yet (recorded_at is None,
essential=True, analytics=False, other=False).
* PUT writes a row, stamps recorded_at, and the choice survives.
* PUT with `analytics=true, other=false` round-trips faithfully.
* `essential` is permanently true at the API surface — a PUT that
requests essential=false is still persisted with essential=true.
* The endpoint requires authentication (401 for anon).
* A second PUT updates the existing row in place (single row per
user, recorded_at re-stamps).
* Choice persists across sign-out / sign-in.
"""
from __future__ import annotations
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
def test_get_cookie_consent_fresh_user_has_no_choice(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.get("/api/users/me/cookie-consent")
assert r.status_code == 200, r.text
body = r.json()
assert body["essential"] is True
assert body["analytics"] is False
assert body["other"] is False
assert body["recorded_at"] is None
def test_put_cookie_consent_records_choice(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": True, "other": False},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert body["essential"] is True
assert body["analytics"] is True
assert body["other"] is False
assert body["recorded_at"] is not None
# Round-trip the read endpoint.
r = client.get("/api/users/me/cookie-consent")
body = r.json()
assert body["essential"] is True
assert body["analytics"] is True
assert body["other"] is False
assert body["recorded_at"] is not None
def test_put_cookie_consent_forces_essential_true(app_with_fake_gitea):
"""§14.5: `essential` is permanently true at the API surface. A
request that sets it to false is accepted (for symmetry with the
other two flags) but persisted as true.
"""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.put(
"/api/users/me/cookie-consent",
json={"essential": False, "analytics": False, "other": False},
)
assert r.status_code == 200, r.text
assert r.json()["essential"] is True
# Confirm at the schema layer too — the persisted row has essential=1.
row = db.conn().execute(
"SELECT essential FROM cookie_consent WHERE user_id = ?",
(2,),
).fetchone()
assert row["essential"] == 1
def test_cookie_consent_requires_auth(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/api/users/me/cookie-consent")
assert r.status_code == 401, r.text
r = client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": True, "other": True},
)
assert r.status_code == 401, r.text
def test_put_cookie_consent_upserts_in_place(app_with_fake_gitea):
"""A second PUT updates the existing row rather than inserting a new
one. Verifies the §14.5 single-row-per-user shape.
"""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": True, "other": False},
)
client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": False, "other": True},
)
rows = db.conn().execute(
"SELECT analytics, other_cookies FROM cookie_consent WHERE user_id = ?",
(2,),
).fetchall()
assert len(rows) == 1
assert rows[0]["analytics"] == 0
assert rows[0]["other_cookies"] == 1
def test_cookie_consent_persists_across_sign_out_in(app_with_fake_gitea):
"""§14.5 precedence: the server row survives sign-out / sign-in.
"""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": True, "other": True},
)
# Simulate sign-out by clearing the session cookie.
client.cookies.clear()
# Anonymous viewer cannot read.
r = client.get("/api/users/me/cookie-consent")
assert r.status_code == 401
# Sign back in as Alice. The server row is still there.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.get("/api/users/me/cookie-consent")
body = r.json()
assert body["analytics"] is True
assert body["other"] is True
assert body["recorded_at"] is not None
def test_two_users_have_independent_rows(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=3, login="bob", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": True, "other": False},
)
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": False, "other": False},
)
# Each user reads their own row.
r = client.get("/api/users/me/cookie-consent").json()
assert r["analytics"] is False # Bob's
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.get("/api/users/me/cookie-consent").json()
assert r["analytics"] is True # Alice's
+25
View File
@@ -24,3 +24,28 @@ VITE_APP_NAME=
# VITE_BETA_CONTACT=ben@wiggleverse.org
# VITE_BETA_CONTACT=DM @ben on Matrix
VITE_BETA_CONTACT=
# Optional URL to the deployment's privacy policy (v0.13.0+, SPEC §14.5).
# The framework ships a minimal default privacy policy at `/privacy`
# that describes the framework's stance and lists the cookies the
# framework sets. When this var is set to an http(s) URL, the page
# renders the framework's stub above a link to the configured URL —
# deployments use this to layer their own policy content on top
# without forking the framework. Unset is OK; the stub is sufficient
# for a deployment that has nothing specific to add.
#
# Examples:
# VITE_PRIVACY_POLICY_URL=https://wiggleverse.org/privacy
VITE_PRIVACY_POLICY_URL=
# Optional URL to the deployment's cookies policy (v0.13.0+, SPEC §14.6).
# Same shape as VITE_PRIVACY_POLICY_URL. The framework's default
# `/cookies` page lists exactly which cookies the framework sets
# (rfc_session, the consent-choice localStorage entry); a deployment
# that adds its own cookies (analytics SDK once #13 lands, third-party
# embeds) points this var at a page that documents the full list.
# Unset is OK; the stub is sufficient for a default-config deployment.
#
# Examples:
# VITE_COOKIES_POLICY_URL=https://wiggleverse.org/cookies
VITE_COOKIES_POLICY_URL=
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rfc-app-frontend",
"version": "0.5.0",
"version": "0.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
"version": "0.5.0",
"version": "0.13.0",
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.5.0",
"version": "0.13.0",
"type": "module",
"scripts": {
"dev": "vite",
+103
View File
@@ -1864,3 +1864,106 @@
.discussion-readonly {
font-size: 12px; color: #666; padding: 4px 0;
}
/* §14.5 — cookie consent banner (v0.13.0) */
.cookie-consent-banner {
position: fixed;
left: 0; right: 0; bottom: 0;
z-index: 1000;
background: #fff;
border-top: 1px solid #d1d5db;
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08);
padding: 20px 24px;
}
.cookie-consent-body {
max-width: 880px; margin: 0 auto;
display: flex; flex-direction: column; gap: 12px;
}
.cookie-consent-title {
margin: 0; font-size: 16px; font-weight: 700; color: #111;
}
.cookie-consent-intro {
margin: 0; font-size: 13px; color: #4b5563; line-height: 1.5;
}
.cookie-consent-choices {
border: none; padding: 0; margin: 0;
display: flex; flex-direction: column; gap: 6px;
}
.cookie-consent-choice {
display: flex; gap: 10px; align-items: flex-start;
padding: 10px 12px; border-radius: 6px;
border: 1px solid #e5e7eb;
cursor: pointer;
}
.cookie-consent-choice.is-selected {
border-color: #111; background: #f9fafb;
}
.cookie-consent-choice input[type=radio] { margin-top: 3px; }
.cookie-consent-choice-text {
display: flex; flex-direction: column; gap: 2px;
}
.cookie-consent-choice-label {
font-size: 13px; font-weight: 600; color: #111;
}
.cookie-consent-choice-desc {
font-size: 12px; color: #6b7280; line-height: 1.5;
}
.cookie-consent-links {
margin: 0; font-size: 12px; color: #6b7280;
}
.cookie-consent-links a { color: #111; text-decoration: underline; }
.cookie-consent-error {
margin: 0; font-size: 12px; color: #b91c1c;
}
.cookie-consent-actions {
display: flex; gap: 8px; justify-content: flex-end;
}
.visually-hidden {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
/* §14.5 / §14.6 — privacy + cookies policy pages */
.policy-page {
max-width: 720px; margin: 0 auto;
padding: 0 32px 80px;
}
.policy-header {
display: flex; align-items: center; gap: 12px;
padding: 20px 0; border-bottom: 1px solid #f3f4f6;
margin-bottom: 24px;
}
.policy-back {
background: none; border: none; cursor: pointer;
color: #6b7280; font-size: 13px; padding: 4px 8px;
}
.policy-back:hover { color: #111; }
.policy-title { font-size: 13px; font-weight: 600; color: #6b7280; }
.policy-body { line-height: 1.7; color: #111; }
.policy-body h1 { font-size: 26px; margin: 0 0 6px; font-weight: 700; }
.policy-body .policy-subtitle { color: #6b7280; margin: 0 0 24px; font-size: 14px; }
.policy-body h2 { font-size: 16px; margin: 28px 0 8px; font-weight: 600; }
.policy-body p { margin: 0 0 12px; }
.policy-body ul { margin: 0 0 16px; padding-left: 22px; }
.policy-body li { margin-bottom: 6px; }
.policy-body code {
background: #f3f4f6; padding: 1px 5px; border-radius: 3px;
font-family: ui-monospace, monospace; font-size: 12px;
}
.policy-body .policy-footnote {
margin-top: 24px; font-size: 12px; color: #6b7280;
}
.policy-table {
width: 100%; border-collapse: collapse;
font-size: 13px; margin: 8px 0 16px;
}
.policy-table th, .policy-table td {
text-align: left; padding: 8px 10px;
border-bottom: 1px solid #f3f4f6;
vertical-align: top;
}
.policy-table th {
font-size: 11px; text-transform: uppercase;
color: #6b7280; letter-spacing: 0.05em; font-weight: 600;
}
+26
View File
@@ -13,6 +13,9 @@ import Philosophy from './components/Philosophy.jsx'
import NotificationSettings from './components/NotificationSettings.jsx'
import Admin from './components/Admin.jsx'
import ToastHost, { showToast } from './components/ToastHost.jsx'
import CookieConsentBanner from './components/CookieConsentBanner.jsx'
import Privacy from './pages/Privacy.jsx'
import Cookies from './pages/Cookies.jsx'
import './App.css'
export default function App() {
@@ -23,8 +26,19 @@ export default function App() {
const [inboxOpen, setInboxOpen] = useState(false)
const [unreadCount, setUnreadCount] = useState(0)
const [inboxTick, setInboxTick] = useState(0)
// §14.5: a tick that, when bumped, asks <CookieConsentBanner> to
// re-open even if the user has already made a choice. The settings
// "Privacy & cookies" tab dispatches a `rfc-app:cookie-consent-reopen`
// event that bumps this.
const [consentReopenTick, setConsentReopenTick] = useState(0)
const navigate = useNavigate()
useEffect(() => {
const handler = () => setConsentReopenTick(t => t + 1)
window.addEventListener('rfc-app:cookie-consent-reopen', handler)
return () => window.removeEventListener('rfc-app:cookie-consent-reopen', handler)
}, [])
useEffect(() => {
getMe()
.then(setMe)
@@ -132,6 +146,10 @@ export default function App() {
<Route path="/welcome" element={<Landing />} />
<Route path="/beta-pending" element={<BetaPending />} />
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
Available to anonymous and authenticated viewers alike. */}
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
<Route path="/cookies" element={<PolicyShell><Cookies /></PolicyShell>} />
{viewer && (
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={viewer} />} />
)}
@@ -172,10 +190,18 @@ export default function App() {
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
)}
<ToastHost />
<CookieConsentBanner viewer={viewer} forceOpen={consentReopenTick} />
</div>
)
}
function PolicyShell({ children }) {
// §14.5 / §14.6 policy pages reuse the chrome-pane shape so they
// render full-width without the catalog rail. The components inside
// carry their own back affordance per Philosophy.jsx's pattern.
return <main className="chrome-pane">{children}</main>
}
function PhilosophyWithSidebar({ viewer }) {
// The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
// all use the full app body no catalog left pane, no propose modal.
+17
View File
@@ -508,6 +508,23 @@ export async function setQuietHours({ start, end, timezone } = {}) {
}))
}
// v0.13.0 / roadmap item #11: cookie consent (SPEC §14.5).
export async function getCookieConsent() {
return jsonOrThrow(await fetch('/api/users/me/cookie-consent'))
}
export async function setCookieConsent({ analytics, other } = {}) {
return jsonOrThrow(await fetch('/api/users/me/cookie-consent', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
essential: true,
analytics: !!analytics,
other: !!other,
}),
}))
}
export async function muteUser(userId) {
return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'POST' }))
}
@@ -0,0 +1,164 @@
// CookieConsentBanner.jsx v0.13.0 / roadmap item #11 / SPEC §14.5.
//
// A non-modal bottom-of-page banner that asks the user once which
// categories of cookies they accept. The framework's strictly-necessary
// cookies (session, signed payloads) are always on; the user can opt in
// or out of analytics (which gates the §13 SDK landing in v0.15.0) and
// "other" (third-party embeds, social widgets if a deployment adds any).
//
// Visible until the user makes a choice. Hides itself once the choice
// is recorded. The /settings/notifications "Privacy & cookies" tab
// surfaces the current choice and re-opens the banner via `forceOpen`.
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { getConsent, setConsent, hasChosen, hydrateFromServer } from '../lib/consent.js'
import { getCookieConsent, setCookieConsent } from '../api.js'
const CATEGORIES = [
{
key: 'essential-only',
label: 'Essential only',
description: 'Just the cookies the app needs to keep you signed in and protect submissions. (Sign-in session, signed payloads.)',
flags: { analytics: false, other: false },
},
{
key: 'essential-analytics',
label: 'Essential + analytics',
description: 'Adds anonymous usage analytics so the framework can see which surfaces get used. No third-party scripts beyond the analytics SDK.',
flags: { analytics: true, other: false },
},
{
key: 'essential-analytics-other',
label: 'Essential + analytics + other',
description: 'Adds analytics plus any third-party embeds the deployment configures (e.g. social widgets). Choose this if you want the full surface.',
flags: { analytics: true, other: true },
},
]
function selectionKeyFor(consent) {
if (consent.analytics && consent.other) return 'essential-analytics-other'
if (consent.analytics && !consent.other) return 'essential-analytics'
return 'essential-only'
}
export default function CookieConsentBanner({ viewer, forceOpen, onClosed }) {
const [open, setOpen] = useState(() => forceOpen || !hasChosen())
const [choice, setChoice] = useState(() => selectionKeyFor(getConsent()))
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
// When forceOpen flips (settings "Change" affordance), re-render the
// banner and pre-select the user's current choice.
useEffect(() => {
if (forceOpen) {
setOpen(true)
setChoice(selectionKeyFor(getConsent()))
}
}, [forceOpen])
// Server-side hydrate for authenticated viewers per the v0.13.0
// precedence rule: a server row overrides local; absent server row,
// upload the local choice.
useEffect(() => {
if (!viewer?.user_id) return
let cancelled = false
getCookieConsent()
.then(record => {
if (cancelled) return
if (record.recorded_at) {
// Server is authoritative adopt + hide the banner unless
// the settings page forced it open.
hydrateFromServer(record)
setChoice(selectionKeyFor(record))
if (!forceOpen) setOpen(false)
} else if (hasChosen()) {
// Local has a choice the server doesn't know about yet push.
const local = getConsent()
setCookieConsent({ analytics: local.analytics, other: local.other })
.then(r => hydrateFromServer(r))
.catch(() => {})
}
})
.catch(() => {
// Network or auth error leave the local-only path in place.
})
return () => { cancelled = true }
}, [viewer?.user_id]) // eslint-disable-line react-hooks/exhaustive-deps
if (!open) return null
async function save() {
setSaving(true)
setError(null)
const picked = CATEGORIES.find(c => c.key === choice) || CATEGORIES[0]
try {
setConsent(picked.flags)
if (viewer?.user_id) {
// Best-effort server persistence. A failure here doesn't
// invalidate the local choice; the banner still hides because
// the user expressed their preference. The server can catch up
// on the next sign-in via the hydrate path above.
try {
const r = await setCookieConsent(picked.flags)
hydrateFromServer(r)
} catch (e) {
setError(`Saved locally; server sync failed (${e.message}).`)
}
}
setOpen(false)
onClosed?.()
} finally {
setSaving(false)
}
}
return (
<div className="cookie-consent-banner" role="region" aria-label="Cookie consent">
<div className="cookie-consent-body">
<h2 className="cookie-consent-title">Cookies &amp; privacy</h2>
<p className="cookie-consent-intro">
This site uses cookies. Essential cookies keep you signed in and
protect your submissions; analytics and other cookies are
optional. Choose what you allow you can change this any time
from <Link to="/settings/notifications">Settings &rarr; Privacy &amp; cookies</Link>.
</p>
<fieldset className="cookie-consent-choices">
<legend className="visually-hidden">Cookie categories</legend>
{CATEGORIES.map(c => (
<label key={c.key} className={`cookie-consent-choice ${choice === c.key ? 'is-selected' : ''}`}>
<input
type="radio"
name="cookie-consent-choice"
value={c.key}
checked={choice === c.key}
onChange={() => setChoice(c.key)}
disabled={saving}
/>
<span className="cookie-consent-choice-text">
<span className="cookie-consent-choice-label">{c.label}</span>
<span className="cookie-consent-choice-desc">{c.description}</span>
</span>
</label>
))}
</fieldset>
<p className="cookie-consent-links">
<Link to="/cookies">Cookies policy</Link>
<span aria-hidden> &middot; </span>
<Link to="/privacy">Privacy policy</Link>
</p>
{error && <p className="cookie-consent-error">{error}</p>}
<div className="cookie-consent-actions">
<button
type="button"
className="btn-primary"
onClick={save}
disabled={saving}
>
{saving ? 'Saving…' : 'Save choice'}
</button>
</div>
</div>
</div>
)
}
@@ -28,7 +28,9 @@ import {
unmuteUser,
muteUser,
searchUsers,
getCookieConsent,
} from '../api.js'
import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js'
const CHURN_REFUSAL = 'Per-commit and per-message email is intentionally not offered. The digest aggregates this activity weekly.'
@@ -48,10 +50,67 @@ export default function NotificationSettings({ viewer }) {
<QuietHoursSection />
<WatchesSection />
<MutesSection viewer={viewer} />
<PrivacyCookiesSection />
</div>
)
}
// §14.5 cookie / privacy consent (v0.13.0 / roadmap item #11)
function PrivacyCookiesSection() {
const [consent, setConsent] = useState(() => getConsent())
useEffect(() => {
// Pull the server-side row on mount; if it has a recorded_at the
// local snapshot is updated via hydrate.
getCookieConsent()
.then(record => { if (record.recorded_at) hydrateFromServer(record) })
.catch(() => {})
return onConsentChange(next => setConsent(next))
}, [])
function reopenBanner() {
// App.jsx listens for this event and bumps the forceOpen tick on
// <CookieConsentBanner>. The banner pre-selects the current choice
// from the snapshot, so the user can revise rather than restart.
window.dispatchEvent(new CustomEvent('rfc-app:cookie-consent-reopen'))
}
const summary = (() => {
if (!consent.recorded_at) {
return 'No choice recorded — the consent banner is being shown to you.'
}
if (consent.analytics && consent.other) {
return 'Essential + analytics + other.'
}
if (consent.analytics) {
return 'Essential + analytics.'
}
return 'Essential only.'
})()
return (
<SectionShell
title="Privacy & cookies"
subtitle="What categories of cookies you've allowed. Essential cookies are always on; analytics and other categories are opt-in."
>
<div className="settings-row">
<span className="settings-note"><strong>Current choice:</strong> {summary}</span>
</div>
{consent.recorded_at && (
<p className="settings-note muted">Recorded {consent.recorded_at}.</p>
)}
<div className="settings-row">
<button className="btn-primary" onClick={reopenBanner}>
Change
</button>
<Link to="/cookies" className="btn-link-muted">Cookies policy</Link>
<Link to="/privacy" className="btn-link-muted">Privacy policy</Link>
</div>
</SectionShell>
)
}
// §15.4 email category toggles
function EmailPreferencesSection() {
+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()
})
}
+125
View File
@@ -0,0 +1,125 @@
// Cookies.jsx v0.13.0 / roadmap item #11 / SPEC §14.6.
//
// Lists the framework's cookies, by category, with each cookie's
// purpose. Deployments override via `VITE_COOKIES_POLICY_URL` (linked
// below the framework's stub list, same shape as the privacy page).
//
// Keeping the list in source makes the framework self-documenting:
// when a future framework release adds or removes a cookie, this page
// is the change-record. Item #13's analytics SDK will add its own row
// to the analytics-category list in v0.15.0.
import { useNavigate, Link } from 'react-router-dom'
const COOKIES = [
{
name: 'rfc_session',
category: 'Essential',
purpose: "Signed session cookie that remembers who you're signed in as. itsdangerous-signed; HttpOnly; SameSite=Lax.",
lifetime: 'Session (cleared on sign-out).',
},
{
name: 'rfc-app.cookie-consent.v1',
category: 'Essential',
purpose: 'localStorage entry (not a cookie strictly, but tracked here for symmetry) that remembers your consent choice on this device. Cleared on browser data reset.',
lifetime: 'Until cleared.',
},
]
export default function Cookies() {
const navigate = useNavigate()
const deploymentUrl = (import.meta.env.VITE_COOKIES_POLICY_URL || '').trim()
const appName = import.meta.env.VITE_APP_NAME || 'this deployment'
return (
<div className="policy-page">
<header className="policy-header">
<button
className="policy-back"
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
>
Back
</button>
<span className="policy-title">Cookies policy</span>
</header>
<article className="policy-body">
<h1>Cookies policy</h1>
<p className="policy-subtitle">
What {appName} stores in your browser, by category.
</p>
<h2>Categories</h2>
<ul>
<li>
<strong>Essential</strong> required for the app to keep
you signed in, protect submissions, and remember your
consent choice. Cannot be switched off (without these the
app cannot function).
</li>
<li>
<strong>Analytics</strong> optional anonymous usage
telemetry. Off by default; opt-in via the consent banner.
As of v0.13.0 no analytics SDK ships; roadmap item #13
(v0.15.0) adds one behind this gate.
</li>
<li>
<strong>Other</strong> third-party embeds, social
widgets, or anything else the deployment chooses to enable.
Off by default; opt-in via the consent banner. The
framework ships no such cookies by default.
</li>
</ul>
<h2>Current cookies set by the framework</h2>
<table className="policy-table">
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th>Purpose</th>
<th>Lifetime</th>
</tr>
</thead>
<tbody>
{COOKIES.map(c => (
<tr key={c.name}>
<td><code>{c.name}</code></td>
<td>{c.category}</td>
<td>{c.purpose}</td>
<td>{c.lifetime}</td>
</tr>
))}
</tbody>
</table>
<h2>Manage your choice</h2>
<p>
Change your consent any time from{' '}
<Link to="/settings/notifications">
Settings &rarr; Privacy &amp; cookies
</Link>. The "Change" affordance re-opens the consent banner
with your current selection pre-loaded.
</p>
{deploymentUrl ? (
<>
<h2>Deployment-specific cookies</h2>
<p>
This deployment may add additional cookies on top of the
framework's. See the full deployment policy at:
</p>
<p>
<a href={deploymentUrl} target="_blank" rel="noopener noreferrer">
{deploymentUrl}
</a>
</p>
</>
) : null}
<p className="policy-footnote">
See also the <Link to="/privacy">privacy policy</Link>.
</p>
</article>
</div>
)
}
+118
View File
@@ -0,0 +1,118 @@
// Privacy.jsx v0.13.0 / roadmap item #11 / SPEC §14.5.
//
// The framework's default privacy policy page. Reachable by anonymous
// and authenticated viewers alike at `/privacy`. The text below is a
// minimal stub that describes the framework's stance; deployments are
// expected to override it via the `VITE_PRIVACY_POLICY_URL` env var.
//
// When `VITE_PRIVACY_POLICY_URL` is set:
// - http(s) URL the page renders the framework's stub above a
// "Read the full deployment policy" link to the configured URL.
// We don't iframe-embed third-party policy hosts because their
// Content-Security-Policy frequently refuses framing; the link is
// the predictable affordance.
//
// The framework's stub is intentionally short the rules that matter
// to a user are: (1) what categories of cookies the app sets, (2) how
// to change consent, (3) where to reach the deployment operator with a
// complaint. Each deployment's content repo can carry a fuller version.
import { useNavigate, Link } from 'react-router-dom'
export default function Privacy() {
const navigate = useNavigate()
const deploymentUrl = (import.meta.env.VITE_PRIVACY_POLICY_URL || '').trim()
const appName = import.meta.env.VITE_APP_NAME || 'this deployment'
return (
<div className="policy-page">
<header className="policy-header">
<button
className="policy-back"
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
>
Back
</button>
<span className="policy-title">Privacy policy</span>
</header>
<article className="policy-body">
<h1>Privacy policy</h1>
<p className="policy-subtitle">
What {appName} stores, why, and how to control it.
</p>
<h2>What we store</h2>
<p>
{appName} runs on the Wiggleverse RFC framework. The framework
stores the identity you sign in with (your Gitea login,
display name, email, and avatar URL), the proposals and edits
you author, the discussion threads you participate in, and
your notification preferences. Authoring is public by design
this is a framework for public-async RFC work, and threads,
changes, and PRs are visible to anyone who reaches the
deployment. Settings (notification toggles, quiet hours, mute
list, cookie consent) are private to your account.
</p>
<h2>Cookies</h2>
<p>
The app sets a small set of cookies. The full list is on the{' '}
<Link to="/cookies">cookies policy page</Link>. You can choose
which categories you allow from the consent banner shown on
your first visit or from <Link to="/settings/notifications">
Settings &rarr; Privacy &amp; cookies</Link> any time
afterwards.
</p>
<h2>Analytics</h2>
<p>
The framework supports an optional anonymous analytics layer
gated behind your consent choice. As of v0.13.0 no analytics
SDK ships in the framework; deployments that enable analytics
do so via a later framework version (roadmap item #13). The
consent toggle exists today so the gate is already in place
when the SDK lands.
</p>
<h2>Your data, your control</h2>
<ul>
<li>Revoke cookie consent any time from settings.</li>
<li>
Edit notification preferences including the global email
opt-out from{' '}
<Link to="/settings/notifications">notification settings</Link>.
</li>
<li>
Your authored content (proposals, threads, edits) is public
and not retractable from the meta-repo's Git history. If you
need a redaction, reach the deployment operator directly.
</li>
</ul>
{deploymentUrl ? (
<>
<h2>Deployment-specific policy</h2>
<p>
This deployment may layer additional policy on top of the
framework's defaults. Read the full deployment policy at:
</p>
<p>
<a href={deploymentUrl} target="_blank" rel="noopener noreferrer">
{deploymentUrl}
</a>
</p>
</>
) : (
<>
<h2>Deployment contact</h2>
<p>
For deployment-specific privacy questions data subject
requests, redaction requests, complaints contact the
operator of {appName}.
</p>
</>
)}
</article>
</div>
)
}