Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ddeb5c1cd |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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=
|
||||
|
||||
Generated
+2
-2
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.5.0",
|
||||
"version": "0.13.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 & 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 → Privacy & 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> · </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() {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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 → Privacy & 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>
|
||||
)
|
||||
}
|
||||
@@ -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 → Privacy & 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user