Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8aa65014b4 | |||
| f8e797ab09 | |||
| 21743a08b1 |
+336
@@ -23,6 +23,342 @@ skip versions are the composition of each intervening adjacent
|
|||||||
release's steps in order — no A-to-B path is pre-computed beyond
|
release's steps in order — no A-to-B path is pre-computed beyond
|
||||||
that.
|
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** `013_cookie_consent.sql` — new
|
||||||
|
`cookie_consent` table keyed by `user_id`, three flags
|
||||||
|
(`essential`, `analytics`, `other_cookies`), and `recorded_at`.
|
||||||
|
(Renumbered from `012_*` during driver integration because v0.7.0
|
||||||
|
also added a `012_otc.sql` migration that landed in the integration
|
||||||
|
order before this one.)
|
||||||
|
- **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 `013_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.7.0 — 2026-05-28
|
||||||
|
|
||||||
|
**Minor — schema migration required; new auth path is additive.**
|
||||||
|
This release lands email + one-time-code sign-in (roadmap item #5,
|
||||||
|
SPEC §6.2) as the primary human-auth gesture. Users sign in by
|
||||||
|
typing their email, receiving a six-digit code via email, and
|
||||||
|
entering it. The Gitea OAuth callback (`/auth/callback`) remains
|
||||||
|
functional during migration — the new UI no longer points at it
|
||||||
|
primarily, but a "Sign in with Gitea (fallback)" link survives on
|
||||||
|
the new login surface so users with active OAuth sessions or older
|
||||||
|
invite paths still have a way in. A future release retires the
|
||||||
|
OAuth path entirely once every active user has signed in at least
|
||||||
|
once via OTC.
|
||||||
|
|
||||||
|
The migration path for existing users: first OTC sign-in matches by
|
||||||
|
`users.email` (case-insensitive) to the OAuth-era row and reuses
|
||||||
|
that row's `id` and `gitea_id`. New users provisioned via OTC carry
|
||||||
|
`gitea_id = NULL` and `gitea_login = NULL`. The `gitea_id` linker
|
||||||
|
remains the canonical handle for grandfathered users; `email`
|
||||||
|
becomes the identity key for everything provisioned after v0.7.0.
|
||||||
|
|
||||||
|
The Gitea **bot** user + token are still required (server-side git
|
||||||
|
operations — repo reads, PR creation — still flow through it). Only
|
||||||
|
the operator-facing sign-in surface moves.
|
||||||
|
|
||||||
|
### Upgrade steps (from 0.6.0)
|
||||||
|
|
||||||
|
1. **MUST** restart the backend so migration `012_otc.sql` runs.
|
||||||
|
The migration rebuilds the `users` table (SQLite cannot ALTER
|
||||||
|
COLUMN); existing rows pass through unchanged, but the new
|
||||||
|
schema relaxes `gitea_id` / `gitea_login` to nullable (with
|
||||||
|
partial unique indexes that ignore NULL) and adds a partial
|
||||||
|
unique index on `email`. A new `otc_codes` table is created.
|
||||||
|
2. **MUST** confirm the SMTP overlay is set (`SMTP_HOST`,
|
||||||
|
`SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_STARTTLS`,
|
||||||
|
`EMAIL_FROM`, `EMAIL_FROM_NAME`). The OHM overlay already
|
||||||
|
carries these as of v0.5.0; deployments without them fall back
|
||||||
|
to logging the code to stdout (dev-only path — production
|
||||||
|
visitors will not receive their codes).
|
||||||
|
3. **SHOULD** announce the new email-based sign-in to existing
|
||||||
|
users. Wording suggestion: "You can now sign in by entering
|
||||||
|
your email and a one-time code we'll send you. Your old
|
||||||
|
account is linked automatically the first time you sign in."
|
||||||
|
4. **MAY** keep the existing OAuth callback as a fallback path.
|
||||||
|
The new login UI surfaces a small "Sign in with Gitea
|
||||||
|
(fallback)" link beneath the primary email/code form; a
|
||||||
|
deployment that prefers to hide it can override the Login
|
||||||
|
component in a future framework release that exposes the link
|
||||||
|
behind a feature flag. For v0.7.0, the link is hard-coded.
|
||||||
|
|
||||||
|
### New environment variables (all optional with defaults)
|
||||||
|
|
||||||
|
- `OTC_TTL_MINUTES` (default `10`) — how long a one-time code is
|
||||||
|
valid after issuance. Re-requesting invalidates the prior code
|
||||||
|
immediately regardless of TTL.
|
||||||
|
- `OTC_REQUEST_COOLDOWN_SECONDS` (default `60`) — per-email cooldown
|
||||||
|
between successive `/auth/otc/request` calls. The endpoint returns
|
||||||
|
HTTP 429 when the cooldown blocks a request (the loud-failure
|
||||||
|
shape; the abuse path is visible rather than swallowed).
|
||||||
|
|
||||||
|
No new secrets are required. The existing `SECRET_KEY` continues to
|
||||||
|
sign session cookies; OTC codes are bcrypt-hashed at rest using a
|
||||||
|
per-row salt the library generates.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`POST /auth/otc/request`** — body `{email}`. Generates a six-digit
|
||||||
|
code, stores its bcrypt hash with an expiry, and dispatches a plain
|
||||||
|
text email via the existing SMTP layer. Returns HTTP 200 (`{ok:true}`)
|
||||||
|
uniformly so allowlist state is not leaked. Returns HTTP 429 when
|
||||||
|
the per-email cooldown blocks the request.
|
||||||
|
- **`POST /auth/otc/verify`** — body `{email, code}`. Validates the
|
||||||
|
bcrypt hash against the most-recent unconsumed non-expired row, marks
|
||||||
|
the row consumed, provisions or links the `users` row by email, and
|
||||||
|
stores the session cookie. Returns HTTP 200 on success, HTTP 400 on
|
||||||
|
any failure (expired, consumed, wrong, unknown).
|
||||||
|
- **`backend/migrations/012_otc.sql`** — creates `otc_codes` and
|
||||||
|
rebuilds `users` with nullable `gitea_id` / `gitea_login` plus a
|
||||||
|
partial unique index on `email`.
|
||||||
|
- **`backend/app/otc.py`** — the OTC request/verify state machine and
|
||||||
|
the `provision_or_link_user` linker.
|
||||||
|
- **`backend/app/email_otc.py`** — outbound OTC mail composition. Reuses
|
||||||
|
the SMTP plumbing from `email.py` (`EmailConfig.from_env()`) and the
|
||||||
|
test buffer (`_SENT`) but writes its own envelope (no unsubscribe
|
||||||
|
footer, no quiet-hours hold — OTC mail carries a credential and
|
||||||
|
ignores notification preferences).
|
||||||
|
- **`frontend/src/components/Login.jsx`** — two-step sign-in surface
|
||||||
|
at `/login`. Step 1: enter email → request code. Step 2: enter
|
||||||
|
six-digit code → verify. Cmd/Ctrl+Enter on the code field submits.
|
||||||
|
The previous header "Sign in" link and the `Welcome` component's
|
||||||
|
inline link now route to `/login` instead of jumping straight to
|
||||||
|
the Gitea OAuth dance.
|
||||||
|
- **SPEC `§6.1` / `§6.2` / `§14.1` / `§17` / `§19.2`** corrections per
|
||||||
|
§19.3 rule-2 — see below.
|
||||||
|
- **`backend/tests/test_otc_vertical.py`** — 11 new tests covering
|
||||||
|
the happy path, expired/consumed/wrong codes, the per-email rate
|
||||||
|
limit (and its per-email isolation), the allowlist gate, the
|
||||||
|
migration link to OAuth-era users, fresh provisioning, and the
|
||||||
|
prior-code-invalidation behavior on re-request.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`backend/app/auth.py#current_user`** — coerces NULL `gitea_id` and
|
||||||
|
NULL `gitea_login` to `0` / `""` so the `SessionUser` shape stays
|
||||||
|
stable for OTC-only users. The DB remains the source of truth for
|
||||||
|
"is this user OAuth-linked" (`gitea_id IS NOT NULL`).
|
||||||
|
- **`backend/requirements.txt`** — adds `bcrypt>=4.2` for OTC code
|
||||||
|
hashing. Pure-Python wheels are available on every platform the
|
||||||
|
deployment matrix targets; `pip install -r backend/requirements.txt`
|
||||||
|
picks it up.
|
||||||
|
- **`frontend/src/App.jsx`** — adds the `/login` route and replaces
|
||||||
|
the header "Sign in" `<a href="/auth/login">` with `<Link to="/login">`.
|
||||||
|
The `Welcome` component's inline sign-in link follows suit.
|
||||||
|
- **`frontend/src/components/Landing.jsx`** — the `/welcome` page's
|
||||||
|
primary action moves from "Sign in with Gitea" to "Sign in" pointing
|
||||||
|
at `/login`.
|
||||||
|
|
||||||
|
### Deferred to later releases
|
||||||
|
|
||||||
|
Per the v0.7.0 scope discipline (the foundation for items #6, #8, #9,
|
||||||
|
#10), several adjacent capabilities are intentionally not in this
|
||||||
|
release and surface as §19.2 candidates:
|
||||||
|
|
||||||
|
- **First-OTC profile capture** (first name, last name, "why") — item
|
||||||
|
#6, expected v0.8.0.
|
||||||
|
- **Open beta-access request flow** replacing the allowlist gate —
|
||||||
|
also item #6, v0.8.0.
|
||||||
|
- **Passcodes** (a long-term reauth token alternative) — item #8,
|
||||||
|
expected v0.10.0.
|
||||||
|
- **Device-trust 30-day skip** — item #9, expected v0.11.0.
|
||||||
|
- **Cloudflare Turnstile** on `/auth/otc/request` — item #10,
|
||||||
|
expected v0.12.0.
|
||||||
|
- **Removing the Gitea OAuth `/auth/callback` route entirely** — a
|
||||||
|
later release after every active user has signed in via OTC.
|
||||||
|
|
||||||
|
## 0.6.0 — 2026-05-28
|
||||||
|
|
||||||
|
**Minor — no operator action required.** A sweep-the-edges hardening
|
||||||
|
release (roadmap item #4, "anon discuss + contribute off-limits") that
|
||||||
|
audits every write-shaped backend endpoint and asserts each one
|
||||||
|
enforces an explicit `auth.require_contributor` (or stricter) gate
|
||||||
|
before doing any state-changing work. v0.3.0 hid write affordances
|
||||||
|
behind a sign-in CTA on the frontend; v0.5.0 added the PR-less
|
||||||
|
discussion surface with its own write gate; v0.6.0 sweeps the rest
|
||||||
|
and adds a regression test net so future endpoints can't quietly
|
||||||
|
ship without a gate. No schema migration, no env-var changes, no
|
||||||
|
new dependencies. The only behavioural change is one tightening: the
|
||||||
|
`GET /api/rfcs/<slug>/graduate/progress` SSE now requires
|
||||||
|
`auth.require_user` since it surfaces operator-visible step detail
|
||||||
|
(repo name, PR number, rollback steps) not part of the v0.3.0
|
||||||
|
anonymous-read contract for catalog/RFC bodies.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`backend/app/api_graduation.py`** — `GET /graduate/progress`
|
||||||
|
now calls `auth.require_user(request)` as its first line.
|
||||||
|
Anonymous callers receive 401 instead of being able to subscribe
|
||||||
|
to a graduation's SSE. The floor is `require_user` (not
|
||||||
|
`require_contributor`) so a write-muted operator can still observe
|
||||||
|
the progress of a graduation they kicked off before being muted.
|
||||||
|
- **SPEC `§6.1`** (`SPEC.md`) — the Anonymous role's bullet now
|
||||||
|
documents the v0.6.0 audit: every write-shaped endpoint in §17
|
||||||
|
enforces an explicit gate; anonymous writes refuse 401. The list
|
||||||
|
of audited write families is recorded in-line.
|
||||||
|
- **SPEC `§10.10`** — the discussion-vs-contribution section now
|
||||||
|
records that the v0.5.0 write gates have a regression test net
|
||||||
|
(`test_anon_offlimits_vertical.py`) added in v0.6.0.
|
||||||
|
- **SPEC `§17`** — the `GET /api/rfcs/<slug>/graduate/progress`
|
||||||
|
bullet now documents the `require_user` gate added in v0.6.0,
|
||||||
|
with the rationale.
|
||||||
|
|
||||||
|
### Added (tests)
|
||||||
|
|
||||||
|
- **`backend/tests/test_anon_offlimits_vertical.py`** — twelve new
|
||||||
|
tests asserting that every write-shaped endpoint surveyed in the
|
||||||
|
v0.6.0 audit refuses anonymous callers with 401, and that the five
|
||||||
|
anonymous-read surfaces (health, philosophy, auth/me, catalog,
|
||||||
|
RFC view, discussion threads, proposals) stay reachable. Sixty-six
|
||||||
|
assertions in total, covering: propose; proposal merge / decline /
|
||||||
|
withdraw; branch promote-to-branch / start-edit-branch / metadata /
|
||||||
|
manual-flush / visibility / grants (POST + DELETE) / threads (POST)
|
||||||
|
/ messages (POST) / resolve / chat-seen / changes (accept / decline
|
||||||
|
/ reask) / chat-stream; super-draft start-edit-branch + metadata;
|
||||||
|
PR pr-draft / open / seen / review / merge / withdraw /
|
||||||
|
description / resolution-branch; discussion thread create + message
|
||||||
|
post + resolve; admin role / mute / allowlist (POST + DELETE) plus
|
||||||
|
the admin reads; notification preferences / quiet-hours / watch /
|
||||||
|
mark-read / user-mute (POST + DELETE); funder credentials (POST +
|
||||||
|
DELETE) + consent (POST + DELETE); graduation kickoff + claim +
|
||||||
|
progress SSE; PR review page anonymous-readable.
|
||||||
|
|
||||||
|
### Anonymous-writeable allowlist
|
||||||
|
|
||||||
|
Two endpoints are intentionally anonymous-by-design. They are not
|
||||||
|
audit findings; they are documented here so the contract is
|
||||||
|
explicit:
|
||||||
|
|
||||||
|
- `GET /auth/login` and `GET /auth/callback` — the OAuth
|
||||||
|
round-trip. Anonymous-by-design because they ARE the sign-in
|
||||||
|
entrypoint.
|
||||||
|
- `POST /api/webhooks/gitea` and `POST /api/webhooks/email-bounce`
|
||||||
|
— anonymous in the session sense but authenticated by HMAC shared
|
||||||
|
secret (`GITEA_WEBHOOK_SECRET` and `WEBHOOK_EMAIL_BOUNCE_SECRET`
|
||||||
|
respectively). The webhook receiver is the wrong place for an
|
||||||
|
authenticated session; the shared-secret shape is correct.
|
||||||
|
|
||||||
|
### §19.2 candidates surfaced
|
||||||
|
|
||||||
|
- None unique to this release. The two pre-existing candidates the
|
||||||
|
audit touched — anonymous-read polish for the discussion surface
|
||||||
|
(carried from v0.5.0) and the operator-visibility floor on
|
||||||
|
graduation progress — were settled here as `require_user` on
|
||||||
|
`/graduate/progress` rather than deferred.
|
||||||
|
|
||||||
|
### Upgrade steps (from 0.5.0)
|
||||||
|
|
||||||
|
1. The deployment **MUST** rebuild the frontend (`npm install &&
|
||||||
|
npm run build`) so the frontend bundle's reported version matches
|
||||||
|
the backend's. No new env vars; existing `frontend/.env` is
|
||||||
|
sufficient.
|
||||||
|
2. The deployment **MUST** restart the backend so the new
|
||||||
|
`require_user` gate on `/graduate/progress` is enforced. No
|
||||||
|
schema migration runs.
|
||||||
|
3. The deployment **MAY** announce the audit completion to
|
||||||
|
operators: every write-shaped backend endpoint now enforces an
|
||||||
|
explicit gate, and the `test_anon_offlimits_vertical.py` test
|
||||||
|
net asserts the contract on every CI run. The audited surfaces
|
||||||
|
are listed in the `Added (tests)` section above and in `SPEC.md`
|
||||||
|
§6.1.
|
||||||
|
4. The deployment **MUST NOT** assume any new envelope behaviour:
|
||||||
|
v0.6.0 is purely a hardening release. No schema, no env, no
|
||||||
|
dependency changes; the operator's role is reduced to rebuild +
|
||||||
|
restart.
|
||||||
|
|
||||||
|
|
||||||
## 0.5.0 — 2026-05-27
|
## 0.5.0 — 2026-05-27
|
||||||
|
|
||||||
**Minor — no operator action required.** This release wires the
|
**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
|
- `actions` — append-only audit log for every state transition, every
|
||||||
graduation, every grant change. Includes the acting user, the bot
|
graduation, every grant change. Includes the acting user, the bot
|
||||||
commit hash if any, and the on-behalf-of trailer applied.
|
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
|
**Super-draft scoping.** For rows in `threads` and `changes` where the
|
||||||
entry referenced by `rfc_slug` is in state `super-draft`, `branch_name`
|
entry referenced by `rfc_slug` is in state `super-draft`, `branch_name`
|
||||||
@@ -349,17 +356,42 @@ merge with no data movement.
|
|||||||
|
|
||||||
Authorization is owned by the app. Gitea sees only the bot account.
|
Authorization is owned by the app. Gitea sees only the bot account.
|
||||||
|
|
||||||
|
Authentication, as of v0.7.0, is by email + one-time-code: a visitor
|
||||||
|
enters their email address, receives a six-digit code via SMTP, and
|
||||||
|
exchanges the code for a session. The Gitea OAuth callback that
|
||||||
|
v0.1 used as the human sign-in path remains as a migration fallback
|
||||||
|
during the v0.7.0 window — `users.gitea_id` is preserved on existing
|
||||||
|
rows so a grandfathered user signing in via either path resolves to
|
||||||
|
the same row — but the primary surface points at OTC. `users.email`
|
||||||
|
is the identity key for everything provisioned after v0.7.0;
|
||||||
|
`users.gitea_id` is the grandfathering linker (nullable, partial-
|
||||||
|
unique). The Gitea bot user + token are still required for server-
|
||||||
|
side git operations (repo reads, PR creation); only the operator-
|
||||||
|
facing sign-in surface moved.
|
||||||
|
|
||||||
### 6.1 Four roles, each a strict superset of the one below
|
### 6.1 Four roles, each a strict superset of the one below
|
||||||
|
|
||||||
1. **Anonymous.** Can read public RFCs (the meta repo's main branch,
|
1. **Anonymous.** Can read public RFCs (the meta repo's main branch,
|
||||||
every RFC repo's main branch), read any branch whose `read_public`
|
every RFC repo's main branch), read any branch whose `read_public`
|
||||||
is true, read any PR. Cannot chat, propose, create branches, or
|
is true, read any PR. Cannot chat, propose, create branches, or
|
||||||
open PRs.
|
open PRs. v0.6.0 (roadmap item #4) closed the audit: every
|
||||||
2. **Contributor.** Default role for any authenticated account.
|
write-shaped endpoint surveyed in §17 enforces an explicit
|
||||||
Everything anonymous can do, plus: propose new RFCs (open a PR
|
`auth.require_contributor` (or stricter) gate before doing any
|
||||||
against the meta repo), create branches on any RFC repo, open PRs
|
state-changing work; anonymous writes refuse 401. The explicit
|
||||||
from branches they have contribute access to, chat on anything
|
audit covers propose, branch create, branch threads, PR-less
|
||||||
they can read, claim ownership of unclaimed super-drafts.
|
discussion threads + messages, PR open / merge / withdraw,
|
||||||
|
funder credentials + consent, admin allowlist add, graduation
|
||||||
|
kickoff + claim. Anonymous reads on every catalog and RFC-body
|
||||||
|
surface remain open per the v0.3.0 contract.
|
||||||
|
2. **Contributor.** Default role for any authenticated account. A
|
||||||
|
first OTC sign-in by a previously unknown email provisions a row
|
||||||
|
at this role; v0.7.0 keeps the v0.3.0 allowlist gate (`allowed_emails`)
|
||||||
|
as the admission control, deferring the open beta-access request
|
||||||
|
flow to a later release. Everything anonymous can do, plus:
|
||||||
|
propose new RFCs (open a PR against the meta repo), create
|
||||||
|
branches on any RFC repo, open PRs from branches they have
|
||||||
|
contribute access to, chat on anything they can read, claim
|
||||||
|
ownership of unclaimed super-drafts.
|
||||||
3. **Admin.** Everything contributor can do, plus: act on any RFC
|
3. **Admin.** Everything contributor can do, plus: act on any RFC
|
||||||
(merge PRs on behalf of arbiters, graduate super-drafts, set
|
(merge PRs on behalf of arbiters, graduate super-drafts, set
|
||||||
branch visibility on anyone's behalf, downgrade or restore
|
branch visibility on anyone's behalf, downgrade or restore
|
||||||
@@ -377,6 +409,14 @@ subject to the standard 30/90 hygiene rules (§12). Restoring is the
|
|||||||
reverse action. Every mute and restore is logged in
|
reverse action. Every mute and restore is logged in
|
||||||
`permission_events`.
|
`permission_events`.
|
||||||
|
|
||||||
|
The write-mute is keyed on `users.id` and is auth-path-agnostic: a
|
||||||
|
contributor muted under the v0.1 OAuth-era flow stays muted after
|
||||||
|
the v0.7.0 email/OTC migration, since the same row is reused via the
|
||||||
|
email-match linker. Identity in this section means the `users.id`
|
||||||
|
column; the v0.7.0 identity-key shift (`gitea_id` → `email`) is
|
||||||
|
about which column carries the unique constraint for new
|
||||||
|
provisioning, not about which column the permission gates read.
|
||||||
|
|
||||||
This write-mute is structurally distinct from the two notification
|
This write-mute is structurally distinct from the two notification
|
||||||
mutes introduced in §15.8 — the per-RFC notification mute (the
|
mutes introduced in §15.8 — the per-RFC notification mute (the
|
||||||
`muted` state on the `watches` row, §15.6) and the per-user
|
`muted` state on the `watches` row, §15.6) and the per-user
|
||||||
@@ -1669,8 +1709,12 @@ them was the failure mode of generic-PR-comments-as-only-conversation.
|
|||||||
|
|
||||||
Reads on the discussion surface follow §14 / the v0.3.0 anonymous-read
|
Reads on the discussion surface follow §14 / the v0.3.0 anonymous-read
|
||||||
contract: anyone can see the conversation. Writes require contributor
|
contract: anyone can see the conversation. Writes require contributor
|
||||||
role per §6.1 (v0.5.0 implements the gate; v0.6.0 — item #4 — hardens
|
role per §6.1: v0.5.0 implemented the gate on the three discussion
|
||||||
adjacent surfaces to match). The notification routing reuses the
|
write paths (POST threads, POST messages, POST resolve); v0.6.0 (item
|
||||||
|
#4) audited the adjacent surfaces and added the matching test net
|
||||||
|
(`test_anon_offlimits_vertical.py`) so a regression on any write
|
||||||
|
endpoint is caught immediately. The gates use `auth.require_contributor`
|
||||||
|
as the canonical helper. The notification routing reuses the
|
||||||
existing `chat_message_in_participated_thread` /
|
existing `chat_message_in_participated_thread` /
|
||||||
`chat_reply_to_my_message` event kinds with `branch_name=null` on the
|
`chat_reply_to_my_message` event kinds with `branch_name=null` on the
|
||||||
fan-out row; the §15.7 reconciler and §15 inbox prose render
|
fan-out row; the §15.7 reconciler and §15 inbox prose render
|
||||||
@@ -1934,8 +1978,12 @@ and its public face.
|
|||||||
The app's root URL, accessed by an unauthenticated visitor, renders a
|
The app's root URL, accessed by an unauthenticated visitor, renders a
|
||||||
landing page consisting of the title, the subtitle, and the short-form
|
landing page consisting of the title, the subtitle, and the short-form
|
||||||
deck from the top of `PHILOSOPHY.md` (see §2). Beneath the deck, a
|
deck from the top of `PHILOSOPHY.md` (see §2). Beneath the deck, a
|
||||||
single primary action: "Sign in with Gitea." Beneath that, a secondary
|
single primary action: "Sign in" → the email + one-time-code surface
|
||||||
link: "Read the full philosophy" → `/philosophy`.
|
at `/login` (per §6.2). Beneath that, a secondary link: "Read the
|
||||||
|
full philosophy" → `/philosophy`. The v0.1 landing said "Sign in
|
||||||
|
with Gitea"; v0.7.0's email/OTC surface replaced that as the primary
|
||||||
|
gesture, with a small "Sign in with Gitea (fallback)" link surviving
|
||||||
|
on `/login` itself for the migration window.
|
||||||
|
|
||||||
This is the front door. It sets expectation before the user encounters
|
This is the front door. It sets expectation before the user encounters
|
||||||
the mechanics, so the mechanics (super-drafts, graduation, public
|
the mechanics, so the mechanics (super-drafts, graduation, public
|
||||||
@@ -1973,6 +2021,92 @@ The visual design of the landing page and the `/philosophy` route —
|
|||||||
typography, layout, illustrations if any — is deferred. The structural
|
typography, layout, illustrations if any — is deferred. The structural
|
||||||
decisions above are the binding part.
|
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
|
## 15. Notifications
|
||||||
@@ -2489,6 +2623,28 @@ The follow-up session will refine this. A minimal starting set:
|
|||||||
returned `version` matches the tag the operator just deployed,
|
returned `version` matches the tag the operator just deployed,
|
||||||
catching the failure mode where a restart did not pick up the
|
catching the failure mode where a restart did not pick up the
|
||||||
new code.
|
new code.
|
||||||
|
- `POST /auth/otc/request` — unauthenticated. Body carries `email`.
|
||||||
|
Generates a six-digit code, stores its bcrypt hash with an expiry
|
||||||
|
(`OTC_TTL_MINUTES`, default 10), and dispatches a plain-text email
|
||||||
|
via the SMTP layer. Returns HTTP 200 (`{ok:true}`) uniformly so
|
||||||
|
allowlist state (§6.1 / §6.2) is not leaked to callers. Returns
|
||||||
|
HTTP 429 when the per-email cooldown (`OTC_REQUEST_COOLDOWN_SECONDS`,
|
||||||
|
default 60) blocks back-to-back requests — the loud-failure shape
|
||||||
|
for the abuse path. A re-request invalidates the prior unused
|
||||||
|
code for the same email so only one code is outstanding at a time.
|
||||||
|
Per §19.2's expected next session, this endpoint is the lead-up
|
||||||
|
to the Cloudflare-Turnstile abuse-mitigation overlay.
|
||||||
|
- `POST /auth/otc/verify` — unauthenticated. Body carries `email` and
|
||||||
|
`code`. Validates the bcrypt hash against the most-recent unconsumed
|
||||||
|
non-expired row for the email, marks the row consumed, provisions
|
||||||
|
or links the `users` row by email (per §6.2's migration path —
|
||||||
|
match by `users.email` case-insensitive, otherwise insert a fresh
|
||||||
|
contributor row with `gitea_id = NULL`), and stores the session
|
||||||
|
cookie. Returns HTTP 200 on success with a minimal user payload;
|
||||||
|
HTTP 400 on any failure (expired, consumed, wrong, unknown). The
|
||||||
|
failure modes collapse to a single generic message so a probing
|
||||||
|
client cannot distinguish "you got the wrong code" from "we don't
|
||||||
|
know this email" — the operator logs carry the distinction.
|
||||||
- `GET /api/rfcs` — list entries with state, id, title, slug, repo,
|
- `GET /api/rfcs` — list entries with state, id, title, slug, repo,
|
||||||
owners, last_active_at, has_open_prs, starred-by-me. Supports
|
owners, last_active_at, has_open_prs, starred-by-me. Supports
|
||||||
search, sort, filter chips, and the `unclaimed` predicate.
|
search, sort, filter chips, and the `unclaimed` predicate.
|
||||||
@@ -2544,7 +2700,13 @@ The follow-up session will refine this. A minimal starting set:
|
|||||||
trailing `rollback` step's events if any earlier step fails. The
|
trailing `rollback` step's events if any earlier step fails. The
|
||||||
Graduate dialog opens this stream on confirm and renders the step
|
Graduate dialog opens this stream on confirm and renders the step
|
||||||
stack from the events. The stream closes on success or on
|
stack from the events. The stream closes on success or on
|
||||||
rollback completion.
|
rollback completion. Requires `auth.require_user` per v0.6.0
|
||||||
|
(item #4): the step detail (repo name, PR number, rollback steps)
|
||||||
|
is operator-visible state and isn't part of the v0.3.0
|
||||||
|
anonymous-read contract for catalog and RFC bodies. The floor is
|
||||||
|
`require_user` (not `require_contributor`) so a write-muted
|
||||||
|
operator can still observe a graduation they kicked off before
|
||||||
|
being muted.
|
||||||
- `GET /api/rfcs/<slug>/blocking-prs` — list open meta-repo PRs
|
- `GET /api/rfcs/<slug>/blocking-prs` — list open meta-repo PRs
|
||||||
against `rfcs/<slug>.md` per §13.2's precondition popover. Returns
|
against `rfcs/<slug>.md` per §13.2's precondition popover. Returns
|
||||||
PR number, title, author, last-activity timestamp, and the
|
PR number, title, author, last-activity timestamp, and the
|
||||||
@@ -2716,6 +2878,16 @@ The follow-up session will refine this. A minimal starting set:
|
|||||||
short confirmation page.
|
short confirmation page.
|
||||||
- `POST /api/webhooks/email-bounce` — bounce and complaint receiver
|
- `POST /api/webhooks/email-bounce` — bounce and complaint receiver
|
||||||
per §15.4; sets the recipient's global email opt-out.
|
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
|
Plus all the chat / streaming / model-picker endpoints, scoped to
|
||||||
per-RFC and per-branch threads.
|
per-RFC and per-branch threads.
|
||||||
@@ -3448,6 +3620,101 @@ the new §15 (Notifications, in full), and §17 (the notification
|
|||||||
endpoints — list, mark-read, stream, watch mutation, preferences,
|
endpoints — list, mark-read, stream, watch mutation, preferences,
|
||||||
quiet-hours, per-user mute, unsubscribe, bounce webhook).
|
quiet-hours, per-user mute, unsubscribe, bounce webhook).
|
||||||
|
|
||||||
|
- **First-OTC profile capture.** *Surfaced by v0.7.0's email/OTC
|
||||||
|
migration.* When a fresh email lands at `/auth/otc/verify` with
|
||||||
|
no matching `users.email` row, v0.7.0 provisions the row with
|
||||||
|
`display_name = <local part of email>` and no other identity
|
||||||
|
fields. A subsequent release (the roadmap item-#6 candidate)
|
||||||
|
is expected to add a one-shot profile-capture step on the
|
||||||
|
first-OTC sign-in: first name, last name, and a free-text
|
||||||
|
"why I want access" field that flows into the open beta-access
|
||||||
|
request queue (also item #6) that replaces the v0.3.0
|
||||||
|
`allowed_emails` gate. The schema slot exists implicitly already
|
||||||
|
(`users.display_name` is updateable, the audit-log + permission-
|
||||||
|
events tables carry the freeform notes); the structural decision
|
||||||
|
is what gates the capture (modal on `/login` after verify? a
|
||||||
|
one-time redirect to `/welcome/profile`? a deferred banner on
|
||||||
|
the main view?) and how it interacts with the open-access
|
||||||
|
request flow that replaces the allowlist. Earns its session as
|
||||||
|
the v0.8.0 design pass.
|
||||||
|
- **Removing the Gitea OAuth fallback.** *Surfaced by v0.7.0.*
|
||||||
|
v0.7.0 keeps `/auth/callback` functional and links to it as a
|
||||||
|
"Sign in with Gitea (fallback)" affordance on the new `/login`
|
||||||
|
surface, so users with active OAuth sessions or older invite
|
||||||
|
paths still have a way in during the migration window. A later
|
||||||
|
release retires the route entirely. Decision points: how do we
|
||||||
|
know "every active user has signed in via OTC at least once"
|
||||||
|
(probably: a `users.otc_first_signed_in_at` timestamp added in
|
||||||
|
v0.7.x and a query that confirms 100% population), how do we
|
||||||
|
handle users who never come back (probably: silently leave them
|
||||||
|
with stale rows; OAuth callback returning 404 is a sufficient
|
||||||
|
message), and whether the `/auth/login` and `/auth/callback`
|
||||||
|
routes get a tombstone redirect to `/login` or just 404. Earns
|
||||||
|
its session once the OTC adoption curve flattens.
|
||||||
|
- **Device trust (30-day skip).** *Surfaced by v0.7.0 — the
|
||||||
|
signed-in cookie already lasts 30 days via SessionMiddleware,
|
||||||
|
but every sign-in still requires a fresh OTC.* The roadmap
|
||||||
|
item-#9 candidate adds a "trust this device" affordance on the
|
||||||
|
verify step that issues a longer-lived rotating token, so
|
||||||
|
returning visitors on the same device skip the OTC step. The
|
||||||
|
shape question is whether the trust is a signed cookie distinct
|
||||||
|
from the session, a row in a `device_trust` table keyed by a
|
||||||
|
random device-id, or a property of the session itself; and
|
||||||
|
whether the trust survives password-equivalent events (none
|
||||||
|
exist yet — passcodes are item #8 / v0.10.0) or only survives
|
||||||
|
explicit logout. Earns its session as the v0.11.0 design pass.
|
||||||
|
- **Cloudflare Turnstile (or equivalent) on `/auth/otc/request`.**
|
||||||
|
*Surfaced by v0.7.0 — the endpoint is now the new abuse hot
|
||||||
|
path.* Per-email cooldown stops the trivial loop; what it
|
||||||
|
doesn't stop is a distributed scrape that fans out across a
|
||||||
|
large invitee list to harvest the "this email is admitted vs.
|
||||||
|
this email is not" signal indirectly (timing differences, SMTP
|
||||||
|
bounce-rate observation). The roadmap item-#10 candidate gates
|
||||||
|
the request endpoint behind a one-step browser-side challenge
|
||||||
|
before the bcrypt hash + SMTP send. Open questions: which
|
||||||
|
provider (Turnstile is the default since it's free and
|
||||||
|
privacy-respecting; hCaptcha and reCAPTCHA are also viable);
|
||||||
|
how the deployment configures it (`TURNSTILE_SITE_KEY` +
|
||||||
|
`TURNSTILE_SECRET_KEY` env vars, gated by `if
|
||||||
|
config.turnstile_site_key:` at the handler so existing
|
||||||
|
deployments don't break); whether the verify endpoint also
|
||||||
|
gets a challenge (probably yes for parity); and how the test
|
||||||
|
harness mocks the challenge. Earns its session as the v0.12.0
|
||||||
|
design pass.
|
||||||
|
|
||||||
|
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
|
### 19.3 Working agreement for the queue
|
||||||
|
|
||||||
Pre-build sessions ran on the queue agreement from prior versions
|
Pre-build sessions ran on the queue agreement from prior versions
|
||||||
|
|||||||
@@ -81,3 +81,14 @@ WEBHOOK_EMAIL_BOUNCE_SECRET=
|
|||||||
# Production default is hourly; tests override to seconds via the same
|
# Production default is hourly; tests override to seconds via the same
|
||||||
# env var.
|
# env var.
|
||||||
HYGIENE_TICK_SECONDS=3600
|
HYGIENE_TICK_SECONDS=3600
|
||||||
|
|
||||||
|
# --- v0.7.0: email + one-time-code sign-in (§6.2) ---
|
||||||
|
# How long a one-time code stays valid after issuance. Re-requesting
|
||||||
|
# invalidates the prior code immediately regardless of TTL.
|
||||||
|
OTC_TTL_MINUTES=10
|
||||||
|
|
||||||
|
# Per-email cooldown between successive /auth/otc/request calls. The
|
||||||
|
# endpoint returns HTTP 429 when the cooldown blocks a request (the
|
||||||
|
# loud-failure shape so the abuse path is visible). Set to 0 to
|
||||||
|
# disable the cooldown — useful for tests but never in production.
|
||||||
|
OTC_REQUEST_COOLDOWN_SECONDS=60
|
||||||
|
|||||||
@@ -520,7 +520,16 @@ def make_router(
|
|||||||
|
|
||||||
@router.get("/api/rfcs/{slug}/graduate/progress")
|
@router.get("/api/rfcs/{slug}/graduate/progress")
|
||||||
async def graduate_progress(slug: str, request: Request):
|
async def graduate_progress(slug: str, request: Request):
|
||||||
del request
|
# v0.6.0 (item #4): the progress SSE surfaces admin-internal step
|
||||||
|
# detail (repo name, PR number, rollback steps) that isn't part of
|
||||||
|
# the v0.3.0 anonymous-read contract for catalog/RFC bodies. The
|
||||||
|
# corresponding POST /graduate is gated to RFC owners/arbiters and
|
||||||
|
# app admins/owners via `_can_graduate`; the read SSE shares that
|
||||||
|
# operator-visible surface, so it requires at least an
|
||||||
|
# authenticated viewer. We keep the floor at require_user (not
|
||||||
|
# require_contributor) so a write-muted operator can still observe
|
||||||
|
# the progress of a graduation they kicked off before being muted.
|
||||||
|
auth.require_user(request)
|
||||||
state = _get_active(slug)
|
state = _get_active(slug)
|
||||||
if state is None:
|
if state is None:
|
||||||
raise HTTPException(404, "No graduation in flight for this slug")
|
raise HTTPException(404, "No graduation in flight for this slug")
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ The endpoints in this module are:
|
|||||||
- `POST /api/users/me/quiet-hours` — set / clear
|
- `POST /api/users/me/quiet-hours` — set / clear
|
||||||
- `POST /api/users/<id>/notification-mute` — §15.8
|
- `POST /api/users/<id>/notification-mute` — §15.8
|
||||||
- `DELETE /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
|
- `GET /api/email/unsubscribe` — §15.4 one-click
|
||||||
- `POST /api/webhooks/email-bounce` — §15.4 receiver
|
- `POST /api/webhooks/email-bounce` — §15.4 receiver
|
||||||
|
|
||||||
@@ -73,6 +75,15 @@ class BounceBody(BaseModel):
|
|||||||
kind: str = Field(default="hard") # 'hard' or 'complaint'
|
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
|
# Router
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -362,6 +373,74 @@ def make_router(config: Config) -> APIRouter:
|
|||||||
)
|
)
|
||||||
return {"ok": True}
|
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 -----
|
# ----- Email: one-click unsubscribe + bounce webhook -----
|
||||||
|
|
||||||
@router.get("/api/email/unsubscribe")
|
@router.get("/api/email/unsubscribe")
|
||||||
|
|||||||
+8
-2
@@ -193,10 +193,16 @@ def current_user(request: Request) -> SessionUser | None:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
|
# v0.7.0: OTC-provisioned users have NULL gitea_id / gitea_login.
|
||||||
|
# Coerce nulls to the SessionUser's typed defaults so downstream
|
||||||
|
# code (Actor, _on_behalf_trailer) reads a stable shape regardless
|
||||||
|
# of which sign-in path the row came from. The DB remains the
|
||||||
|
# source of truth for "is this an OAuth-linked user" (gitea_id IS
|
||||||
|
# NOT NULL); the in-memory SessionUser is the per-request handle.
|
||||||
return SessionUser(
|
return SessionUser(
|
||||||
user_id=row["id"],
|
user_id=row["id"],
|
||||||
gitea_id=row["gitea_id"],
|
gitea_id=row["gitea_id"] or 0,
|
||||||
gitea_login=row["gitea_login"],
|
gitea_login=row["gitea_login"] or "",
|
||||||
display_name=row["display_name"],
|
display_name=row["display_name"],
|
||||||
email=row["email"] or "",
|
email=row["email"] or "",
|
||||||
avatar_url=row["avatar_url"] or "",
|
avatar_url=row["avatar_url"] or "",
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Outbound OTC email — a thin wrapper over the existing SMTP layer.
|
||||||
|
|
||||||
|
The §15.4 notification mailer in `email.py` is purpose-built for
|
||||||
|
inbox-driven mail (unsubscribe footers, quiet-hours holds, bundling).
|
||||||
|
OTC mail is structurally different: it carries a credential, has no
|
||||||
|
inbox row behind it, and ignores user-preferences (a contributor
|
||||||
|
who's opted out of every notification still needs to receive the
|
||||||
|
code they explicitly requested).
|
||||||
|
|
||||||
|
So this module reuses `EmailConfig.from_env()` for the SMTP plumbing
|
||||||
|
and the From identity, but writes its own envelope. In dev (no
|
||||||
|
SMTP_HOST set), the envelope is logged at INFO level and pushed to
|
||||||
|
the same `_SENT` buffer the notification mailer uses, so the
|
||||||
|
integration tests can assert on the outbound shape without standing
|
||||||
|
up an SMTP server.
|
||||||
|
|
||||||
|
The send is synchronous. The `/auth/otc/request` endpoint always
|
||||||
|
returns 202 regardless of send outcome — the user-facing surface
|
||||||
|
doesn't know whether the SMTP relay was reachable, since revealing
|
||||||
|
that would let an attacker probe for valid emails on a tight loop.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.utils import formataddr
|
||||||
|
|
||||||
|
from .email import EmailConfig, _SENT
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def send_otc_email(to_address: str, code: str) -> bool:
|
||||||
|
"""Compose and send the one-time-code email. Returns True on the
|
||||||
|
happy path; False on SMTP failure. The notifier-side buffer
|
||||||
|
`_SENT` is appended either way so tests can assert on content.
|
||||||
|
|
||||||
|
The subject and body intentionally avoid branding strings that
|
||||||
|
belong to a deployment — only `EMAIL_FROM_NAME` (operator-supplied
|
||||||
|
via env) lands in the From line. The body names the code, the
|
||||||
|
TTL, and a single instruction line. No tracking pixel, no
|
||||||
|
deep-link query, no embedded JS — plain text only."""
|
||||||
|
cfg = EmailConfig.from_env()
|
||||||
|
subject = f"Your sign-in code for {cfg.from_name}"
|
||||||
|
body = _body(code, cfg)
|
||||||
|
envelope = {
|
||||||
|
"to": to_address,
|
||||||
|
"from": formataddr((cfg.from_name, cfg.from_address)),
|
||||||
|
"subject": subject,
|
||||||
|
"body": body,
|
||||||
|
"kind": "otc",
|
||||||
|
}
|
||||||
|
_SENT.append(envelope)
|
||||||
|
|
||||||
|
if not cfg.enabled:
|
||||||
|
log.info("otc email disabled (EMAIL_ENABLED=0): to=%s", to_address)
|
||||||
|
return True
|
||||||
|
if not cfg.smtp_host:
|
||||||
|
# Dev fallback: surface the code at INFO so the operator can
|
||||||
|
# complete a sign-in flow without an SMTP relay. In production
|
||||||
|
# SMTP_HOST is always set per OHM's overlay.
|
||||||
|
log.info("otc email (stdout fallback): to=%s code=%s", to_address, code)
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["From"] = envelope["from"]
|
||||||
|
msg["To"] = to_address
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg.set_content(body)
|
||||||
|
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
|
||||||
|
try:
|
||||||
|
if cfg.smtp_starttls:
|
||||||
|
smtp.starttls()
|
||||||
|
if cfg.smtp_user:
|
||||||
|
smtp.login(cfg.smtp_user, cfg.smtp_password)
|
||||||
|
smtp.send_message(msg)
|
||||||
|
finally:
|
||||||
|
smtp.quit()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
log.exception("otc email send failed: to=%s", to_address)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _body(code: str, cfg: EmailConfig) -> str:
|
||||||
|
return (
|
||||||
|
f"Your sign-in code is:\n\n"
|
||||||
|
f" {code}\n\n"
|
||||||
|
f"Enter this code in the sign-in screen to finish signing in.\n"
|
||||||
|
f"The code expires in 10 minutes. If you did not request this,\n"
|
||||||
|
f"you can safely ignore this email — no account was created.\n\n"
|
||||||
|
f"---\n"
|
||||||
|
f"{cfg.from_name} · {cfg.app_url}\n"
|
||||||
|
)
|
||||||
+61
-1
@@ -12,9 +12,21 @@ from contextlib import asynccontextmanager
|
|||||||
|
|
||||||
from fastapi import APIRouter, FastAPI, HTTPException, Request
|
from fastapi import APIRouter, FastAPI, HTTPException, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from . import api as api_routes, auth, cache, db, digest, hygiene, providers as providers_mod, webhooks
|
from . import (
|
||||||
|
api as api_routes,
|
||||||
|
auth,
|
||||||
|
cache,
|
||||||
|
db,
|
||||||
|
digest,
|
||||||
|
email_otc,
|
||||||
|
hygiene,
|
||||||
|
otc,
|
||||||
|
providers as providers_mod,
|
||||||
|
webhooks,
|
||||||
|
)
|
||||||
from .bot import Bot
|
from .bot import Bot
|
||||||
from .config import load_config
|
from .config import load_config
|
||||||
from .gitea import Gitea
|
from .gitea import Gitea
|
||||||
@@ -23,6 +35,15 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
|
|||||||
log = logging.getLogger("rfc_app")
|
log = logging.getLogger("rfc_app")
|
||||||
|
|
||||||
|
|
||||||
|
class OtcRequestBody(BaseModel):
|
||||||
|
email: str = Field(min_length=3, max_length=320)
|
||||||
|
|
||||||
|
|
||||||
|
class OtcVerifyBody(BaseModel):
|
||||||
|
email: str = Field(min_length=3, max_length=320)
|
||||||
|
code: str = Field(min_length=1, max_length=16)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -122,4 +143,43 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
request.session.clear()
|
request.session.clear()
|
||||||
return RedirectResponse("/")
|
return RedirectResponse("/")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# v0.7.0: email + one-time-code sign-in (§6.2).
|
||||||
|
#
|
||||||
|
# Replaces the OAuth gesture as the primary human-auth path. The
|
||||||
|
# /auth/callback handler above remains functional as a fallback;
|
||||||
|
# the new UI no longer surfaces it. A future release retires the
|
||||||
|
# OAuth path entirely once every active user has signed in at
|
||||||
|
# least once via OTC.
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/auth/otc/request")
|
||||||
|
async def otc_request(body: OtcRequestBody):
|
||||||
|
outcome = otc.request_code(body.email)
|
||||||
|
if outcome.reason == "cooldown":
|
||||||
|
# Loud failure per the rate-limit primitive — the abuse
|
||||||
|
# surface should be visible to clients hammering /request.
|
||||||
|
raise HTTPException(429, "Wait before requesting another code")
|
||||||
|
if outcome.sent and outcome.code is not None:
|
||||||
|
email_otc.send_otc_email(body.email.strip(), outcome.code)
|
||||||
|
# 202 regardless of allowlist/invalid — don't leak which
|
||||||
|
# emails are recognized.
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@router.post("/auth/otc/verify")
|
||||||
|
async def otc_verify(body: OtcVerifyBody, request: Request):
|
||||||
|
result = otc.verify_code(body.email, body.code)
|
||||||
|
if not result.ok or result.user is None:
|
||||||
|
raise HTTPException(400, "Invalid or expired code")
|
||||||
|
auth.store_session(request, result.user)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"user": {
|
||||||
|
"id": result.user.user_id,
|
||||||
|
"display_name": result.user.display_name,
|
||||||
|
"email": result.user.email,
|
||||||
|
"role": result.user.role,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
"""§6.2 / v0.7.0: email + one-time-code sign-in.
|
||||||
|
|
||||||
|
Replaces the Gitea OAuth gesture as the primary human-auth path. The
|
||||||
|
Gitea bot user + token are still needed for server-side git
|
||||||
|
operations (repo reads, PR creation); only the operator-facing
|
||||||
|
sign-in surface moves through this module.
|
||||||
|
|
||||||
|
The shape:
|
||||||
|
|
||||||
|
* `request_code(email)` generates a 6-digit decimal code,
|
||||||
|
hashes it (bcrypt), stores the hash + expiry in `otc_codes`,
|
||||||
|
and dispatches a plain-text email via `email_otc.send`. It
|
||||||
|
invalidates any prior unused codes for the same email so a
|
||||||
|
re-request keeps the surface to one outstanding code per
|
||||||
|
address. The TTL comes from `OTC_TTL_MINUTES` (default 10).
|
||||||
|
A per-email cooldown (`OTC_REQUEST_COOLDOWN_SECONDS`, default
|
||||||
|
60) refuses back-to-back requests inside the window.
|
||||||
|
|
||||||
|
* `verify_code(email, code)` walks the most recent unconsumed
|
||||||
|
non-expired row for the email, checks the bcrypt hash, marks
|
||||||
|
the row consumed, and returns the linked or freshly-provisioned
|
||||||
|
user row.
|
||||||
|
|
||||||
|
* `provision_or_link_user(email)` is the migration path: if a
|
||||||
|
`users` row already carries `email` (case-insensitive), it is
|
||||||
|
reused — `gitea_id` is left alone so a grandfathered OAuth-era
|
||||||
|
user keeps the linker intact. Otherwise a fresh contributor
|
||||||
|
row is provisioned with `gitea_id = NULL`, `gitea_login = NULL`.
|
||||||
|
|
||||||
|
The endpoints in `main.py` thin-wrap this module. The allowlist gate
|
||||||
|
from v0.3.0 is consulted at request time — if `allowed_emails` is
|
||||||
|
populated and the requested address isn't on it, the request returns
|
||||||
|
202 as usual but no email is sent. This intentionally does not leak
|
||||||
|
allowlist state to the caller; the §19.2 candidate for v0.8.0
|
||||||
|
replaces this gate with an admin-grant flow.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .auth import SessionUser, allowlist_is_active
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tunables — env-driven with defaults so v0.7.0 needs no new secrets.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _ttl_minutes() -> int:
|
||||||
|
raw = os.environ.get("OTC_TTL_MINUTES", "").strip()
|
||||||
|
if not raw:
|
||||||
|
return 10
|
||||||
|
try:
|
||||||
|
return max(1, int(raw))
|
||||||
|
except ValueError:
|
||||||
|
return 10
|
||||||
|
|
||||||
|
|
||||||
|
def _cooldown_seconds() -> int:
|
||||||
|
raw = os.environ.get("OTC_REQUEST_COOLDOWN_SECONDS", "").strip()
|
||||||
|
if not raw:
|
||||||
|
return 60
|
||||||
|
try:
|
||||||
|
return max(0, int(raw))
|
||||||
|
except ValueError:
|
||||||
|
return 60
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Code generation + hashing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _new_code() -> str:
|
||||||
|
"""Six decimal digits. `secrets.randbelow` is CSPRNG-backed so the
|
||||||
|
code resists guessing even at the small (10^6) keyspace. The TTL
|
||||||
|
+ rate-limit are what carry the security weight — the entropy of a
|
||||||
|
six-digit code by itself is intentionally human-readable."""
|
||||||
|
return f"{secrets.randbelow(1_000_000):06d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_code(code: str) -> str:
|
||||||
|
"""bcrypt over the code bytes. The hash is stored at rest; the code
|
||||||
|
itself only travels in the outbound email and the inbound verify
|
||||||
|
body."""
|
||||||
|
return bcrypt.hashpw(code.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_code(code: str, code_hash: str) -> bool:
|
||||||
|
try:
|
||||||
|
return bcrypt.checkpw(code.encode("utf-8"), code_hash.encode("ascii"))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Allowlist gate — shared with the OAuth flow.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _allowlist_admits(email: str) -> bool:
|
||||||
|
"""The same allowlist v0.3.0 introduced for OAuth, applied to OTC
|
||||||
|
requests. If the allowlist is populated and the email is not on it,
|
||||||
|
we still respond 202 to the caller, but no code is sent."""
|
||||||
|
if not allowlist_is_active():
|
||||||
|
return True
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Request path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RequestOutcome:
|
||||||
|
"""The outcome of a `request_code` call.
|
||||||
|
|
||||||
|
`code` is None whenever no code was generated — either because the
|
||||||
|
allowlist denied the email or because the cooldown window blocked
|
||||||
|
the request. The caller (the API endpoint) does not surface this
|
||||||
|
distinction to the user; it returns 202 either way.
|
||||||
|
"""
|
||||||
|
sent: bool
|
||||||
|
code: str | None
|
||||||
|
reason: str # 'sent' | 'allowlist' | 'cooldown' | 'invalid'
|
||||||
|
|
||||||
|
|
||||||
|
def request_code(email: str) -> RequestOutcome:
|
||||||
|
email = (email or "").strip()
|
||||||
|
if not email or "@" not in email:
|
||||||
|
return RequestOutcome(sent=False, code=None, reason="invalid")
|
||||||
|
|
||||||
|
# Cooldown: refuse if a code was issued for this email in the last
|
||||||
|
# COOLDOWN_SECONDS. We surface it as a distinct outcome so the
|
||||||
|
# endpoint can return 429 — the spec calls this out as a "loud
|
||||||
|
# failure" so the abuse path is visible rather than swallowed.
|
||||||
|
cooldown = _cooldown_seconds()
|
||||||
|
if cooldown > 0:
|
||||||
|
row = db.conn().execute(
|
||||||
|
f"""
|
||||||
|
SELECT 1 FROM otc_codes
|
||||||
|
WHERE email = ?
|
||||||
|
AND datetime(created_at, '+{cooldown} seconds') > datetime('now')
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(email,),
|
||||||
|
).fetchone()
|
||||||
|
if row is not None:
|
||||||
|
return RequestOutcome(sent=False, code=None, reason="cooldown")
|
||||||
|
|
||||||
|
# Allowlist: silently drop the send if the email isn't on the list.
|
||||||
|
# The row is not written either — there's nothing for verify to
|
||||||
|
# match against, so the user-facing experience is "I never got an
|
||||||
|
# email", which is the intended shape for the private-beta gate.
|
||||||
|
if not _allowlist_admits(email):
|
||||||
|
return RequestOutcome(sent=False, code=None, reason="allowlist")
|
||||||
|
|
||||||
|
# Invalidate prior unused codes for this email. A re-request is
|
||||||
|
# always for the most recent code; older codes are dead.
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
UPDATE otc_codes
|
||||||
|
SET consumed_at = datetime('now')
|
||||||
|
WHERE email = ?
|
||||||
|
AND consumed_at IS NULL
|
||||||
|
""",
|
||||||
|
(email,),
|
||||||
|
)
|
||||||
|
|
||||||
|
code = _new_code()
|
||||||
|
code_hash = _hash_code(code)
|
||||||
|
ttl = _ttl_minutes()
|
||||||
|
db.conn().execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO otc_codes (email, code_hash, expires_at)
|
||||||
|
VALUES (?, ?, datetime('now', '+{ttl} minutes'))
|
||||||
|
""",
|
||||||
|
(email, code_hash),
|
||||||
|
)
|
||||||
|
return RequestOutcome(sent=True, code=code, reason="sent")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Verify path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VerifyOutcome:
|
||||||
|
"""Result of a `verify_code` call.
|
||||||
|
|
||||||
|
`user` is populated only on success. `reason` distinguishes the
|
||||||
|
failure modes the UI can render — 'expired', 'consumed', 'wrong',
|
||||||
|
'unknown' (no outstanding code at all). The endpoint maps the
|
||||||
|
failure modes to a single 400 with a generic message; the reason
|
||||||
|
is logged for the operator.
|
||||||
|
"""
|
||||||
|
ok: bool
|
||||||
|
user: SessionUser | None
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def verify_code(email: str, code: str) -> VerifyOutcome:
|
||||||
|
email = (email or "").strip()
|
||||||
|
code = (code or "").strip()
|
||||||
|
if not email or not code:
|
||||||
|
return VerifyOutcome(ok=False, user=None, reason="invalid")
|
||||||
|
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT id, code_hash, expires_at, consumed_at
|
||||||
|
FROM otc_codes
|
||||||
|
WHERE email = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 5
|
||||||
|
""",
|
||||||
|
(email,),
|
||||||
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
|
return VerifyOutcome(ok=False, user=None, reason="unknown")
|
||||||
|
|
||||||
|
# Walk the recent rows so a user who pasted an older code still
|
||||||
|
# gets a sensible error — without this, the most-recent-row check
|
||||||
|
# would mask "you entered yesterday's code" as "wrong code".
|
||||||
|
matched = None
|
||||||
|
for row in rows:
|
||||||
|
if _check_code(code, row["code_hash"]):
|
||||||
|
matched = row
|
||||||
|
break
|
||||||
|
|
||||||
|
if matched is None:
|
||||||
|
return VerifyOutcome(ok=False, user=None, reason="wrong")
|
||||||
|
|
||||||
|
if matched["consumed_at"] is not None:
|
||||||
|
return VerifyOutcome(ok=False, user=None, reason="consumed")
|
||||||
|
|
||||||
|
expired = db.conn().execute(
|
||||||
|
"SELECT datetime(?) < datetime('now') AS expired",
|
||||||
|
(matched["expires_at"],),
|
||||||
|
).fetchone()["expired"]
|
||||||
|
if expired:
|
||||||
|
return VerifyOutcome(ok=False, user=None, reason="expired")
|
||||||
|
|
||||||
|
# Stamp consumed before provisioning so a parallel verify of the
|
||||||
|
# same row can't double-sign-in.
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE otc_codes SET consumed_at = datetime('now') WHERE id = ?",
|
||||||
|
(matched["id"],),
|
||||||
|
)
|
||||||
|
user = provision_or_link_user(email)
|
||||||
|
return VerifyOutcome(ok=True, user=user, reason="ok")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provisioning — the migration path from OAuth identity to email identity.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def provision_or_link_user(email: str) -> SessionUser:
|
||||||
|
"""Link the OTC sign-in to a `users` row.
|
||||||
|
|
||||||
|
Match order:
|
||||||
|
1. An existing row whose email equals (case-insensitive) the
|
||||||
|
requested email — the OAuth-era user is grandfathered in via
|
||||||
|
this path. `gitea_id` is preserved so a future OAuth round
|
||||||
|
trip still resolves the same row.
|
||||||
|
2. Otherwise: a fresh contributor row with `gitea_id = NULL`,
|
||||||
|
`gitea_login = NULL`. The display name defaults to the local
|
||||||
|
part of the email (everything before the `@`) — users can
|
||||||
|
rename later via the §19.2 first-OTC profile-capture flow
|
||||||
|
that v0.8.0 introduces.
|
||||||
|
|
||||||
|
The §6.1 owner-zero bootstrap still applies: if the email matches
|
||||||
|
the configured `OWNER_GITEA_LOGIN`-derived owner identity, the row
|
||||||
|
is provisioned with role='owner'. v0.7.0 keeps that field as the
|
||||||
|
Gitea login (so existing deployments don't break); a future
|
||||||
|
release may add a parallel `OWNER_EMAIL` env if the OAuth route is
|
||||||
|
dropped entirely.
|
||||||
|
"""
|
||||||
|
email = email.strip()
|
||||||
|
existing = db.conn().execute(
|
||||||
|
"SELECT * FROM users WHERE email = ? COLLATE NOCASE",
|
||||||
|
(email,),
|
||||||
|
).fetchone()
|
||||||
|
if existing is not None:
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
|
||||||
|
(existing["id"],),
|
||||||
|
)
|
||||||
|
return SessionUser(
|
||||||
|
user_id=existing["id"],
|
||||||
|
gitea_id=existing["gitea_id"] or 0,
|
||||||
|
gitea_login=existing["gitea_login"] or "",
|
||||||
|
display_name=existing["display_name"],
|
||||||
|
email=existing["email"] or email,
|
||||||
|
avatar_url=existing["avatar_url"] or "",
|
||||||
|
role=existing["role"],
|
||||||
|
)
|
||||||
|
|
||||||
|
display = email.split("@", 1)[0] or email
|
||||||
|
cur = db.conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role)
|
||||||
|
VALUES (NULL, NULL, ?, ?, '', 'contributor')
|
||||||
|
""",
|
||||||
|
(email, display),
|
||||||
|
)
|
||||||
|
user_id = cur.lastrowid
|
||||||
|
return SessionUser(
|
||||||
|
user_id=user_id,
|
||||||
|
gitea_id=0,
|
||||||
|
gitea_login="",
|
||||||
|
display_name=display,
|
||||||
|
email=email,
|
||||||
|
avatar_url="",
|
||||||
|
role="contributor",
|
||||||
|
)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
-- §6.2 / v0.7.0: email + one-time-code sign-in.
|
||||||
|
--
|
||||||
|
-- Replaces the Gitea OAuth gesture as the primary human-auth path.
|
||||||
|
-- The Gitea bot user + token are still needed for server-side git
|
||||||
|
-- operations (repo reads, PR creation); only the operator-facing
|
||||||
|
-- sign-in surface moves. The /auth/callback OAuth route remains
|
||||||
|
-- functional during migration as a fallback, scheduled for removal
|
||||||
|
-- in a future release once every active user has signed in via OTC
|
||||||
|
-- at least once.
|
||||||
|
--
|
||||||
|
-- A row in `otc_codes` represents an outstanding 6-digit code that
|
||||||
|
-- was emailed to `email`. Codes are stored hashed (bcrypt) rather
|
||||||
|
-- than plaintext, so a database compromise does not expose the
|
||||||
|
-- in-flight code. TTL is enforced by `expires_at`. Each `verify`
|
||||||
|
-- success stamps `consumed_at` and refuses every later attempt
|
||||||
|
-- against the same row.
|
||||||
|
--
|
||||||
|
-- The §6.2 identity model under v0.7.0:
|
||||||
|
--
|
||||||
|
-- * `users.email` is the primary identity key for new sign-ins.
|
||||||
|
-- * `users.gitea_id` stays populated for users grandfathered in
|
||||||
|
-- via the OAuth-era flow; new users have `gitea_id = NULL`.
|
||||||
|
-- The unique-constraint on `gitea_id` is relaxed (in v0.5.0 it
|
||||||
|
-- was `INTEGER UNIQUE NOT NULL`) to permit the NULL.
|
||||||
|
-- * `users.email` becomes a (case-insensitive) unique key. An
|
||||||
|
-- existing OAuth user whose Gitea profile carried an email is
|
||||||
|
-- linked on first OTC sign-in; if no row matches, a fresh
|
||||||
|
-- contributor row is provisioned.
|
||||||
|
--
|
||||||
|
-- New env vars (v0.7.0):
|
||||||
|
-- * `OTC_TTL_MINUTES` (default 10): how long a code stays valid.
|
||||||
|
-- * `OTC_REQUEST_COOLDOWN_SECONDS` (default 60): per-email rate
|
||||||
|
-- limit between successive `/auth/otc/request` calls.
|
||||||
|
|
||||||
|
CREATE TABLE otc_codes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT NOT NULL COLLATE NOCASE,
|
||||||
|
code_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
consumed_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_otc_codes_email ON otc_codes (email, consumed_at, expires_at);
|
||||||
|
|
||||||
|
-- Relax `users.gitea_id` from `INTEGER UNIQUE NOT NULL` to a nullable
|
||||||
|
-- column with a partial unique index that ignores nulls. SQLite does
|
||||||
|
-- not support ALTER COLUMN, so we rebuild the table.
|
||||||
|
--
|
||||||
|
-- A few defensive notes:
|
||||||
|
-- * Every foreign key into `users(id)` continues to resolve — `id`
|
||||||
|
-- is the same INTEGER PRIMARY KEY in the rebuilt table.
|
||||||
|
-- * `email` is now declared NOCASE so a `WHERE email = ?` match
|
||||||
|
-- is case-insensitive without changing every read site. The
|
||||||
|
-- prior column accepted any text; existing rows pass through
|
||||||
|
-- unchanged.
|
||||||
|
-- * `gitea_login` likewise relaxes from NOT NULL to nullable, so
|
||||||
|
-- users provisioned by OTC alone don't carry a synthetic login.
|
||||||
|
|
||||||
|
CREATE TABLE users_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
gitea_id INTEGER,
|
||||||
|
gitea_login TEXT,
|
||||||
|
email TEXT COLLATE NOCASE,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
avatar_url TEXT,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'contributor')),
|
||||||
|
muted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
email_personal_direct INTEGER NOT NULL DEFAULT 1,
|
||||||
|
email_watched_structural INTEGER NOT NULL DEFAULT 0,
|
||||||
|
email_admin_actionable INTEGER NOT NULL DEFAULT 1,
|
||||||
|
email_opt_out_all INTEGER NOT NULL DEFAULT 0,
|
||||||
|
digest_cadence TEXT NOT NULL DEFAULT 'weekly' CHECK (digest_cadence IN ('off', 'weekly', 'daily')),
|
||||||
|
notification_quiet_hours_start TEXT,
|
||||||
|
notification_quiet_hours_end TEXT,
|
||||||
|
notification_quiet_hours_timezone TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO users_new (
|
||||||
|
id, gitea_id, gitea_login, email, display_name, avatar_url, role,
|
||||||
|
muted, email_personal_direct, email_watched_structural,
|
||||||
|
email_admin_actionable, email_opt_out_all, digest_cadence,
|
||||||
|
notification_quiet_hours_start, notification_quiet_hours_end,
|
||||||
|
notification_quiet_hours_timezone, created_at, last_seen_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, gitea_id, gitea_login, email, display_name, avatar_url, role,
|
||||||
|
muted, email_personal_direct, email_watched_structural,
|
||||||
|
email_admin_actionable, email_opt_out_all, digest_cadence,
|
||||||
|
notification_quiet_hours_start, notification_quiet_hours_end,
|
||||||
|
notification_quiet_hours_timezone, created_at, last_seen_at
|
||||||
|
FROM users;
|
||||||
|
|
||||||
|
DROP TABLE users;
|
||||||
|
ALTER TABLE users_new RENAME TO users;
|
||||||
|
|
||||||
|
CREATE INDEX idx_users_role ON users (role);
|
||||||
|
-- Partial unique indexes so NULLs are permitted but populated values
|
||||||
|
-- collide. Gitea linkage stays unique per gitea_id; OTC-era identity
|
||||||
|
-- is keyed on email (case-insensitive via NOCASE on the column).
|
||||||
|
CREATE UNIQUE INDEX idx_users_gitea_id ON users (gitea_id) WHERE gitea_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX idx_users_gitea_login ON users (gitea_login) WHERE gitea_login IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE email IS NOT NULL AND email != '';
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -8,3 +8,4 @@ anthropic>=0.39
|
|||||||
google-generativeai>=0.8
|
google-generativeai>=0.8
|
||||||
openai>=1.50
|
openai>=1.50
|
||||||
PyYAML>=6.0
|
PyYAML>=6.0
|
||||||
|
bcrypt>=4.2
|
||||||
|
|||||||
@@ -0,0 +1,476 @@
|
|||||||
|
"""v0.6.0 (roadmap item #4) — "anon discuss + contribute off-limits"
|
||||||
|
vertical.
|
||||||
|
|
||||||
|
A sweep-the-edges hardening release. The v0.3.0 release hid the write
|
||||||
|
affordances from anonymous viewers; v0.5.0 added the PR-less discussion
|
||||||
|
surface with its own write gate. v0.6.0 audits both: every write-shaped
|
||||||
|
endpoint refuses anonymous callers with 401 (or 403 when the role check
|
||||||
|
runs after the auth check), and every anonymous-read surface stays
|
||||||
|
reachable.
|
||||||
|
|
||||||
|
This test is the regression net for the audit. It walks each module's
|
||||||
|
representative write endpoint as an anonymous client and asserts the
|
||||||
|
401/403, then walks the same surfaces' representative read endpoints
|
||||||
|
as anonymous and asserts the 200. The intent is breadth over depth:
|
||||||
|
one assertion per write endpoint family is enough to catch a
|
||||||
|
regression where someone strips the `auth.require_contributor` line.
|
||||||
|
|
||||||
|
Endpoints covered (one or two from each module):
|
||||||
|
|
||||||
|
- api.py: propose, decline (admin), withdraw,
|
||||||
|
funder credentials POST/DELETE, funder consent
|
||||||
|
POST/DELETE
|
||||||
|
- api_branches.py: promote-to-branch, start-edit-branch, metadata,
|
||||||
|
manual-flush, visibility, grants POST/DELETE,
|
||||||
|
threads POST, thread messages POST, resolve,
|
||||||
|
chat-seen, change accept/decline/reask
|
||||||
|
- api_prs.py: pr-draft, open-pr, seen, review, merge, withdraw,
|
||||||
|
description, resolution-branch
|
||||||
|
- api_discussion.py: thread create, message post, resolve
|
||||||
|
- api_admin.py: role POST, mute POST, allowlist POST/DELETE
|
||||||
|
- api_notifications.py: prefs POST, watch POST, mark-read POST,
|
||||||
|
quiet-hours POST, user-mute POST/DELETE
|
||||||
|
- api_graduation.py: graduate POST, claim POST, progress GET
|
||||||
|
|
||||||
|
The §15.7 reads (`/api/notifications`, `/api/watches`,
|
||||||
|
`/api/users/me/*`) are per-user surfaces — they require an
|
||||||
|
authenticated viewer by definition; an anonymous 401 on those reads is
|
||||||
|
shape-correct, not a regression. The test does not assert reads on
|
||||||
|
those.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Reuse the fixture / session / fake-Gitea harness from Slice 1.
|
||||||
|
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||||
|
FakeGitea,
|
||||||
|
app_with_fake_gitea,
|
||||||
|
provision_user_row,
|
||||||
|
sign_in_as,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_can_read_every_public_surface(app_with_fake_gitea):
|
||||||
|
"""Per §14 / the v0.3.0 anonymous-read contract: the catalog, the
|
||||||
|
RFC view, the PR-less discussion surface, the philosophy page, and
|
||||||
|
the health probe must remain reachable for unauthenticated viewers.
|
||||||
|
This is the read side of the item #4 contract — the read surfaces
|
||||||
|
must NOT regress to require auth as the write gates tighten.
|
||||||
|
"""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
|
||||||
|
# No session cookie — viewer is anonymous.
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
# The five read surfaces an anonymous viewer must reach.
|
||||||
|
assert client.get("/api/health").status_code == 200
|
||||||
|
assert client.get("/api/philosophy").status_code == 200
|
||||||
|
assert client.get("/api/auth/me").status_code == 200
|
||||||
|
assert client.get("/api/rfcs").status_code == 200
|
||||||
|
assert client.get("/api/rfcs/ohm").status_code == 200
|
||||||
|
assert client.get("/api/rfcs/ohm/main").status_code == 200
|
||||||
|
assert client.get("/api/rfcs/ohm/discussion/threads").status_code == 200
|
||||||
|
assert client.get("/api/proposals").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_propose_refused(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
client.cookies.clear()
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/propose",
|
||||||
|
json={"title": "X", "slug": "x", "pitch": "p", "tags": []},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_proposal_admin_paths_refused(app_with_fake_gitea):
|
||||||
|
"""The admin-gated proposal actions — merge, decline — must refuse
|
||||||
|
anonymous callers with 401 (the auth check runs before the role
|
||||||
|
check; both refusals are correct, but 401 is the structural signal
|
||||||
|
"no session at all")."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
client.cookies.clear()
|
||||||
|
# PR number doesn't need to exist — the gate runs first.
|
||||||
|
assert client.post("/api/proposals/1/merge").status_code == 401
|
||||||
|
assert (
|
||||||
|
client.post("/api/proposals/1/decline", json={"comment": "no"}).status_code
|
||||||
|
== 401
|
||||||
|
)
|
||||||
|
assert client.post("/api/proposals/1/withdraw").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_branch_writes_refused_on_active_rfc(app_with_fake_gitea):
|
||||||
|
"""Branch-scoped writes on an active RFC: promote-to-branch,
|
||||||
|
manual-flush, visibility, grants, threads create, message post,
|
||||||
|
resolve, chat-seen, change accept/decline/reask. All must 401 for
|
||||||
|
anonymous callers."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
# Branch-scoped writes — slug + branch values are placeholders;
|
||||||
|
# the auth gate runs before any state lookup.
|
||||||
|
slug = "ohm"
|
||||||
|
branch = "feature-x"
|
||||||
|
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/main/promote-to-branch",
|
||||||
|
json={},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/manual-flush",
|
||||||
|
json={"new_content": "hi", "paragraph_count": 1},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/visibility",
|
||||||
|
json={"read_public": False},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/grants",
|
||||||
|
json={"grantee_gitea_login": "alice"},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.delete(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/grants/alice",
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/threads",
|
||||||
|
json={"thread_kind": "chat", "anchor_kind": "whole-doc"},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/threads/1/messages",
|
||||||
|
json={"text": "hi"},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/threads/1/resolve",
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/chat-seen",
|
||||||
|
json={"last_seen_message_id": 1},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/changes/1/accept",
|
||||||
|
json={"proposed": "x"},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/changes/1/decline",
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/changes/1/reask",
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
# Chat stream — POST shaped, same auth gate.
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/threads/1/chat",
|
||||||
|
json={"text": "hi"},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_super_draft_writes_refused(app_with_fake_gitea):
|
||||||
|
"""Super-draft-scoped writes: start-edit-branch and metadata. The
|
||||||
|
PR open / merge paths share the gate via api_prs.py — see the
|
||||||
|
PR-flow test below for those."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
client.cookies.clear()
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/rfcs/anything/start-edit-branch", json={}
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/rfcs/anything/metadata", json={"title": "x"}
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_pr_flow_writes_refused(app_with_fake_gitea):
|
||||||
|
"""All §10 PR-flow writes — open, merge, withdraw, description,
|
||||||
|
review, seen, pr-draft, resolution-branch — must 401 for anonymous."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
client.cookies.clear()
|
||||||
|
slug, branch, pr = "ohm", "feature-x", 1
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/pr-draft"
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/branches/{branch}/open-pr",
|
||||||
|
json={"title": "t", "description": "d"},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/prs/{pr}/seen",
|
||||||
|
json={"last_seen_message_id": 1},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/prs/{pr}/review",
|
||||||
|
json={"text": "x", "anchor_payload": {}},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/prs/{pr}/merge"
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/prs/{pr}/withdraw"
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/prs/{pr}/description",
|
||||||
|
json={"title": "t", "description": "d"},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/rfcs/{slug}/prs/{pr}/resolution-branch"
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_discussion_writes_refused(app_with_fake_gitea):
|
||||||
|
"""The v0.5.0 PR-less discussion surface — write gates must hold.
|
||||||
|
This duplicates the assertion in `test_discussion_vertical.py` and
|
||||||
|
keeps it here too as the canonical home for the item #4 audit."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
client.cookies.clear()
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/rfcs/ohm/discussion/threads",
|
||||||
|
json={"message": "drive-by"},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/rfcs/ohm/discussion/threads/1/messages",
|
||||||
|
json={"text": "drive-by"},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/rfcs/ohm/discussion/threads/1/resolve"
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_admin_writes_refused(app_with_fake_gitea):
|
||||||
|
"""Admin surfaces — role, mute, allowlist — refuse anonymous.
|
||||||
|
The auth check runs before the require_admin role check, so the
|
||||||
|
response is 401."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
client.cookies.clear()
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/admin/users/1/role", json={"role": "admin"}
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/admin/users/1/mute", json={"muted": True}
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/admin/allowlist", json={"email": "x@y.z"}
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.delete("/api/admin/allowlist/x@y.z").status_code == 401
|
||||||
|
)
|
||||||
|
# Admin reads also gated.
|
||||||
|
assert client.get("/api/admin/users").status_code == 401
|
||||||
|
assert client.get("/api/admin/audit").status_code == 401
|
||||||
|
assert client.get("/api/admin/permission-events").status_code == 401
|
||||||
|
assert client.get("/api/admin/graduation-queue").status_code == 401
|
||||||
|
assert client.get("/api/admin/allowlist").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_notification_writes_refused(app_with_fake_gitea):
|
||||||
|
"""Notification preference / watch / mark-read / user-mute writes —
|
||||||
|
all per-user surfaces, all require an authenticated viewer."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
client.cookies.clear()
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/users/me/notification-preferences",
|
||||||
|
json={"email_personal_direct": False},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/users/me/quiet-hours",
|
||||||
|
json={"start": None, "end": None, "timezone": None},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post("/api/rfcs/ohm/watch", json={"state": "watching"}).status_code
|
||||||
|
== 401
|
||||||
|
)
|
||||||
|
assert client.post("/api/notifications/1/read").status_code == 401
|
||||||
|
assert (
|
||||||
|
client.post("/api/notifications/read", json={}).status_code == 401
|
||||||
|
)
|
||||||
|
assert client.post("/api/users/1/notification-mute").status_code == 401
|
||||||
|
assert client.delete("/api/users/1/notification-mute").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_funder_writes_refused(app_with_fake_gitea):
|
||||||
|
"""§6.7 funder credential + consent writes — registering a key,
|
||||||
|
consenting to fund — all refuse anonymous callers."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
client.cookies.clear()
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/users/me/funder/credentials",
|
||||||
|
json={"provider": "anthropic", "api_key": "sk-test"},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.delete(
|
||||||
|
"/api/users/me/funder/credentials/anthropic"
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post("/api/rfcs/ohm/funder/consent").status_code == 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.delete("/api/rfcs/ohm/funder/consent").status_code == 401
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_graduation_writes_refused(app_with_fake_gitea):
|
||||||
|
"""§13 graduation: the POST kickoff and POST claim both refuse
|
||||||
|
anonymous. The progress SSE was gated to require_user in v0.6.0
|
||||||
|
(item #4) since it surfaces admin-internal step detail."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
client.cookies.clear()
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/rfcs/anything/graduate",
|
||||||
|
json={
|
||||||
|
"rfc_id": "RFC-0001",
|
||||||
|
"repo_name": "rfc-0001-x",
|
||||||
|
"owners": ["alice"],
|
||||||
|
},
|
||||||
|
).status_code == 401
|
||||||
|
)
|
||||||
|
assert client.post("/api/rfcs/anything/claim").status_code == 401
|
||||||
|
# v0.6.0 tightening: progress SSE now requires require_user.
|
||||||
|
# No graduation is in flight, but the auth check runs first.
|
||||||
|
assert (
|
||||||
|
client.get("/api/rfcs/anything/graduate/progress").status_code == 401
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_can_read_published_pr_view(app_with_fake_gitea):
|
||||||
|
"""The PR review page is §11.3 universal-public — once a PR is
|
||||||
|
open, anonymous viewers can read it. This guards against a
|
||||||
|
regression where the read endpoint accidentally grows an auth
|
||||||
|
gate."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
# Seed an open PR row directly — the cache shape is enough for
|
||||||
|
# the read endpoint; the live Gitea fetch falls back gracefully.
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cached_prs
|
||||||
|
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
|
||||||
|
opened_by, opened_at, head_branch, base_branch, head_sha)
|
||||||
|
VALUES ('ohm', 'rfc_branch', 'wiggleverse/rfc-0001-ohm', 7, 't', 'd',
|
||||||
|
'open', 'alice', datetime('now'), 'feature-x', 'main', 'sha7')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
client.cookies.clear()
|
||||||
|
# Anonymous read on an open PR: should be 200. The endpoint may
|
||||||
|
# surface a partial response (the FakeGitea won't have the head
|
||||||
|
# branch's RFC.md, so branch_body falls back to empty) but the
|
||||||
|
# auth gate must let the read through.
|
||||||
|
r = client.get("/api/rfcs/ohm/prs/7")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["capabilities"]["is_anonymous"] is True
|
||||||
|
assert body["capabilities"]["can_merge"] is False
|
||||||
|
assert body["capabilities"]["can_post_review"] is False
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
"""End-to-end integration tests for the v0.7.0 email/OTC sign-in
|
||||||
|
vertical (§6.2).
|
||||||
|
|
||||||
|
The release replaces the Gitea OAuth gesture as the primary human
|
||||||
|
sign-in path. The tests prove:
|
||||||
|
|
||||||
|
* `/auth/otc/request` is rate-limited per-email — back-to-back
|
||||||
|
requests inside `OTC_REQUEST_COOLDOWN_SECONDS` are refused with
|
||||||
|
429 (the loud-failure shape the spec calls out).
|
||||||
|
* The happy path: request → code lands in the outbound buffer →
|
||||||
|
verify with the code → session cookie surfaces an authenticated
|
||||||
|
user via `/api/auth/me`.
|
||||||
|
* Expired codes refuse with 400.
|
||||||
|
* Already-consumed codes refuse with 400 on re-use.
|
||||||
|
* Wrong codes refuse with 400.
|
||||||
|
* Allowlist gate: when `allowed_emails` is populated and the email
|
||||||
|
isn't on it, the response is still 202 (no leak), but no email
|
||||||
|
lands in the outbound buffer and verify finds no matching code.
|
||||||
|
* Migration link: an existing OAuth-era user (with a `users.email`
|
||||||
|
row) is linked by email on first OTC sign-in — `gitea_id` is
|
||||||
|
preserved.
|
||||||
|
* Provisioning path: an unrecognized email creates a fresh
|
||||||
|
contributor row with `gitea_id = NULL`.
|
||||||
|
|
||||||
|
The Gitea bot user + token are still required at process construction
|
||||||
|
(every test harness sets the same `GITEA_*` env vars); the OTC flow
|
||||||
|
itself never reaches Gitea. The fakes from `test_propose_vertical`
|
||||||
|
remain in scope so the rest of the app boots cleanly.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401
|
||||||
|
FakeGitea,
|
||||||
|
app_with_fake_gitea,
|
||||||
|
provision_user_row,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_outbound():
|
||||||
|
from app import email as email_mod
|
||||||
|
email_mod.reset_sent_envelopes()
|
||||||
|
|
||||||
|
|
||||||
|
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
|
||||||
|
"""Pluck the `code` line out of every OTC email in the test buffer.
|
||||||
|
|
||||||
|
The OTC mailer stamps `kind='otc'` on the envelope so the §15.4
|
||||||
|
notification mailer's envelopes (the unsubscribe-footer shape)
|
||||||
|
don't accidentally satisfy the assertion. Each envelope's body
|
||||||
|
carries the code on its own indented line; this helper extracts
|
||||||
|
just that token so the test reads the same way the user would
|
||||||
|
read the email.
|
||||||
|
"""
|
||||||
|
from app import email as email_mod
|
||||||
|
out = []
|
||||||
|
for env in email_mod.sent_envelopes():
|
||||||
|
if env.get("kind") != "otc":
|
||||||
|
continue
|
||||||
|
if to_address is not None and env["to"] != to_address:
|
||||||
|
continue
|
||||||
|
for line in env["body"].splitlines():
|
||||||
|
tok = line.strip()
|
||||||
|
if tok.isdigit() and len(tok) == 6:
|
||||||
|
out.append(tok)
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Happy path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_request_then_verify_signs_in_a_fresh_user(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
|
||||||
|
# Request: 202 + a single OTC envelope to the requested address.
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "newcomer@example.com"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
codes = _outbound_otc_codes("newcomer@example.com")
|
||||||
|
assert len(codes) == 1
|
||||||
|
code = codes[0]
|
||||||
|
|
||||||
|
# Verify: 200 + session cookie + me-shape now reads authenticated.
|
||||||
|
r = client.post("/auth/otc/verify", json={"email": "newcomer@example.com", "code": code})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
me = client.get("/api/auth/me").json()
|
||||||
|
assert me["authenticated"] is True
|
||||||
|
assert me["user"]["email"] == "newcomer@example.com"
|
||||||
|
# Fresh provisioning: no gitea linker. The display name is the
|
||||||
|
# local part of the email per §6.2.
|
||||||
|
assert me["user"]["role"] == "contributor"
|
||||||
|
assert me["user"]["display_name"] == "newcomer"
|
||||||
|
|
||||||
|
# The `users` row reflects the same: gitea_id NULL, email set.
|
||||||
|
from app import db
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT gitea_id, email FROM users WHERE email = ? COLLATE NOCASE",
|
||||||
|
("newcomer@example.com",),
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row["gitea_id"] is None
|
||||||
|
assert row["email"] == "newcomer@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Failure modes on verify
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_verify_refuses_wrong_code(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": "000000"})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_verify_refuses_consumed_code(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||||
|
# First verify succeeds.
|
||||||
|
r1 = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
|
||||||
|
assert r1.status_code == 200
|
||||||
|
# Drop the session cookie so the re-verify reads as fresh.
|
||||||
|
client.cookies.clear()
|
||||||
|
# Second verify with the same code is refused — `consumed_at`
|
||||||
|
# stamped on the row blocks the replay.
|
||||||
|
r2 = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
|
||||||
|
assert r2.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_verify_refuses_expired_code(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||||
|
# Backdate the row's expires_at to the past. The TTL setting is
|
||||||
|
# an env var (default 10 min); rather than waiting, the test
|
||||||
|
# rewrites the row.
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE otc_codes SET expires_at = datetime('now', '-1 minute') WHERE email = ?",
|
||||||
|
("alice@example.com",),
|
||||||
|
)
|
||||||
|
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Rate limiting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_request_rate_limited_per_email(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
r1 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
assert r1.status_code == 200
|
||||||
|
# Cooldown defaults to 60s; the second back-to-back call is
|
||||||
|
# refused with a loud 429.
|
||||||
|
r2 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
assert r2.status_code == 429
|
||||||
|
# The buffer still has exactly one envelope — the rate-limited
|
||||||
|
# call didn't double-send.
|
||||||
|
assert len(_outbound_otc_codes("alice@example.com")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_request_cooldown_is_per_email_not_global(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
r1 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
assert r1.status_code == 200
|
||||||
|
# Different email, fresh cooldown.
|
||||||
|
r2 = client.post("/auth/otc/request", json={"email": "bob@example.com"})
|
||||||
|
assert r2.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Allowlist gate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_request_silently_drops_when_email_not_on_allowlist(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
# Populate the allowlist so the gate turns on.
|
||||||
|
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("invited@example.com",))
|
||||||
|
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "stranger@example.com"})
|
||||||
|
# Still 202 — the allowlist's state is not leaked to callers.
|
||||||
|
assert r.status_code == 200
|
||||||
|
# But no email was sent, and no row landed in otc_codes.
|
||||||
|
assert _outbound_otc_codes("stranger@example.com") == []
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT 1 FROM otc_codes WHERE email = ?",
|
||||||
|
("stranger@example.com",),
|
||||||
|
).fetchone()
|
||||||
|
assert row is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_request_admits_allowlisted_email(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("invited@example.com",))
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "invited@example.com"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert len(_outbound_otc_codes("invited@example.com")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Migration path — link by email to an OAuth-era user
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_links_to_existing_oauth_user_by_email(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
# Seed an OAuth-era row. `provision_user_row` writes
|
||||||
|
# email=<login>@test, so we sign in via OTC with the matching
|
||||||
|
# email and expect the same `users.id` to come back.
|
||||||
|
provision_user_row(user_id=42, login="legacyuser", role="contributor")
|
||||||
|
existing = db.conn().execute(
|
||||||
|
"SELECT id, gitea_id FROM users WHERE id = ?", (42,)
|
||||||
|
).fetchone()
|
||||||
|
assert existing["gitea_id"] == 42 # OAuth linker is set.
|
||||||
|
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "legacyuser@test"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
code = _outbound_otc_codes("legacyuser@test")[-1]
|
||||||
|
r = client.post("/auth/otc/verify", json={"email": "legacyuser@test", "code": code})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
# /api/auth/me reports the linked user — same id, original role.
|
||||||
|
me = client.get("/api/auth/me").json()
|
||||||
|
assert me["authenticated"] is True
|
||||||
|
assert me["user"]["id"] == 42
|
||||||
|
assert me["user"]["role"] == "contributor"
|
||||||
|
|
||||||
|
# gitea_id is preserved on the linked row — the migration path
|
||||||
|
# doesn't disturb the OAuth linker.
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT gitea_id FROM users WHERE id = ?", (42,)
|
||||||
|
).fetchone()
|
||||||
|
assert row["gitea_id"] == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_provisions_fresh_user_when_email_matches_no_one(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "newperson@example.com"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
code = _outbound_otc_codes("newperson@example.com")[-1]
|
||||||
|
r = client.post("/auth/otc/verify", json={"email": "newperson@example.com", "code": code})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
# A fresh row landed with NULL gitea_id (no OAuth linker).
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT id, gitea_id, gitea_login, role FROM users WHERE email = ? COLLATE NOCASE",
|
||||||
|
("newperson@example.com",),
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row["gitea_id"] is None
|
||||||
|
assert row["gitea_login"] is None
|
||||||
|
assert row["role"] == "contributor"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Re-request invalidates prior code
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_re_request_invalidates_prior_unused_code(app_with_fake_gitea, monkeypatch):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
# Drop the cooldown so the second request lands instead of 429ing.
|
||||||
|
monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0")
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
first = _outbound_otc_codes("alice@example.com")[-1]
|
||||||
|
client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
second = _outbound_otc_codes("alice@example.com")[-1]
|
||||||
|
assert first != second
|
||||||
|
|
||||||
|
# The old code is invalidated — verify with `first` now refuses.
|
||||||
|
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": first})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
# The new code still works.
|
||||||
|
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": second})
|
||||||
|
assert r.status_code == 200
|
||||||
@@ -24,3 +24,28 @@ VITE_APP_NAME=
|
|||||||
# VITE_BETA_CONTACT=ben@wiggleverse.org
|
# VITE_BETA_CONTACT=ben@wiggleverse.org
|
||||||
# VITE_BETA_CONTACT=DM @ben on Matrix
|
# VITE_BETA_CONTACT=DM @ben on Matrix
|
||||||
VITE_BETA_CONTACT=
|
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",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.5.0",
|
"version": "0.13.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.5.0",
|
"version": "0.13.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.0",
|
"version": "0.13.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -352,6 +352,89 @@
|
|||||||
}
|
}
|
||||||
.landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; }
|
.landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; }
|
||||||
|
|
||||||
|
/* --- v0.7.0: email + one-time-code sign-in (§6.2) --- */
|
||||||
|
|
||||||
|
.otc-login {
|
||||||
|
flex: 1;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 40px 24px;
|
||||||
|
}
|
||||||
|
.otc-login-inner {
|
||||||
|
max-width: 360px;
|
||||||
|
width: 100%;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.otc-login h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
.otc-login .otc-hint {
|
||||||
|
color: #555;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.otc-login input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.otc-login input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
.otc-login button[type="submit"] {
|
||||||
|
background: #1a1a1a; color: #fff;
|
||||||
|
border: none; border-radius: 6px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 14px; font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.otc-login button[type="submit"]:hover:not(:disabled) { background: #333; }
|
||||||
|
.otc-login button[type="submit"]:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.otc-login form {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.otc-actions {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
}
|
||||||
|
.otc-login .btn-link-quiet {
|
||||||
|
background: none; border: none;
|
||||||
|
color: #666; font-size: 13px;
|
||||||
|
cursor: pointer; padding: 0;
|
||||||
|
}
|
||||||
|
.otc-login .btn-link-quiet:hover { color: #1a1a1a; text-decoration: underline; }
|
||||||
|
.otc-shortcut-hint {
|
||||||
|
color: #888; font-size: 12px; margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
.otc-shortcut-hint kbd {
|
||||||
|
background: #f0f0ee; border: 1px solid #ddd; border-radius: 3px;
|
||||||
|
padding: 1px 5px; font-size: 11px; font-family: inherit;
|
||||||
|
}
|
||||||
|
.otc-status {
|
||||||
|
color: #555; font-size: 13px;
|
||||||
|
background: #f7f6f0;
|
||||||
|
border-left: 3px solid #cfc8a8;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
.otc-fallback {
|
||||||
|
font-size: 12px; color: #777;
|
||||||
|
margin: 16px 0 0;
|
||||||
|
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.otc-fallback a, .otc-fallback .otc-fallback-link {
|
||||||
|
color: #666; text-decoration: none;
|
||||||
|
}
|
||||||
|
.otc-fallback a:hover { color: #1a1a1a; text-decoration: underline; }
|
||||||
|
.otc-fallback-sep { color: #ccc; }
|
||||||
|
|
||||||
/* --- Beta-pending page (post-OAuth-rejection) --- */
|
/* --- Beta-pending page (post-OAuth-rejection) --- */
|
||||||
|
|
||||||
.beta-pending {
|
.beta-pending {
|
||||||
@@ -1864,3 +1947,106 @@
|
|||||||
.discussion-readonly {
|
.discussion-readonly {
|
||||||
font-size: 12px; color: #666; padding: 4px 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+31
-3
@@ -8,11 +8,15 @@ import PRView from './components/PRView.jsx'
|
|||||||
import ProposalView from './components/ProposalView.jsx'
|
import ProposalView from './components/ProposalView.jsx'
|
||||||
import ProposeModal from './components/ProposeModal.jsx'
|
import ProposeModal from './components/ProposeModal.jsx'
|
||||||
import Landing from './components/Landing.jsx'
|
import Landing from './components/Landing.jsx'
|
||||||
|
import Login from './components/Login.jsx'
|
||||||
import BetaPending from './components/BetaPending.jsx'
|
import BetaPending from './components/BetaPending.jsx'
|
||||||
import Philosophy from './components/Philosophy.jsx'
|
import Philosophy from './components/Philosophy.jsx'
|
||||||
import NotificationSettings from './components/NotificationSettings.jsx'
|
import NotificationSettings from './components/NotificationSettings.jsx'
|
||||||
import Admin from './components/Admin.jsx'
|
import Admin from './components/Admin.jsx'
|
||||||
import ToastHost, { showToast } from './components/ToastHost.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'
|
import './App.css'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -23,8 +27,19 @@ export default function App() {
|
|||||||
const [inboxOpen, setInboxOpen] = useState(false)
|
const [inboxOpen, setInboxOpen] = useState(false)
|
||||||
const [unreadCount, setUnreadCount] = useState(0)
|
const [unreadCount, setUnreadCount] = useState(0)
|
||||||
const [inboxTick, setInboxTick] = 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()
|
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(() => {
|
useEffect(() => {
|
||||||
getMe()
|
getMe()
|
||||||
.then(setMe)
|
.then(setMe)
|
||||||
@@ -121,17 +136,22 @@ export default function App() {
|
|||||||
<a className="btn-link" href="/auth/logout">Sign out</a>
|
<a className="btn-link" href="/auth/logout">Sign out</a>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<a className="btn-signin-header" href="/auth/login" title="Private beta — only invited emails can sign in">
|
<Link className="btn-signin-header" to="/login" title="Private beta — only invited emails can sign in">
|
||||||
Sign in <span className="beta-chip">Beta</span>
|
Sign in <span className="beta-chip">Beta</span>
|
||||||
</a>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="app-body">
|
<div className="app-body">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/welcome" element={<Landing />} />
|
<Route path="/welcome" element={<Landing />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/beta-pending" element={<BetaPending />} />
|
<Route path="/beta-pending" element={<BetaPending />} />
|
||||||
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
<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 && (
|
{viewer && (
|
||||||
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={viewer} />} />
|
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={viewer} />} />
|
||||||
)}
|
)}
|
||||||
@@ -172,10 +192,18 @@ export default function App() {
|
|||||||
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
|
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
|
||||||
)}
|
)}
|
||||||
<ToastHost />
|
<ToastHost />
|
||||||
|
<CookieConsentBanner viewer={viewer} forceOpen={consentReopenTick} />
|
||||||
</div>
|
</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 }) {
|
function PhilosophyWithSidebar({ viewer }) {
|
||||||
// The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
|
// The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
|
||||||
// all use the full app body — no catalog left pane, no propose modal.
|
// all use the full app body — no catalog left pane, no propose modal.
|
||||||
@@ -216,7 +244,7 @@ function Welcome({ viewer }) {
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Discussion and contribution are in private <strong>Beta</strong> —
|
Discussion and contribution are in private <strong>Beta</strong> —
|
||||||
read freely, and <a href="/auth/login">sign in</a> if your email has
|
read freely, and <Link to="/login">sign in</Link> if your email has
|
||||||
been invited.
|
been invited.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -25,6 +25,30 @@ export async function getMe() {
|
|||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.7.0: email + one-time-code sign-in (§6.2) ─────────────────────────
|
||||||
|
//
|
||||||
|
// The legacy /auth/login → /auth/callback OAuth flow remains during the
|
||||||
|
// migration — the new UI just no longer points at it primarily. These
|
||||||
|
// two helpers drive the Login.jsx surface.
|
||||||
|
|
||||||
|
export async function requestOtc(email) {
|
||||||
|
const res = await fetch('/auth/otc/request', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
})
|
||||||
|
return jsonOrThrow(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyOtc(email, code) {
|
||||||
|
const res = await fetch('/auth/otc/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, code }),
|
||||||
|
})
|
||||||
|
return jsonOrThrow(res)
|
||||||
|
}
|
||||||
|
|
||||||
export async function listRFCs() {
|
export async function listRFCs() {
|
||||||
return jsonOrThrow(await fetch('/api/rfcs'))
|
return jsonOrThrow(await fetch('/api/rfcs'))
|
||||||
}
|
}
|
||||||
@@ -508,6 +532,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) {
|
export async function muteUser(userId) {
|
||||||
return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'POST' }))
|
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,7 @@ export default function Landing() {
|
|||||||
first RFC defining <em>human</em>. Build the dictionary first.
|
first RFC defining <em>human</em>. Build the dictionary first.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a className="btn-signin" href="/auth/login">Sign in with Gitea</a>
|
<Link className="btn-signin" to="/login">Sign in</Link>
|
||||||
<Link className="secondary-link" to="/philosophy">Read the full philosophy →</Link>
|
<Link className="secondary-link" to="/philosophy">Read the full philosophy →</Link>
|
||||||
|
|
||||||
<ul className="landing-deck">
|
<ul className="landing-deck">
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// Login.jsx — v0.7.0's primary sign-in surface (§6.2).
|
||||||
|
//
|
||||||
|
// Two-step:
|
||||||
|
// 1. Enter email → POST /auth/otc/request → on 200, advance.
|
||||||
|
// On 429 (rate-limit), surface a "wait a moment" hint and keep
|
||||||
|
// the user on step 1.
|
||||||
|
// 2. Enter the six-digit code from the email → POST /auth/otc/verify
|
||||||
|
// → on 200, redirect to the post-login landing. Cmd/Ctrl+Enter
|
||||||
|
// on the code field is the keyboard shortcut.
|
||||||
|
//
|
||||||
|
// Server-side, /auth/otc/request always returns 202 for an unrecognized
|
||||||
|
// email (so the allowlist gate doesn't leak), so this surface never
|
||||||
|
// distinguishes "we couldn't reach you" from "we don't know you" —
|
||||||
|
// it just advances to step 2. If a user is genuinely blocked, the
|
||||||
|
// code never arrives.
|
||||||
|
//
|
||||||
|
// The legacy Gitea OAuth callback remains at /auth/login → /auth/callback
|
||||||
|
// during the v0.7.0 migration; we surface a "Sign in with Gitea" link
|
||||||
|
// as a fallback in the footer so users with active OAuth sessions or
|
||||||
|
// older invite emails still have a path.
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
|
import { requestOtc, verifyOtc } from '../api'
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [step, setStep] = useState('email')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [status, setStatus] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const emailRef = useRef(null)
|
||||||
|
const codeRef = useRef(null)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step === 'email') emailRef.current?.focus()
|
||||||
|
else codeRef.current?.focus()
|
||||||
|
}, [step])
|
||||||
|
|
||||||
|
async function submitEmail(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!email.trim() || !email.includes('@')) {
|
||||||
|
setStatus('Enter a valid email address.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setStatus('')
|
||||||
|
try {
|
||||||
|
await requestOtc(email.trim())
|
||||||
|
setStep('code')
|
||||||
|
setStatus('Check your inbox — a six-digit code is on the way.')
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 429) {
|
||||||
|
setStatus('Slow down — wait a minute before requesting another code.')
|
||||||
|
} else {
|
||||||
|
setStatus(err.message || 'Could not request a code. Try again.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCode(e) {
|
||||||
|
if (e) e.preventDefault()
|
||||||
|
if (!code.trim() || code.trim().length !== 6) {
|
||||||
|
setStatus('Enter the six-digit code from your email.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setStatus('')
|
||||||
|
try {
|
||||||
|
await verifyOtc(email.trim(), code.trim())
|
||||||
|
// Reload so App.jsx's getMe() picks up the fresh session. We
|
||||||
|
// navigate to "/" via a hard load so any cached "anonymous"
|
||||||
|
// view state in memory is dropped cleanly.
|
||||||
|
window.location.assign('/')
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('That code is invalid or expired. Try again, or request a new code.')
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCodeKey(e) {
|
||||||
|
// §6.2 ergonomic: Cmd/Ctrl+Enter submits from the code field.
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||||
|
submitCode(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToEmail() {
|
||||||
|
setStep('email')
|
||||||
|
setCode('')
|
||||||
|
setStatus('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="otc-login">
|
||||||
|
<div className="otc-login-inner">
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
{step === 'email' && (
|
||||||
|
<form onSubmit={submitEmail}>
|
||||||
|
<p className="otc-hint">
|
||||||
|
Enter your email. We'll send you a one-time code.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={emailRef}
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={busy || !email.trim()}>
|
||||||
|
{busy ? 'Sending…' : 'Send code'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{step === 'code' && (
|
||||||
|
<form onSubmit={submitCode}>
|
||||||
|
<p className="otc-hint">
|
||||||
|
Enter the six-digit code we sent to <strong>{email}</strong>.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={codeRef}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
maxLength={6}
|
||||||
|
value={code}
|
||||||
|
onChange={e => setCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
onKeyDown={onCodeKey}
|
||||||
|
placeholder="123456"
|
||||||
|
required
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<div className="otc-actions">
|
||||||
|
<button type="submit" disabled={busy || code.length !== 6}>
|
||||||
|
{busy ? 'Signing in…' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-link-quiet"
|
||||||
|
onClick={backToEmail}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Use a different email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="otc-shortcut-hint">
|
||||||
|
Tip: <kbd>⌘</kbd>+<kbd>Enter</kbd> (or <kbd>Ctrl</kbd>+<kbd>Enter</kbd>) to sign in.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{status && <p className="otc-status">{status}</p>}
|
||||||
|
<p className="otc-fallback">
|
||||||
|
<Link to="/philosophy">Read the philosophy →</Link>
|
||||||
|
<span className="otc-fallback-sep">·</span>
|
||||||
|
<a href="/auth/login">Sign in with Gitea (fallback)</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -28,7 +28,9 @@ import {
|
|||||||
unmuteUser,
|
unmuteUser,
|
||||||
muteUser,
|
muteUser,
|
||||||
searchUsers,
|
searchUsers,
|
||||||
|
getCookieConsent,
|
||||||
} from '../api.js'
|
} 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.'
|
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 />
|
<QuietHoursSection />
|
||||||
<WatchesSection />
|
<WatchesSection />
|
||||||
<MutesSection viewer={viewer} />
|
<MutesSection viewer={viewer} />
|
||||||
|
<PrivacyCookiesSection />
|
||||||
</div>
|
</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 ───────────────────────────────────────────
|
// ── §15.4 email category toggles ───────────────────────────────────────────
|
||||||
|
|
||||||
function EmailPreferencesSection() {
|
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