Compare commits

..

1 Commits

Author SHA1 Message Date
Ben Stull 2590c244d6 Release 0.7.0: email/OTC sign-in (Gitea OAuth retained as fallback)
Replaces the Gitea OAuth gesture as the primary human-auth path
(roadmap item #5, SPEC §6.2). Users sign in by entering their email,
receiving a six-digit code via the existing SMTP layer, and entering
the code on a two-step /login surface. The Gitea OAuth callback
remains functional during migration — the new UI links to it as a
fallback for users with active OAuth sessions or older invite paths
— and is scheduled for removal in a future release once OTC adoption
is universal. Existing users are linked by email on first OTC sign-
in (gitea_id preserved); new users are provisioned with NULL
gitea_id and rely on email as the identity key. The migration
introduces backend/migrations/012_otc.sql (otc_codes table + users
schema rebuild for nullable gitea_id and a partial unique index on
email), two new endpoints (POST /auth/otc/request, POST /auth/otc/verify),
bcrypt as a new backend dependency for code hashing, and 11 new
tests in test_otc_vertical.py covering the happy path, expired and
consumed and wrong codes, the per-email rate limit, the allowlist
gate, the OAuth-era link path, fresh provisioning, and prior-code
invalidation on re-request. No new secrets are required — the
existing SECRET_KEY signs sessions and bcrypt's per-row salt covers
the code hashes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:55:29 -07:00
38 changed files with 211 additions and 6732 deletions
+4 -744
View File
@@ -23,650 +23,6 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond
that.
## 0.14.0 — 2026-05-28
**Minor — no operator action required; new optional env var.** This
release ships `DOCS.md` and the `/docs` route — a public-facing user
guide that translates `SPEC.md` into plain prose for readers,
proposers, and contributors. The originating need was the
admin-vs-owner distinction on the `/admin/users` surface (the §6.1
role separation was load-bearing but only documented in spec voice);
the response was a single guide that covers the framework's user-
facing surfaces end-to-end. Mirrors `/philosophy` end-to-end: a
markdown file checked into the repo root, served by a sibling backend
loader, rendered with `MarkdownPreview`. No schema migration. No
required env-var changes. The new "Docs" header link sits alongside
the persistent "About" link from §14.3 and is reachable by anonymous
viewers per the same v0.3.0 anonymous-read contract.
### Added
- **`DOCS.md`** at the repo root — the user-facing guide. Covers
reading anonymously, signing in, proposing an RFC, super-drafts vs
active RFCs, the discussion-vs-contribution distinction (§10.10),
working on a branch (contribute mode, AI proposals, manual edits,
flags, branch visibility, contribute grants, hygiene), opening and
reviewing PRs, graduation (§13), withdrawal and reopening, the AI
participant (§6.6 / §6.7 / §18), notifications and watch states
(§15), and the full roles-and-permissions story (§6 in plain
prose: anonymous / contributor / admin / owner, per-RFC
owners + arbiters, per-branch contribute grants, the write-mute,
and the three structurally distinct "mutes"). Framework-neutral —
no deployment-specific names or corpus references; consistent with
`CLAUDE.md`'s separation-of-concerns rule.
- **`backend/app/docs.py`** — sibling loader for `philosophy.py`.
Reads `DOCS.md` from the repo root with the same disk-first,
in-process-cached, `refresh()`-on-demand shape. Optional
`DOCS_PATH` env var points at an alternative source (e.g. a
meta-repo working-tree clone) for deployments that prefer that.
- **`§17` endpoint** — `GET /api/docs` returns
`{ "body": "<DOCS.md verbatim>" }`. Anonymous-reachable, same
contract as `GET /api/philosophy`.
- **`frontend/src/components/Docs.jsx`** — the `/docs` reading
surface. Mirrors `Philosophy.jsx`: chrome with Back / "USER GUIDE" /
Home affordances, body rendered through `MarkdownPreview`.
### Changed
- **`backend/app/api.py`** — imports `docs as docs_mod` alongside
`philosophy` in the relative-import block; registers the new
`GET /api/docs` handler immediately after `GET /api/philosophy`.
- **`frontend/src/api.js`** — exports `getDocs()` alongside
`getPhilosophy()`. Same fetch shape, different endpoint path.
- **`frontend/src/App.jsx`** — imports `Docs` alongside `Philosophy`,
registers the `/docs` route alongside `/philosophy`, adds the
persistent "Docs" header link alongside "About", and adds the
`DocsWithSidebar` chrome wrapper alongside `PhilosophyWithSidebar`.
### Upgrade steps (from 0.13.0)
- You **MUST** rebuild the frontend and restart the backend after
upgrading so the new `/docs` route, the new endpoint, and the new
loader are picked up. `frontend/package.json#version` and `VERSION`
both move to `0.14.0`. No schema migration; the new endpoint
serves a checked-in file.
- You **MAY** set `DOCS_PATH` to an absolute path if your deployment
hosts `DOCS.md` outside the framework's repo (e.g. as a sync target
from a content repo). Unset is supported — the framework's
`DOCS.md` at the repo root is the default, mirroring how
`PHILOSOPHY_PATH` works for `/api/philosophy`.
- You **MAY** customize `DOCS.md` for your deployment if you want
deployment-specific phrasing layered on top of the framework's
guide. The file is a regular markdown source; standard `vim`/`git`
edits suffice. Framework upgrades that ship a new `DOCS.md` will
show as a normal merge in your deployment-overlay layer.
## 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.12.0 — 2026-05-28
**Minor — operator action required (new secret + new overlay).**
CloudFlare Turnstile gates the email-entry step of the OTC sign-in
flow against automated abuse (roadmap item #10, SPEC §6.2 / §19.2-
settled). Since v0.7.0 made `/auth/otc/request` the primary human-
auth path and v0.8.0 opened the request endpoint to any valid email,
the OTC dispatch became the natural target for distributed scrapers
fanning out to harvest "this email is admitted vs. this email is
not" timing/bounce signals. The per-email cooldown stops the trivial
back-to-back loop; the Turnstile challenge stops the distributed one
by costing the attacker a browser-side proof-of-humanness on every
request. The challenge runs before the bcrypt hash + SMTP send so a
failed verify spends no rate budget and produces no envelope.
Scope: the widget renders on the email-entry step of `/login` only.
The OTC verify step (where the user pastes the six-digit code) is
already bottlenecked on email delivery and protected by the
five-minute TTL + single-use consume on the row; a second challenge
there would double the rate budget against the same abuse path
without measurably more protection. If bots adapt to defeat the
email-entry challenge specifically — pushing the abuse vector onto
the verify step — a future release adds the second widget. The
widget also renders on the passcode step's "Use a code instead"
fallback dispatch since that route also calls `/auth/otc/request`.
Default policy: `TURNSTILE_REQUIRED=false`. The gate stays open when
the secret is absent — the dev / test path, and the pre-rollout
path while the operator is wiring the secret. Once the secret is in
GCP Secret Manager and the site key is in the overlay, the operator
**MAY** flip `TURNSTILE_REQUIRED=true` so a future config drift on
the secret fails loudly (HTTP 500 "auth misconfigured") instead of
silently disabling abuse defense.
No schema migration — Turnstile siteverify is stateless.
### Added
- **`backend/app/turnstile.py`** — the siteverify caller. POSTs
`secret` + `response` (+ optional `remoteip`) to
`https://challenges.cloudflare.com/turnstile/v0/siteverify` and
returns a `VerifyOutcome` (`ok` boolean + `reason` enum:
`ok` / `skipped` / `misconfigured` / `missing-token` / `failed` /
`network`). Tunables read from env at call time so tests
monkeypatch cleanly: `CLOUDFLARE_TURNSTILE_SECRET`,
`TURNSTILE_REQUIRED`, and (test-only) `TURNSTILE_SITEVERIFY_URL`.
- **`frontend/src/components/TurnstileWidget.jsx`** — the React
wrapper around the official CloudFlare Turnstile JS API. Reads the
site key from `import.meta.env.VITE_TURNSTILE_SITE_KEY`; renders
nothing when the var is unset (the form still submits and the
backend's `TURNSTILE_REQUIRED` policy decides admission). Loads
the CloudFlare script once per page on first widget mount. Cleans
up the widget instance on unmount via `turnstile.remove()` so a
remount produces a fresh challenge rather than reusing a stale,
already-consumed token.
- **Backend tests** (`backend/tests/test_turnstile_vertical.py`) —
five vertical scenarios: happy path (secret + valid token →
admit), siteverify rejects → 400 + no envelope, missing-token →
400 + no envelope, missing-secret-soft (default) → admit, and
missing-secret-hard (`TURNSTILE_REQUIRED=true`) → 500
"misconfigured". All five mock the siteverify HTTP call via
`monkeypatch.setattr(turnstile.httpx, "post", …)`; no real
CloudFlare keys are ever embedded.
- **SPEC `§6.2`** — names the Turnstile gate on the OTC dispatch as
the v0.12.0 settled shape; the §19.2 candidate from v0.7.0 closes.
### Changed
- **`backend/app/main.py`** — `OtcRequestBody` grows an optional
`turnstile_token` field. The `/auth/otc/request` handler calls
`turnstile.verify_token` first, before `otc.request_code`, so a
failed challenge spends no rate budget and produces no envelope.
The handler maps `misconfigured` → HTTP 500, all other failures
(`missing-token`, `failed`, `network`) → uniform HTTP 400 so the
response does not enumerate which leg of the challenge broke.
- **`frontend/src/api.js`** — `requestOtc` accepts a second arg
`{ turnstileToken }` and threads it into the request body. The
positional signature stays backwards-compatible so calls that pass
only an email still type-check.
- **`frontend/src/components/Login.jsx`** — the email step and the
passcode step both render `<TurnstileWidget>`. The submit button
on the email step is disabled until the widget produces a token
(when the widget is enabled at build time); the "Use a code
instead" link on the passcode step has the same gate. A 400 from
`/auth/otc/request` clears the token and surfaces a "couldn't
verify you're human, please retry" status. The fallback-from-
passcode path bounces back to the email step on 400 so the user
gets a fresh challenge in the natural place.
- **`backend/.env.example`** — documents `CLOUDFLARE_TURNSTILE_SECRET`
and `TURNSTILE_REQUIRED` alongside the existing OTC tunables.
- **`frontend/.env.example`** — documents `VITE_TURNSTILE_SITE_KEY`
with the operator wire-up procedure (dash.cloudflare.com →
Turnstile → Add site).
### Upgrade steps (from 0.10.0)
The operator **MUST** create a CloudFlare Turnstile site
(dash.cloudflare.com → Turnstile → Add site, choose "Managed" widget
mode), obtain the site key (public) and secret key (private), and:
- You **MUST** `flotilla secret set ohm-rfc-app CLOUDFLARE_TURNSTILE_SECRET`
(paste the secret key when prompted) before the v0.12.0 deploy.
The framework reads the secret at request time; deploying v0.12.0
without the secret leaves the gate in its default soft-fail state
(every request admitted regardless of token), which means abuse
defense is silently off.
- You **MUST** `flotilla overlay set ohm-rfc-app VITE_TURNSTILE_SITE_KEY <site-key>`
so the frontend build embeds the site key and the widget renders
on `/login`. The site key is public — it travels in the bundle and
appears in every browser — so this is the overlay (non-secret)
layer per the §3 invariant 1 split. Skipping this step leaves
`/login` with no widget; even after the operator sets the secret,
the backend would refuse every request as `missing-token` once
`TURNSTILE_REQUIRED=true` flips.
- You **MUST** rebuild the frontend and restart the backend after
upgrading. `frontend/package.json#version` and `VERSION` both move
to `0.12.0`. No schema migration; Turnstile siteverify is
stateless. The site-key embed is build-time, so the rebuild after
the `flotilla overlay set` is what actually wires the widget into
the bundle the deploy serves.
- You **MAY** `flotilla overlay set ohm-rfc-app TURNSTILE_REQUIRED true`
once you've confirmed a real sign-in works end-to-end with the
widget. The default (`false`) keeps the gate in soft-fail mode so
a missing-secret regression admits requests rather than 500ing
every sign-in attempt; flipping to `true` makes a future config
drift on the secret fail loudly with HTTP 500 instead of silently
disabling abuse defense. The framework's tested path is the
flipped-to-true production shape; the default `false` exists for
the dev / pre-rollout window only.
- You **MAY** customize the Turnstile widget mode (Managed /
Non-interactive / Invisible) from the dashboard at any time
without redeploying — the site key stays the same, and the widget
picks up the mode change on the next page load. The framework's
tested path is "Managed" because it gives the operator a visible
challenge surface to debug against.
If either of the two **MUST** secret/overlay steps is skipped, the
deploy still boots and `/login` still serves; the failure mode is
that abuse defense is off (default `TURNSTILE_REQUIRED=false`) or
every sign-in attempt 500s (`TURNSTILE_REQUIRED=true` flipped while
the secret is unset). The driver pauses the wave at the secret/
overlay gesture so the operator confirms both are in place before
the framework version pin moves.
## 0.10.0 — 2026-05-28
**Minor — schema migration required; new auth path is additive.**
This release lands user-set passcodes after OTC (roadmap item #8,
SPEC §6.2). After a successful one-time-code sign-in, the user can
set a passcode (420 characters) and use email + passcode for
subsequent sign-ins. OTC remains the structural fallback: a
forgotten passcode is recovered by requesting a fresh code, and
five consecutive failed passcode verifies lock the passcode path
for 15 minutes (HTTP 423) while leaving the OTC path open. The
`/login` surface now consults a new `GET /auth/passcode/check`
endpoint after the email step to decide whether to render a
passcode input or an OTC code input; an "Use a code instead" link
on the passcode step lets the user fall back to OTC manually. The
`/settings/notifications` page grew a new "Sign-in" tab where the
user can set, change, or remove their passcode.
### Upgrade steps (from 0.8.0)
1. **MUST** restart the backend so migration `015_passcode.sql`
runs. The migration adds four nullable columns to the `users`
table: `passcode_hash`, `passcode_set_at`,
`passcode_failed_attempts` (default 0), `passcode_locked_until`.
Existing rows pass through with `passcode_hash = NULL`, which
the runtime treats as "no passcode set" — every existing user
continues to sign in via OTC unchanged, and can opt into a
passcode from the new settings tab at any time.
2. **MUST** rebuild the frontend so the v0.10.0 `/login` flow and
the new settings tab ship. `frontend/package.json#version` and
`VERSION` both move to `0.10.0`.
3. **SHOULD** announce the new sign-in option to users. Wording
suggestion: "You can now set a passcode for faster sign-in.
We'll keep emailing one-time codes as a fallback — if you
forget your passcode, just request a code as usual."
4. **MAY** leave the §6.2 default lockout shape (5 attempts,
15-minute window) unchanged. v0.10.0 does not expose env
tunables for these; raising or lowering them lives in §19.2
as a candidate.
### Added
- **`POST /auth/passcode/set`** — authenticated. Body `{passcode}`.
Validates length (420) and refuses obvious patterns from a small
denylist (`0000`, `1234`, `aaaa`, `password`, etc.). bcrypt-hashes
the passcode and writes `users.passcode_hash` plus
`users.passcode_set_at`. Clears any active lockout and the
failure counter (a user setting a fresh passcode is implicitly
re-authenticating). Replaces any prior passcode.
- **`DELETE /auth/passcode`** — authenticated. Clears the passcode
hash and the set-at stamp; the user is back to OTC-only.
- **`POST /auth/passcode/verify`** — unauthenticated. Body
`{email, passcode}`. Returns HTTP 200 + minimal user payload on
success; HTTP 423 with `locked_until` when the account is in the
lockout window; HTTP 400 for every other failure (the
wrong-passcode and unknown-email modes both collapse to 400 so
the response does not enumerate account state).
- **`GET /auth/passcode/check`** — unauthenticated. Query param
`email`. Returns `{has_passcode: boolean}`. The Login.jsx flow
consults this after the email step to decide whether to render a
passcode input or fall back to OTC. The response carries only
the boolean; lockout state, the hash, and the `passcode_set_at`
stamp are not leaked. An unknown email and a known-without-
passcode email both return `false`, so the endpoint is
account-enumeration-safe.
- **Schema migration** `015_passcode.sql` — four ALTER TABLE ADD
COLUMN statements on the `users` table:
- `passcode_hash TEXT` (nullable) — the bcrypt hash. NULL means
"no passcode set".
- `passcode_set_at TEXT` (nullable) — ISO-8601 timestamp.
- `passcode_failed_attempts INTEGER NOT NULL DEFAULT 0`
consecutive failure counter since last success.
- `passcode_locked_until TEXT` (nullable) — lockout window
expiry; verify refuses with HTTP 423 while populated and
in the future.
- **`backend/app/passcode.py`** — the passcode state machine:
validation (length + denylist), bcrypt hashing, set/clear,
status check, and the verify path with lockout management.
- **`frontend/src/components/Login.jsx`** — extended to a five-step
surface: email → passcode-or-code → optional post-OTC
passcode-offer → optional set-passcode. The "Use a code instead"
link on the passcode step re-dispatches an OTC and switches to
the code step. A 423 from passcode verify auto-falls back to OTC
with a visible status message.
- **"Sign-in" tab** in `/settings/notifications` — shows
passcode-set status, the recorded `passcode_set_at` stamp when
set, and Set / Change / Remove buttons. Mirrors the §14.5
"Privacy & cookies" tab pattern.
- **SPEC `§6` / `§14.1` / `§17` / `§19.2`** corrections per §19.3
rule 2:
- §6 names the three current auth paths (OTC, passcode-with-OTC-
fallback, OAuth-fallback-during-migration).
- §14.1 documents the stepped `/login` surface and the passcode
check endpoint.
- §17 lists the four new `/auth/passcode/*` endpoints.
- §19.2 surfaces four new candidates (passcode policy tunables
via env, per-IP rate-limit on `/auth/passcode/verify`,
passcode-change "old passcode" challenge, passkey/WebAuthn);
the "device-trust 30d" entry's passcode cross-ref is updated;
the "first-OTC profile capture" entry's roadmap cross-ref is
updated.
### Changed
- **`backend/app/main.py`** — registers the four new
`/auth/passcode/*` routes on the existing oauth router, alongside
the v0.7.0 `/auth/otc/*` routes.
- **`backend/app/api.py`** — `/api/auth/me` payload now includes
`has_passcode` (boolean) and `passcode_set_at` (string or null)
so the settings surface can render the Set / Change / Remove
affordances without a second round trip.
- **`frontend/src/api.js`** — adds `checkPasscode`, `verifyPasscode`,
`setPasscode`, `clearPasscode` helpers, neighboring the v0.7.0
`requestOtc` / `verifyOtc` block.
- **`frontend/src/components/NotificationSettings.jsx`** — adds the
`SignInSection` component between `MutesSection` and
`PrivacyCookiesSection`.
### Tests
- **`backend/tests/test_passcode_vertical.py`** — 17 new tests
cover: set requires session; check returns false for unknown and
for set-less users; check returns true after set without leaking
other fields; happy-path OTC → set → verify roundtrip; wrong
passcode increments the counter without locking; five consecutive
failures lock with 423 and persist `passcode_locked_until`;
lockout expires and the next attempt clears the counter; the OTC
path is unaffected by passcode lockout; clear wipes the hash and
set-at; setting a new passcode replaces the prior one and resets
the lockout; `passcode_set_at` updates on every set; validation
refuses too-short passcodes and denylist patterns; `/api/auth/me`
carries `has_passcode` and `passcode_set_at` correctly.
### Environment variables
None new. The existing `SECRET_KEY` continues to sign session
cookies; passcode hashing reuses the bcrypt dependency added in
v0.7.0. The lockout shape (5 attempts, 15 minutes) and the length
range (420) are hard-coded in `backend/app/passcode.py`. See
§19.2 for the env-tunable candidate.
## 0.8.0 — 2026-05-28
**Minor — schema migration required; admission semantics shift.**
This release replaces the v0.3.0 / v0.7.0 `allowed_emails` admission
gate with an admin-grant flow (roadmap item #6, SPEC §6.1 / §6.2 /
§14.1 / §17). Anyone with a valid email can sign in via the v0.7.0
OTC flow; the OTC request endpoint no longer consults the
allowlist. A fresh user lands in `permission_state='pending'` until
an admin grants access. The first-OTC sign-in captures first name,
last name, and a free-text "why I should be included in the beta"
via a new `POST /api/auth/me/beta-request` endpoint; the captured
fields populate the same `users` row alongside the OAuth-era
columns.
A pending user has the same read access an anonymous viewer has —
the catalog, RFC bodies, the philosophy page, and every public
conversation are reachable. Every write-shaped endpoint
(`auth.require_contributor` floor) refuses pending users with 403.
The frontend renders a thin "Your beta access request is in
review" banner on every page and re-purposes the v0.3.0
`/beta-pending` page as the post-capture landing surface.
Grandfathered behavior: every `users` row at migration time
carries `permission_state='granted'` via the column default, so
existing contributors are unaffected. The OAuth fallback at
`/auth/callback` still consults the v0.3.0 allowlist (legacy
path); the OTC flow does not.
The `allowed_emails` table stays in the schema as a fast-path
bypass — the v0.3.0 admin UI continues to manage it, but the OTC
request handler no longer reads it. v0.9.0 (roadmap item #7) is
expected to ship the admin user-management page that replaces the
allowlist surface entirely; until then, admin grants are done by
direct DB `UPDATE`.
### Upgrade steps (from 0.13.0)
1. **MUST** restart the backend so migration `014_beta_access.sql`
runs. The migration adds `permission_state` (default `'granted'`,
so existing rows pass through unaffected), `first_name`,
`last_name`, `beta_request_reason`, `permission_decided_by`,
and `permission_decided_at` to the `users` table, plus an index
on `permission_state` for the pending queue. The migration is
ALTER-TABLE-based (no table rebuild) — every foreign key and
existing row passes through untouched.
2. **MUST** rebuild the frontend. The `Login.jsx` surface now
runs a conditional third step (the capture form) on fresh OTC
sign-ins; `BetaPending.jsx` carries the new "your request is
in review" copy; `App.jsx` renders a thin pending-access
banner. The build embeds the new `/api/auth/me/beta-request`
client call.
3. **SHOULD** announce the new admission flow to existing users.
Wording suggestion: "We've replaced our email-allowlist gate
with an admin-review flow. Existing users are unaffected;
new visitors sign in with their email, tell us a bit about
themselves, and an admin reviews their request before
discussion and contribution unlock." Existing sessions
remain valid.
4. **SHOULD** plan the admin grant mechanism. v0.8.0 does not
ship a UI for the grant — v0.9.0 (roadmap item #7) will. For
the v0.8.0 window, an admin grants access via direct DB
gesture:
```sql
UPDATE users
SET permission_state = 'granted',
permission_decided_by = <admin_user_id>,
permission_decided_at = datetime('now')
WHERE email = '<approved>';
```
The pending queue lives in `SELECT * FROM users WHERE
permission_state = 'pending' ORDER BY created_at`.
5. **MUST** decide whether to drain the `allowed_emails` table.
The OTC request handler no longer consults it; populated
rows are inert at the request surface. Three operator
choices, all valid:
* **Leave as-is** (the framework's default behavior — the
v0.3.0 admin UI continues to work, the rows stay as a
fast-path bypass record). Recommended if you anticipate
v0.9.0's user-management page folding the allowlist UI
into its surface.
* **Drain via the existing admin UI** (`/admin/allowlist`)
— one row at a time, no data loss elsewhere.
* **Bulk-drain via DB** — `DELETE FROM allowed_emails;`
drops every row; the table stays.
6. **MAY** announce write access individually to grandfathered
users you want to keep at `'granted'`. The default-`'granted'`
migration means no action is required for them; this step
exists only if you want to send a "you're still in" message.
### Added
- **`backend/migrations/014_beta_access.sql`** — adds
`permission_state` (CHECK in `('pending', 'granted', 'revoked')`,
default `'granted'`), `first_name`, `last_name`,
`beta_request_reason`, `permission_decided_by` (FK to users,
ON DELETE SET NULL), `permission_decided_at` to the `users`
table. Plus `idx_users_permission_state` for the pending
queue.
- **`POST /api/auth/me/beta-request`** — body
`{first_name, last_name, beta_request_reason}` (all required;
bounds 120 / 120 / 4000). Writes the fields to the signed-in
user's row and leaves `permission_state='pending'`. Refuses
HTTP 409 for already-granted / revoked users; refuses HTTP
401 for anonymous callers.
- **`needs_profile` flag** on the `/auth/otc/verify` response.
`true` iff the user is `permission_state='pending'` AND
carries no profile fields yet (a fresh OTC sign-in). The
Login.jsx surface uses the flag to gate the capture step.
- **`permission_state` field** on the `/api/auth/me` response,
plus `first_name`, `last_name`, `beta_request_reason`, and
the same `needs_profile` flag.
- **First-OTC profile capture step** in `Login.jsx`. Third
step in the sign-in surface, gated by the verify response's
`needs_profile` flag.
- **`/beta-pending` repurpose** in `BetaPending.jsx`. The
page now reads as "your request is in review" when the
viewer is pending; the v0.3.0 "private beta" framing
remains as the anonymous-viewer fallback.
- **Thin pending-access banner** at the top of every page
for `permission_state='pending'` viewers (other than
`/beta-pending` itself).
- **SPEC `§6` opening / `§6.1` / `§6.2` / `§14.1` / `§17` /
`§19.2`** corrections per §19.3 rule-2 — the admission
shift, the orthogonality of permission_state vs role / muted
/ notification-mutes, the new endpoints, and the
newly-surfaced §19.2 candidates (admin user-management page,
allowlist deprecation, admin notification on new request).
- **`backend/tests/test_beta_access_vertical.py`** — 9 new
tests covering: a fresh OTC user lands pending with empty
profile; the capture endpoint populates the fields and
keeps state pending; the capture endpoint refuses
anonymous / granted / revoked callers; a pending user is
refused write endpoints; an admin grant promotes pending →
granted; a grandfathered user is unaffected by the
migration; the OTC request endpoint accepts any email
regardless of allowlist state; the `allowed_emails` table
is still present in the schema.
### Changed
- **`backend/app/auth.py#require_contributor`** widens its
gate to refuse `permission_state != 'granted'` with HTTP
403. The §6.1 contributor capabilities (propose, branch,
PR, chat, claim) all funnel through this dependency, so
the widening covers them transitively. `SessionUser` now
carries `permission_state` (default `'granted'` for the
dataclass-default fallback path).
- **`backend/app/otc.py#request_code`** drops the allowlist
check from the OTC request flow. The `RequestOutcome`
shape loses the `'allowlist'` reason (replaced by
`'sent'` / `'cooldown'` / `'invalid'`).
- **`backend/app/otc.py#provision_or_link_user`** sets
`permission_state='pending'` explicitly on a fresh row.
Grandfathered (link-by-email) users pass through with
their existing column value.
- **`backend/app/auth.py#provision_user`** (OAuth fallback)
now sets `permission_state='granted'` explicitly on a
fresh row. The OAuth callback still consults the
`is_allowed_sign_in` allowlist check (the legacy fallback
path retains its v0.3.0 admission shape during the OAuth
migration window).
- **`backend/tests/test_otc_vertical.py`** — the
`test_otc_request_silently_drops_when_email_not_on_allowlist`
test (asserted the v0.7.0 allowlist gate) is replaced by
`test_otc_request_admits_emails_regardless_of_allowlist_population`
which asserts the v0.8.0 open-request contract. The
on-list test stays as a regression net for the
rate-limit / outbound-buffer plumbing.
- **`SPEC.md`** §6 opening, §6.1, §6.2, §14.1, §17, §19.2
per §19.3 rule-2.
- **`VERSION`** → `0.8.0`. `frontend/package.json#version` and
the lockfile mirror.
### Deferred to later releases
- **Admin user-management page** at `/admin/users` (item #7,
v0.9.0) — replaces the manual DB `UPDATE` gesture.
- **Allowlist UI deprecation** (also v0.9.0) — once the
admin user-management page lands, the `/admin/allowlist`
surface and the `allowed_emails` table both retire.
- **Admin email notification on new beta request** (item #7
again, v0.9.0).
- **Revoke gesture in the UI** — the `permission_state='revoked'`
state is wired in the schema and the auth gate; v0.9.0 ships
the admin UI that flips the column.
## 0.7.0 — 2026-05-28
**Minor — schema migration required; new auth path is additive.**
@@ -802,106 +158,6 @@ release and surface as §19.2 candidates:
- **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
**Minor — no operator action required.** This release wires the
@@ -1254,3 +510,7 @@ names itself.
- `CLAUDE.md` at the repo root capturing the separation-of-concerns
rule for working sessions.
## 0.1.0 — v1 build
Initial release. See `docs/DEV.md` for the slicing plan and build
history.
-605
View File
@@ -1,605 +0,0 @@
# Using the RFC app
This is the user-facing guide to the Wiggleverse RFC framework — how to
read what's here, propose a new RFC, contribute to one that already
exists, and understand who is allowed to do what.
This guide describes the framework. Individual deployments brand and
configure themselves independently — the name in the header and the
corpus the RFCs are about belong to the deployment, not to this
document.
For the *why* of the framework, read the [philosophy](/philosophy).
For the binding technical contract, see `SPEC.md` in the repository.
---
## Reading without signing in
You can read the catalog and every public RFC without an account.
Anonymous visitors can:
- Browse the catalog of super-drafts and active RFCs.
- Open any RFC and read its canonical body.
- Read any public branch — its diff and its chat thread.
- Read any pull request — its diff, its conversation, its review
comments.
- Read the discussion that has accumulated on an RFC's main view.
Reading is open by design. The framework's claim is that the *argument
behind a definition* is the evidence that the definition was earned,
and an argument that disappears behind a sign-in wall stops carrying
that evidence.
What you cannot do without an account: chat, propose a new RFC,
create a branch, open a PR, drop a flag, or post on a discussion
thread. Every write affordance is replaced with a sign-in prompt.
---
## Signing in
While the framework is in private beta, only invited email addresses
can complete sign-in. If your email is on the allowlist, the
"Sign in" button in the header completes the flow and lands you on
the catalog with full read and write access. If your email is not on
the allowlist, you'll be sent to a short "pending" page explaining
the gate.
Once you have an account, you're a **contributor** by default — the
role that grants every write affordance the app exposes, scoped by
the per-RFC and per-branch rules described below.
---
## Proposing a new RFC
A new RFC begins as a proposal. The "+ Propose new RFC" button at
the bottom of the catalog opens a small modal that collects four
things:
- **Title.** The word, concept, or topic this RFC would define.
- **Slug.** A kebab-cased identifier derived from the title. It is
the entry's stable handle from this moment until it graduates;
collisions with existing entries or open proposals are caught
inline.
- **Pitch.** One or two paragraphs answering *why this RFC is
needed*. This becomes the body of the entry.
- **Tags.** Optional. The AI suggests tags from the pitch; you can
accept, dismiss, or type your own.
Submitting the modal does one concrete thing: it opens a pull
request against the framework's meta repository, adding one new
file under `rfcs/`. There is no other Git artifact and no other
side-effect. You are returned to the **pending-idea view** for the
new proposal.
A pending idea is publicly readable but not yet a super-draft. The
catalog surfaces it in a "Pending ideas" disclosure at the bottom
of the list. A conversation can accumulate on the pending-idea view
before it is admitted — contributors can argue, in public, about
whether the entry belongs in the catalog at all.
Three outcomes are possible:
- **Merge.** An admin or owner merges the proposal PR. The entry
becomes a super-draft and graduates from the "Pending ideas"
section into the main catalog. Any conversation that accumulated
on the pending-idea view migrates with it.
- **Decline.** An admin or owner declines, attaching a written
comment. You see the comment on your next visit, along with a
one-click affordance to revise and re-propose.
- **Withdraw.** You can withdraw your own proposal at any time. The
entry will not appear in any default view; the conversation that
accumulated stays attached to the closed PR as historical record.
You are automatically the first owner of any RFC you propose. The
claim flow described under [Roles & permissions](#roles--permissions)
is for *other* contributors to add themselves as owners later, not
for the proposer.
---
## What a super-draft is
A super-draft is an entry that has been admitted to the catalog but
does not yet have its own dedicated repository. Most of the
argument that shapes a definition happens here. The framework
assumes — and the philosophy explicitly invites — that many
super-drafts will not survive the argument, and that is fine. The
entries that do survive earn their place in the catalog by being
defensible in public.
Opening a super-draft from the catalog gives you the same surface
an active RFC uses:
- The canonical body in the centre, read-only by default.
- A chat thread on the right where the public conversation lives.
- A breadcrumb dropdown listing any in-flight edit branches and
any open body-edit PRs against this entry.
- A "Start Contributing" affordance that cuts a fresh edit branch
and lands you in contribute mode.
Edits to a super-draft body propagate through pull requests against
the meta repository — there is no dedicated RFC repository yet.
---
## What an active RFC is
An active RFC is an entry that has been **graduated**. It has its
own dedicated repository, an integer `RFC-NNNN` identifier, and a
canonical body file (`RFC.md`) inside that repository. The catalog
distinguishes super-drafts and active RFCs at a glance.
Opening an active RFC gives you:
- `main` — the canonical body, always read-only. Changes to `main`
arrive exclusively through pull requests.
- A breadcrumb listing every open branch and pull request on this
RFC.
- A per-branch chat thread on the right. Each branch has its own
conversation, including `main` itself.
- A "Start Contributing" affordance: on `main` it cuts a new branch
and lands you on it in contribute mode; on any other branch you
already have push access to, it flips that branch into
contribute mode.
---
## Discussion vs contribution
The framework draws an explicit distinction between two surfaces
that other tools tend to conflate:
- **Discussion** is what the RFC is *for*. The chat thread on an
RFC's main view is the place for "what about this part?" or
"have we considered…?" questions that don't yet warrant proposing
a specific edit. Posting on a discussion thread does not create
any Git artifact; the conversation lives in the app database.
- **Contribution** is how an RFC *changes*. Editing the canonical
body requires opening a branch and, eventually, a pull request.
The pull request is the place a specific proposed change is
reviewed and merged.
Reading both surfaces is open to anonymous visitors. Posting on
either requires a contributor account.
---
## Working on a branch
Contribute mode flips one branch into edit-enabled. The centre
column splits: a markdown source pane on the left, a live-rendered
preview on the right. Fenced `mermaid` blocks render as diagrams in
the preview.
Two kinds of edits accumulate on a branch:
- **AI-proposed changes.** You ask the AI a question or request a
revision in the branch's chat. When the AI proposes a concrete
edit, that edit appears as a *change card* in a panel below the
chat — not yet applied to the document. You can **accept**,
**decline**, or **edit before accepting**. Accepting produces
one commit on the branch with the original text, the proposed
text, and the AI's reason recorded in the commit body.
- **Manual edits.** Typing directly into the source pane buffers
locally and flushes as a single commit on an idle window, a
branch switch, or an explicit "Save now" button. Manual edits
also appear as change cards in the same panel — same evidence
shape, different author.
Every accepted change is one commit. The framework does not
support squash-merges or fixup-style cleanups: the per-change
commit granularity is the framework's evidence unit, and
collapsing it would erase what was earned.
### Discuss mode vs contribute mode
A branch defaults to discuss mode — read-only, with chat enabled.
AI proposals still appear in chat, but they are *buffered* rather
than applied; a single CTA invites you to flip the branch into
contribute mode if you want to act on them. The toggle is an
*intent* affordance, not a permission one. If you don't have push
access to the branch, the toggle is disabled with a sign-in or
request-access path.
`main` is special: contribute mode is never available there. The
"Start Contributing" button on `main` always cuts a new branch.
### Flags
Anywhere you can read, you can drop a flag. A flag is the
lightweight "I'm pointing at this, it's a problem" gesture — a
single short declarative statement anchored to a passage. Creating
a flag requires a contributor account but does not require push
access to the branch: any signed-in contributor who can read a
passage can point at it and say it's wrong.
Flags don't block PR merges by design — making them a merge gate
would re-create the failure mode where contributors hastily "resolve"
threads to unblock a button. Flags are prominent on PR headers but
non-blocking.
### Branch visibility
A new branch is publicly readable by default. The branch creator
can flip a branch to private, in which case only the creator, any
explicit grantees, and the RFC's per-RFC owners and arbiters can
read it. Owners and arbiters can flip it back.
**Opening a PR makes the branch fully public.** If your branch is
currently private, the "Open PR" affordance asks you to confirm
this before submitting. There is no concept of a private PR — the
framework's evidence claim depends on the argument being readable.
### Who can push to a branch
Every branch has one of three contribute modes:
- **`just-me`** (default) — only the branch creator can push.
- **`specific`** — only the branch creator and explicitly granted
contributors can push.
- **`any-contributor`** — any signed-in contributor can push.
The branch creator and the RFC's per-RFC owners and arbiters can
change this setting at any time.
### Branch hygiene
A branch with no associated PR auto-closes after 30 days of
inactivity. A closed branch is deleted from the Git host 60 days
later. Closed branches remain in the catalog under a "show closed"
filter — closing is a state, not a censorship event. The chat
attached to a closed or deleted branch is preserved as historical
record.
Owners and arbiters can *pin* a branch to disable the auto-close
timer if the work is paused but legitimately ongoing.
---
## Opening and reviewing a pull request
A pull request is the deliberate "ready for review" gesture for
work that has accumulated on a branch. The "Open PR" affordance is
available on any branch with at least one commit ahead of `main`.
The PR creation modal collects two AI-drafted fields, both editable
before submit:
- **Title.** A one-line description of the change, in spec voice.
- **Description.** Two to four sentences pulling from the branch
chat, written for an arbiter.
There is no reviewer picker. The RFC's arbiters are the implicit
reviewer set.
### The PR review page
The review page shows the diff, the branch's compressed chat
(messages that produced accepted changes are expanded, the rest is
behind a "Show full conversation" toggle), and the review-comment
surface inline below the chat.
Review comments are not a separate concept from chat — they live in
the same thread, anchored to a range in the diff. The framework's
claim is that the disagreement an arbiter raises about a proposed
change is the same *kind* of thing as the disagreement that
produced the proposed change in the first place, and the two should
share a surface.
Each PR records a per-user seen-cursor. New diff hunks and new
conversation messages since your last visit render with a subtle
accent. The cursor advances on view; you do not have to mark
anything as read.
### Merging a PR
Per-RFC owners and arbiters can merge; app-wide admins and owners
also retain this capability. The merge produces a no-fast-forward
commit on `main`, preserving every per-acceptance commit as an
individually reachable node in `main`'s history.
Merge is hard-blocked **only** by Git-level conflicts with `main`.
Open review threads, pending change-cards, unresolved chat threads,
and open flags do not block merge by design.
### Conflicts with main
A conflict surfaces on the PR page as a read-only banner. A "Start
resolution branch" affordance cuts a fresh branch off `main`'s
current tip, replays the work into it (asking the AI to resolve
unambiguous conflicts, surfacing the rest for you), and opens a new
PR. The original PR auto-closes when the resolution PR merges.
Fixup commits on the existing branch are not supported. Per-change
commit granularity is the framework's evidence unit; admitting
"fix merge conflict with main" commits would dilute it.
---
## Graduation: super-draft → active RFC
Graduation is the moment a super-draft becomes a canonical entry
in the catalog. It is initiated by an app-wide admin, an app-wide
owner, or one of the RFC's per-RFC owners or arbiters from the
super-draft's page.
Two preconditions block the action:
- **The super-draft must have at least one owner.** The proposer
is automatically the first owner; if they have stepped away, any
contributor can use the "Claim ownership" affordance to add
themselves.
- **No open body-edit PRs against the super-draft's entry.** An
open body-edit PR would attempt to re-introduce a body to a
frontmatter-only entry after graduation runs. Merge or withdraw
them first.
When the dialog confirms, the framework runs a transactional
sequence: create a fresh Git repository for the RFC, seed it with
the super-draft's body as `RFC.md`, update the meta-repo entry to
`state: active` with the integer ID and the new repository's URL,
auto-merge that update. If any step fails partway, the sequence
rolls back — the half-created repository is deleted and the
unmerged update is abandoned. The dialog shows each step in flight
and tells you exactly what happened.
The chat thread on the super-draft moves to the new repository's
`main` chat at graduation. Edit-branch chats from the super-draft
phase stay attached to their original branches on the meta repo
and surface from the new RFC view under a "Pre-graduation history"
section.
Graduation is not reversible. The path forward from an active RFC
is withdrawal, not back to super-draft.
---
## Withdrawing and reopening
An active RFC or a super-draft can be withdrawn by the proposer
(for a super-draft they proposed) or by an admin or owner. A
withdrawn entry stays in the catalog as a historical record but is
hidden from default views. The entry is filterable back in.
An admin or owner can reopen a withdrawn entry back into the
super-draft state. The history is preserved across the transition.
---
## AI in the chat
The chat on every RFC, super-draft, branch, and PR has an AI
participant by default. The framework treats the AI as one voice
among many in a public argument — not an oracle, and not a
co-author whose name lands on commits.
You invoke the AI by writing into the chat composer and submitting.
Each message can pick a model from the picker (the option list is
configurable per RFC). The AI responds in the chat; when its
response includes a concrete change to the document, that change
appears as a card you can accept, decline, or edit.
When you accept an AI's proposed change, the commit's
`On-behalf-of:` trailer names *you*, not the AI. The AI's authorship
survives only as evidence — the original proposal in the commit body
and the message that produced it in the chat record. The framework
is explicit about this: AI participation produces evidence; it does
not produce authorship.
Two configuration knobs scope AI participation per RFC:
- **Which models are available.** The meta-repo entry's frontmatter
carries an optional `models:` list. Absent means the RFC inherits
whatever models the deployment is provisioned to run. An empty
list (`models: []`) opts the RFC out of AI entirely — every AI
surface is absent rather than disabled-but-present.
- **Whose credentials pay.** By default the deployment operator's
API credentials cover AI calls on every RFC. A `funder:`
frontmatter field can name a single contributor whose registered
credentials pay for AI calls on this RFC instead. The named
contributor must explicitly consent from their settings page;
either side can revoke at any time.
Per-RFC AI configuration is edited through the meta-repo PR flow
that governs the rest of the entry's frontmatter — by the RFC's
per-RFC owners and arbiters, or by app-wide admins or owners.
---
## Notifications
The framework's public-async work model produces signals that
shouldn't all reach you the same way. Five surfaces compose:
- **In-app inbox.** The durable triage surface. One mental space
across every RFC you have any relationship to, with per-RFC and
per-category filters. Reachable from the inbox icon in the
header.
- **Badges.** Ambient pull-ins. A single integer beside the inbox
icon (count of unread notifications). A small binary dot on
individual catalog rows for watched RFCs with unseen activity.
No per-row counts and no per-section counts.
- **Toasts.** Transient mid-session signals. Used only for your own
actions completing, and for events arriving on the view you're
currently looking at.
- **Email.** The single channel that escapes the app. Opt-in per
category, conservative defaults. One-click unsubscribe per
category.
- **Digest.** Aggregation for activity on watched RFCs you haven't
triaged through any other channel.
### Watch states
Every RFC has one of three implicit relationship states for you:
- **Watching.** You receive structural signals for the RFC.
- **Following.** You receive only churn-grade signals (new
commits, new chat messages on threads you didn't participate
in). This is a lighter relationship than watching.
- **Muted.** You receive no signals for the RFC. The mute is
per-RFC and self-imposed; it does not affect what others see
or what reaches you on *other* RFCs.
Watch states transition automatically based on your participation,
with explicit overrides available from each RFC's header and from
the notification settings page.
### Email categories
Four categories with distinct defaults:
- **Personal-direct events** — default on. Signals where you are
the named subject. The contract is that when your name is on the
action, the framework reaches out of band.
- **Watched-RFC structural events** — default off. PR opened on a
watched RFC, PR merged, graduation, withdrawal. Inbox and badges
carry these by default; the email toggle is opt-in.
- **Watched-RFC churn** — permanently off, by design. Per-commit
and per-message email is intentionally not offered. The digest
aggregates this activity weekly.
- **Admin-actionable events** — default on for admins and owners,
unused for contributors.
### Quiet hours
You can set a daily window during which email notifications are
held. Messages held during the window are released at window end —
bundled into a single "Activity while you were away" email if a
threshold accumulated, otherwise sent individually.
---
## Roles & permissions
Authorization in this framework is owned by the app itself, not by
the Git host. The Git host sees only a single bot account — every
commit, every PR, every merge passes through it on a user's behalf
— and the *app* decides which users are authorized to ask the bot
to do which things.
### The four app-wide roles
Each role is a strict superset of the one below it.
1. **Anonymous.** Anyone who has not signed in. Can read public
RFCs, public branches, and public PRs; cannot chat, propose,
create branches, or open PRs.
2. **Contributor.** The default role for any authenticated
account. Adds everything anonymous can do, plus: propose new
RFCs, create branches on any RFC repository, open PRs from
branches they have push access to, post on chat anywhere they
can read, claim ownership of unclaimed super-drafts.
3. **Admin.** Adds the ability to act on any RFC, anywhere in the
framework. Concretely: merge any PR on any RFC, graduate any
super-draft, set branch visibility on anyone's behalf, withdraw
or reopen any entry, write-mute or restore any contributor,
grant or revoke the **admin** role.
4. **Owner.** Adds two capabilities admin does not have: grant or
revoke the **owner** role itself, and disable an account
entirely. The framework names a single "owner zero" at
bootstrap.
The practical difference between admin and owner is narrow but
load-bearing: admin is the operational tier — it does the day-to-
day moderation and stewardship work; owner is the tier that
controls the admin tier. Disabling an account and creating other
owners are owner-only because they affect the framework's chain of
authority itself.
The app refuses to let the last owner demote themselves silently —
losing the last owner would leave nobody able to grant the role
back. Role changes are recorded in an append-only `permission_events`
log; an admin's own admin/users page shows the log of who promoted,
demoted, or muted whom.
### Per-RFC delegated authority
The four roles above are framework-wide. Within an individual RFC,
the meta-repo entry's frontmatter names two additional groups:
- **`owners:`** — contributors elevated for this RFC. They can
grant push access on any branch in the RFC, merge any PR on the
RFC, change branch visibility, and withdraw the RFC.
- **`arbiters:`** — contributors with merge authority for this RFC.
Functionally similar to per-RFC owners for merge decisions; the
distinction matters in some configuration paths.
Per-RFC owners and arbiters are **not** app-wide admins. Their
elevated powers are scoped strictly to the RFC named in the
frontmatter. This is what lets the framework distribute work
without putting one person on the hook for every action.
The proposer of an RFC is automatically the first per-RFC owner.
Additional per-RFC owners are added through a "Claim ownership"
PR against the meta repository; app-wide admins or owners merge
it.
### Per-branch contribute grants
Within an RFC, the branch creator and the RFC's per-RFC owners
and arbiters can grant push access to specific contributors on a
specific branch — `specific` contribute mode, described under
"Working on a branch."
### The write-mute
An app-wide admin or owner can **mute** a contributor. A muted
account retains read access and keeps its existing branches, but
cannot create new branches, open new PRs, propose new RFCs, or
post chat. This is a moderation tool, distinct from removing the
account; restoring is the reverse gesture.
The write-mute applies only to contributors. Promoting a user to
admin or owner is the way to remove a user's write-restriction in
the structural sense; the write-mute is for *retaining* an account
while removing its ability to act.
Every mute and every restore is recorded in `permission_events`.
### Three different "mutes"
The word "mute" appears in three structurally distinct places.
They share a word and nothing else.
- **Write-mute.** Admin-imposed. Removes a contributor's ability
to post or push. Described above.
- **Per-RFC notification mute.** Self-imposed. Sets your watch
state on a specific RFC to *muted* — you stop receiving signals
for that RFC, in inbox, badges, and email. Does not affect what
others see.
- **Per-user notification mute.** Self-imposed. Suppresses
notifications produced by a specific other user, anywhere in
the framework. Notification-volume only — it does not affect
what you can read.
A write-muted contributor continues to receive notifications
normally, so they can triage what they can't act on, and so a
restore lands cleanly.
### Audit trail
Every gesture that changes app state — role changes, mutes,
graduations, withdrawals, grant changes — is recorded in
append-only logs the app maintains. Git commit history is for
code archaeology; the app's audit log is the accountability
record. An admin's page surfaces both `permission_events` (the
role/mute log) and `actions` (the state-transition log) for
review.
---
## Where to learn more
- The framework's *why* lives in [the philosophy
document](/philosophy).
- The binding technical contract — section numbers (`§n.n`)
referenced throughout this guide — is in `SPEC.md` in the
framework's source repository.
- Deployment operators have their own recipe in
`docs/DEPLOYMENTS.md`.
+79 -500
View File
@@ -332,13 +332,6 @@ and exact columns are illustrative; the implementing session can adjust.
- `actions` — append-only audit log for every state transition, every
graduation, every grant change. Includes the acting user, the bot
commit hash if any, and the on-behalf-of trailer applied.
- `cookie_consent` — per-user record of the §14.5 cookie consent
choice. One row per user. Columns: `user_id` (PK, FK users), three
flags (`essential`, `analytics`, `other_cookies`), and
`recorded_at`. `essential` is permanently 1; `recorded_at` is set
on first write and updated on every change. Absence of a row means
"no choice yet" — the banner shows. Anonymous viewers persist their
choice in `localStorage` only, with no corresponding row here.
**Super-draft scoping.** For rows in `threads` and `changes` where the
entry referenced by `rfc_slug` is in state `super-draft`, `branch_name`
@@ -356,76 +349,31 @@ merge with no data movement.
Authorization is owned by the app. Gitea sees only the bot account.
Authentication has three paths, in the order a visitor encounters
them:
1. **Email + one-time code (OTC).** The v0.7.0 primary path: a
visitor enters their email address, receives a six-digit code via
SMTP, and exchanges the code for a session. Used by every visitor
on first sign-in, and as the fallback for the other two paths.
2. **Email + passcode (with OTC fallback).** Added in v0.10.0
(roadmap item #8). After a successful OTC sign-in, the visitor
may set a user-chosen passcode (420 characters, bcrypt-hashed at
rest) and use email + passcode on subsequent sign-ins. Five
consecutive failed verifies lock the passcode path for 15 minutes
(HTTP 423); during the lockout the user falls back to OTC. The
OTC path is unaffected by the passcode lockout, so a forgotten
passcode is recovered by requesting a fresh OTC — there is no
separate "forgot passcode" flow. The user can remove the passcode
at any time from the §6.2 sign-in settings tab, returning to
OTC-only.
3. **Gitea OAuth fallback (migration only).** The v0.1 OAuth
callback remains functional during the v0.7.0 window, with a
small "Sign in with Gitea (fallback)" link on `/login` so users
with active OAuth sessions or older invite paths still have a
way in. Scheduled for removal in a future release per §19.2.
`users.gitea_id` is preserved on existing rows so a grandfathered
user signing in via any of the three paths resolves to the same row.
`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.
Admission, as of v0.8.0, is by admin grant. v0.7.0 carried the
v0.3.0 `allowed_emails` table forward as the admission gate at the
OTC request surface — emails not on the list got a silent drop.
v0.8.0 (roadmap item #6) reverses that: any valid email receives an
OTC, the fresh `users` row lands in `permission_state='pending'`,
and an admin grant flips the column to `'granted'` before write
endpoints accept the user. The capture-fields step (first name,
last name, free-text "why I should be included in the beta") feeds
the admin's triage queue. The `allowed_emails` table stays in the
schema as a fast-path bypass — the v0.3.0 admin UI still manages
it — but the OTC request path no longer consults it. v0.9.0's
admin user-management page replaces the allowlist UI and ships the
pending-queue triage surface.
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
1. **Anonymous.** Can read public RFCs (the meta repo's main branch,
every RFC repo's main branch), read any branch whose `read_public`
is true, read any PR. Cannot chat, propose, create branches, or
open PRs. v0.6.0 (roadmap item #4) closed the audit: every
write-shaped endpoint surveyed in §17 enforces an explicit
`auth.require_contributor` (or stricter) gate before doing any
state-changing work; anonymous writes refuse 401. The explicit
audit covers propose, branch create, branch threads, PR-less
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.
open PRs.
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.8.0 replaced the v0.3.0 / v0.7.0 allowlist gate
with an admin-grant flow (roadmap item #6, see opening of §6).
The contributor capabilities below — propose, branch, PR, chat,
claim — are gated by `users.permission_state='granted'` as well
as by the role. A pending contributor (the post-OTC waiting
state) has the same read access as anonymous and zero write
capability until an admin grants. Everything anonymous can do,
plus: propose new RFCs (open a PR against the meta repo), create
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.
@@ -467,38 +415,6 @@ triage what they can't act on, and the restore lands cleanly); a
self-DND'd contributor's own gestures continue to fire signals to
others normally.
v0.8.0 adds a fourth structurally-distinct field on the same row:
`users.permission_state` (the admission gate the v0.8.0 release
ships, see opening of §6 and §6.1). The four — role, muted,
permission_state, the notification mutes — are orthogonal and the
gate semantics compose:
* `role` answers "what scope of action is this user authorized to
perform if they're admitted at all?" (anonymous / contributor /
admin / owner).
* `muted` answers "is this contributor write-restricted by an
admin gesture against their existing grant?" (a sanctions
primitive — owner/admin imposed).
* `permission_state` answers "is this user admitted to the beta
at all?" (the v0.8.0 admin-grant gate — 'pending' / 'granted' /
'revoked'). The default for grandfathered rows at migration time
is `'granted'`; OTC freshly provisions `'pending'`.
* The notification mutes answer "does this user want to receive
signals about a particular RFC or from a particular other
user?" (self-imposed preference).
The four never gate each other. A pending user with `role=owner`
(impossible by construction in v0.8.0 — fresh OTC always provisions
role=contributor — but the orthogonality holds at the column level)
would still refuse write endpoints because the admission gate
runs first; a granted contributor whose row is also muted refuses
writes via the mute gate; a granted contributor with notification
mutes set still passes the contributor gate and writes normally.
v0.6.0's anon-write audit (item #4) is the structural floor for all
four — every write-shaped endpoint funnels through
`auth.require_contributor`, which checks all three of {authenticated,
not muted, permission_state='granted'} in order.
### 6.3 Per-RFC delegated authority
An RFC's `owners:` and `arbiters:` (from the meta-repo entry's
@@ -1778,12 +1694,8 @@ 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
contract: anyone can see the conversation. Writes require contributor
role per §6.1: v0.5.0 implemented the gate on the three discussion
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
role per §6.1 (v0.5.0 implements the gate; v0.6.0 — item #4 — hardens
adjacent surfaces to match). The notification routing reuses the
existing `chat_message_in_participated_thread` /
`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
@@ -2054,48 +1966,11 @@ 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.
`/login` itself is a stepped surface, driven by which auth path the
viewer is currently on (§6):
1. **Email step.** The viewer enters their email. The frontend
consults `GET /auth/passcode/check?email=…` to learn whether
this email has a passcode set. The check endpoint is
account-enumeration-safe — it returns `has_passcode: false` for
both "unknown email" and "known email without passcode", so a
probing client cannot distinguish the two from the response.
2. **Either the passcode step or the OTC code step.** If the email
has a passcode set, the viewer is asked for it (v0.10.0).
Otherwise an OTC is dispatched and the viewer is asked for the
six-digit code from their email (v0.7.0).
3. **Optional post-OTC passcode-offer step.** After a successful
OTC verify on an account with no passcode set, the surface
asks "Set a passcode for faster sign-in next time?" — the user
can dismiss the offer or set one inline. The skip-for-now path
redirects straight to `/`.
The passcode step carries a "Use a code instead" link that
re-dispatches an OTC and switches to the code step — the same path
the lockout response (HTTP 423) takes automatically after five
consecutive failed passcode verifies.
This is the front door. It sets expectation before the user encounters
the mechanics, so the mechanics (super-drafts, graduation, public
arguments, AI participation in chat) read as load-bearing rather than
novel.
v0.8.0 (roadmap item #6) added a third sign-in step the surface
runs conditionally — on the first OTC sign-in by a previously
unknown email, the verify response carries `needs_profile=true`
and the surface prompts for first name, last name, and a free-text
"why I should be included in the beta" before bouncing the user to
`/beta-pending`. The page displays a "your request is in review"
message keyed on `users.permission_state='pending'` (repurposed
from the v0.3.0 post-OAuth-rejection surface). Anonymous viewers
and pending viewers see the same read surfaces; only the write
affordances differ. A persistent thin "Your beta access is in
review" banner shows on every page (other than `/beta-pending`
itself) until an admin grants access.
### 14.2 The `/philosophy` route
Authenticated and anonymous visitors alike can reach `/philosophy`,
@@ -2127,92 +2002,6 @@ The visual design of the landing page and the `/philosophy` route —
typography, layout, illustrations if any — is deferred. The structural
decisions above are the binding part.
### 14.5 Cookie / privacy consent (v0.13.0)
The framework ships a non-modal cookie consent banner reachable by
every viewer — authenticated and anonymous alike. The banner appears
at the bottom of the viewport on first load and stays visible until
the user makes a choice, after which it hides and the choice is
persisted. The `/settings/notifications` page carries a "Privacy &
cookies" tab that surfaces the current choice and re-opens the banner
on demand.
The choice has three categories, presented as a single-select:
- **Essential only** — the framework's strictly-necessary cookies
(sign-in session, signed payloads, the consent-choice record
itself). Always on; the user cannot switch this off because the
app cannot function without it.
- **Essential + analytics** — adds the optional analytics layer
gated by this choice. As of v0.13.0 no analytics SDK ships in the
framework; roadmap item #13 (v0.15.0) lands one behind this gate.
Off by default — the user has to opt in.
- **Essential + analytics + other** — adds third-party embeds or
social widgets a deployment may configure. The framework ships no
such cookies by default; this category exists so deployments that
add them have a categorized opt-in to wire them behind.
Storage shape:
- **Anonymous viewer** — choice persists in `localStorage` only
(`rfc-app.cookie-consent.v1`). The same browser carries the choice
forward; a different browser, or cleared storage, re-prompts.
- **Authenticated viewer** — choice persists in the `cookie_consent`
row keyed by `user_id`. On sign-in, the server row (if present)
overrides the local snapshot; if the server has no row, the local
choice is uploaded.
The `essential` flag is permanently true at the API surface. The
endpoint accepts it for symmetry but never persists a false value.
A deployment that wants strictly-necessary cookies to be optional
must change the framework contract, not flip a flag.
The framework exports a small JavaScript helper (`frontend/src/lib/
consent.js`) for downstream surfaces:
- `getConsent()` — current snapshot.
- `hasChosen()` — true once the user has made a choice.
- `onConsentChange(cb)` — subscribe to updates.
- `setConsent({analytics, other})` — record a new choice locally
(the banner / settings surface handles server persistence on top).
Roadmap item #13's analytics SDK (v0.15.0) will read from this helper:
read consent, then conditionally `import()` the SDK module. The gate
is wired before the SDK lands so the contract is already in place.
### 14.6 Privacy and cookies policy pages
The framework ships two policy routes:
- `/privacy` — a minimal default privacy policy that describes the
framework's stance (what is stored, why, how to revoke consent,
how to reach the deployment operator). The page is reachable by
anonymous and authenticated viewers alike.
- `/cookies` — the framework's cookies policy, listing exactly which
cookies the framework sets, by category. Self-documenting: a future
framework release that adds or removes a cookie updates this page
as part of the change.
Each page links to the other and to the §14.5 banner. The consent
banner links to both.
Deployments override the policy content via two optional build-time
env vars documented in `frontend/.env.example`:
- `VITE_PRIVACY_POLICY_URL` — an http(s) URL the `/privacy` page
links to as the "full deployment policy". The framework's stub
always renders above the link so the framework-level contract is
always visible; the link layers deployment-specific content on
top.
- `VITE_COOKIES_POLICY_URL` — same shape for `/cookies`.
Both are optional. Unset is the supported default; the stub pages are
sufficient for a default-config deployment that has nothing
deployment-specific to add. The framework chose the env-var path over
a content-repo file because it composes with the existing build-time
config layer; the content-repo-file alternative is the §19.2
candidate.
---
## 15. Notifications
@@ -2732,87 +2521,25 @@ The follow-up session will refine this. A minimal starting set:
- `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.
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. v0.7.0 also dropped requests
silently if the email wasn't on the `allowed_emails` list (the
v0.3.0 admission gate); v0.8.0 (item #6) removed that check —
admission moved to `permission_state` on the freshly-provisioned
`users` row, asserted at the contributor gate. v0.12.0 (item #10)
gates this endpoint behind a CloudFlare Turnstile siteverify call:
the body carries an optional `turnstile_token` field, the server
POSTs `secret` + `response` to `challenges.cloudflare.com/turnstile/
v0/siteverify` before the bcrypt hash + SMTP send, and a failed
challenge refuses with HTTP 400 spending no rate budget. Two env
vars drive the policy: `CLOUDFLARE_TURNSTILE_SECRET` (Secret
Manager) and `TURNSTILE_REQUIRED` (overlay, default `false`). When
the secret is unset and `TURNSTILE_REQUIRED=false`, the gate is
open (the dev / pre-rollout path); when the secret is unset and
`TURNSTILE_REQUIRED=true`, the endpoint refuses with HTTP 500
"auth misconfigured" so a future config drift fails loudly
instead of silently disabling abuse defense.
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
`permission_state='pending'`), and stores the session cookie.
Returns HTTP 200 on success; the response body carries
`{ok, user, needs_profile}` where `needs_profile=true` iff the
user is `permission_state='pending'` AND the row has no
first_name / last_name / beta_request_reason yet (a fresh OTC
sign-in). The `needs_profile` flag drives the Login.jsx surface's
step-3 capture form. 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.
- `POST /api/auth/me/beta-request` — authenticated. Body carries
`first_name`, `last_name`, `beta_request_reason` (all required;
bounded at 120 / 120 / 4000 chars). Writes the fields to the
signed-in user's row and leaves `permission_state='pending'`.
Idempotent for the same already-pending user (a re-submit
updates the row so the admin sees the latest text). Refuses
with HTTP 409 if the user is already `'granted'` or `'revoked'`.
v0.8.0 — the first-OTC profile-capture endpoint (roadmap item
#6). v0.9.0's admin user-management page consumes this column
set to render the request queue.
- `GET /auth/passcode/check` — unauthenticated. Query param `email`.
Returns `{has_passcode: boolean}`. The frontend's `/login` surface
calls this after the email step to decide whether to render a
passcode input or fall back to OTC. The response carries only the
boolean; lockout state, the bcrypt hash, and the `passcode_set_at`
stamp are not leaked. An unknown email and a known-without-passcode
email both return `false`, so the endpoint is enumeration-safe.
- `POST /auth/passcode/set` — authenticated (any role). Body carries
`passcode` (420 characters). bcrypt-hashes the passcode, writes
`users.passcode_hash` + `users.passcode_set_at`, clears the failure
counter and any active lockout. Refuses obvious patterns (a small
denylist: `0000`, `1234`, `aaaa`, `password`, etc.) and length
violations with HTTP 422. Replaces any prior passcode. v0.10.0.
- `DELETE /auth/passcode` — authenticated. Clears
`users.passcode_hash` and `users.passcode_set_at`, returning the
user to OTC-only on next sign-in. v0.10.0.
- `POST /auth/passcode/verify` — unauthenticated. Body carries
`email` and `passcode`. Locates the user, checks the lockout
window, and compares via bcrypt. On success: clears the failure
counter, refreshes `last_seen_at`, stores the session cookie,
returns HTTP 200 with the minimal user payload. On failure:
increments `passcode_failed_attempts`. After five consecutive
failures, stamps `passcode_locked_until = now + 15 minutes` and
returns HTTP 423 with a `locked_until` field; subsequent attempts
inside the window are refused with the same shape. After the
window expires, the next attempt clears the counter and proceeds
normally. The OTC path (§17 above) is unaffected by the passcode
lockout — a locked-out user can still request and verify a fresh
OTC. The wrong-passcode and unknown-email failure modes both
return HTTP 400 with a generic message; the no-passcode-set
failure also collapses to 400 so the response does not enumerate
account state. v0.10.0.
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,
owners, last_active_at, has_open_prs, starred-by-me. Supports
search, sort, filter chips, and the `unclaimed` predicate.
@@ -2868,13 +2595,7 @@ The follow-up session will refine this. A minimal starting set:
trailing `rollback` step's events if any earlier step fails. The
Graduate dialog opens this stream on confirm and renders the step
stack from the events. The stream closes on success or on
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.
rollback completion.
- `GET /api/rfcs/<slug>/blocking-prs` — list open meta-repo PRs
against `rfcs/<slug>.md` per §13.2's precondition popover. Returns
PR number, title, author, last-activity timestamp, and the
@@ -3046,16 +2767,6 @@ The follow-up session will refine this. A minimal starting set:
short confirmation page.
- `POST /api/webhooks/email-bounce` — bounce and complaint receiver
per §15.4; sets the recipient's global email opt-out.
- `GET /api/users/me/cookie-consent` — read the signed-in user's
cookie consent record per §14.5. Returns `{essential, analytics,
other, recorded_at}`. `recorded_at: null` means "no choice yet"
and the banner should be shown; the framework treats absence of a
row as equivalent to that. `essential` is permanently true.
- `PUT /api/users/me/cookie-consent` — write the signed-in user's
cookie consent record per §14.5. Body: `{essential, analytics,
other}`. The `essential` flag is accepted for symmetry but always
persisted as true. Upserts (a single row per user) and stamps
`recorded_at` to now.
Plus all the chat / streaming / model-picker endpoints, scoped to
per-RFC and per-branch threads.
@@ -3788,72 +3499,23 @@ the new §15 (Notifications, in full), and §17 (the notification
endpoints — list, mark-read, stream, watch mutation, preferences,
quiet-hours, per-user mute, unsubscribe, bounce webhook).
First-OTC profile capture (formerly a v0.7.0 candidate) is settled
and folded into §6.1 (the contributor role now requires
`permission_state='granted'`), §6.2 (the orthogonality of
permission_state vs role / muted / notification-mutes), §14.1
(the landing page's v0.8.0 first-OTC capture step), and §17
(the `POST /api/auth/me/beta-request` endpoint and the verify
endpoint's new `needs_profile` flag). The structural decision
landed as: capture is a third step on the `/login` surface
gated by `verify_response.needs_profile=true`; pending users
land on `/beta-pending` after submitting and see a thin banner
on every other page until an admin grants. v0.8.0 (roadmap item
#6) shipped the work.
Candidates surfaced during v0.8.0 (open beta-access request flow,
§6.1 / §14.1, item #6):
- **Admin user-management page** (`/admin/users`). *Surfaced by
v0.8.0 — the release ships the pending-state column but no
admin UI to triage it.* For v0.8.0, the admin gesture is an
out-of-band `UPDATE users SET permission_state='granted' WHERE
email=?`. v0.9.0 (roadmap item #7) is expected to ship the
triage queue: a list of `permission_state='pending'` rows
sorted by `created_at`, each showing the captured first /
last / why fields, with Grant and Revoke buttons that stamp
`permission_decided_by` and `permission_decided_at` (schema
slots already in place per `migrations/014_beta_access.sql`).
The page composes naturally with the existing `/admin/allowlist`
surface — both are admission-control gestures — so v0.9.0 may
fold the allowlist UI into this page (see next candidate).
Decision points: do grants / revokes also fire email
notifications to the user (probably yes — the notifications
layer from v0.6.0 has the personal-direct channel for it); is
there a "decline with reason" gesture that surfaces in the
user's view (probably yes — symmetric with §9.3's
proposal-decline shape); does the page support bulk grants
(probably no for v0.9.0 — the queue volume is operator-scale,
not user-scale). Earns its session as the v0.9.0 design pass.
- **Allowlist deprecation.** *Surfaced by v0.8.0 — the
`allowed_emails` table stays in the schema but the OTC
request path no longer consults it.* v0.8.0 left the table
and the `/admin/allowlist` UI in place as a fast-path bypass
for deployments that want to pre-mark known-good emails (the
v0.8.0 contract is that those emails still go through the
pending-grant flow; the table itself is no longer a gate). A
future release retires both — probably v0.9.0 alongside the
admin user-management page, since the two surfaces are
functionally redundant once the pending queue lands.
Decision points: drop the table outright (a schema migration)
or leave it as a non-functional surface and remove only the
UI (a frontend-only change); how to handle existing
`allowed_emails` rows at the cutover (probably: walk them
into the pending queue with `permission_state='granted'` for
any matching `users` row, leave unmatched rows as a no-op
since v0.8.0 doesn't consult them anymore). Earns its
session as a sub-topic of the v0.9.0 admin user-management
pass.
- **Admin notification on new beta request.** *Surfaced by
v0.8.0 — the capture endpoint writes to the row but does
not signal admins.* v0.9.0 candidate (item #7 again): when a
`POST /api/auth/me/beta-request` lands, fire an `admin-actionable`
notification (per §15.4's category set) to every owner / admin
so the queue doesn't go stale. The §15 infrastructure already
supports the category; the open question is whether the
notification is per-request (one email per submission) or
digested (a daily summary). Earns its session alongside the
admin user-management page.
- **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`
@@ -3870,117 +3532,34 @@ Candidates surfaced during v0.8.0 (open beta-access request flow,
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 or passcode.* 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 both the OTC and
the passcode 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
— v0.10.0's passcode-change and passcode-clear gestures are the
v1 instances — or only survives explicit logout. Earns its
session as the v0.11.0 design pass.
- **Cloudflare Turnstile on `/auth/otc/request`.** *Settled in
v0.12.0 (roadmap item #10).* The OTC request endpoint is now
gated behind a Turnstile siteverify call: the frontend renders
the official widget on the `/login` email-entry step (and on the
passcode step for the "Use a code instead" fallback dispatch),
the captured token rides in the request body as
`turnstile_token`, and the backend POSTs `secret` + `response`
to `challenges.cloudflare.com/turnstile/v0/siteverify` before
the bcrypt hash + SMTP send. The widget renders only on the
email-entry / passcode-fallback dispatch points — the OTC
verify step is bottlenecked on email delivery and protected by
the five-minute TTL + single-use row consume, so a second
challenge there would double the rate budget against the same
abuse path without measurably more protection (revisit if bots
adapt to the email-entry challenge specifically). Configured
via `CLOUDFLARE_TURNSTILE_SECRET` (Secret Manager) and
`VITE_TURNSTILE_SITE_KEY` (frontend build-time overlay); a
third var `TURNSTILE_REQUIRED` (default `false`) lets the
operator flip from "soft-fail when secret unset" (the dev /
pre-rollout shape) to "fail-closed when secret unset" (HTTP 500
"auth misconfigured", the production-locked shape). Tests mock
the siteverify HTTP call at the `httpx.post` boundary in
`app.turnstile`. The hCaptcha / reCAPTCHA alternatives noted in
the v0.7.0 surfacing are still viable substitutes for a future
deployment that wants them but the framework's tested path is
Turnstile.
Candidates surfaced during v0.10.0 (user-set passcodes, §6.2 /
roadmap item #8):
- **Passcode policy tunables via env.** v0.10.0 hard-codes the
lockout shape (5 consecutive failures → 15-minute lockout) and
the min/max passcode length (4 / 20) in
`backend/app/passcode.py`. The denylist of obvious patterns is
also hard-coded. A deployment that wants tighter or looser rules
has to fork the constants. Two env vars
(`PASSCODE_LOCKOUT_AFTER_ATTEMPTS`,
`PASSCODE_LOCKOUT_DURATION_MINUTES`) would let operators
reshape the lockout without forking; a third
(`PASSCODE_MIN_LENGTH`) would cover the length floor. Earns its
session if a deployment surfaces evidence that the v1 defaults
bite.
- **Per-IP rate-limiting on `/auth/passcode/verify`.** v0.10.0's
lockout is per-account: 5 failures against the same email lock
that account for 15 minutes. A distributed attacker that knows
many emails can fan out across them without ever tripping any
one account's lockout. Adding a per-IP throttle (e.g., 30
passcode-verify attempts / minute / IP, returning HTTP 429) is
the natural pairing. Defer-able — the per-account lockout is
the v1 shape that closes the loud-loop case; the per-IP
distributed case waits on evidence. Touches §6.2 and §17.
- **Passcode-change "still know your old passcode" challenge.**
v0.10.0 lets a signed-in user replace their passcode from
`/settings/notifications` without re-entering the old one — the
session is sufficient. A future hardening pass may require the
old passcode (or a fresh OTC verify) before accepting the
change, to mitigate session-hijack scenarios where the attacker
rotates the passcode to lock the legitimate owner out. The same
question applies to the clear gesture. Earns its session if
session-hijack becomes a real threat surface.
- **Passkey / WebAuthn.** A much heavier next step than passcodes:
hardware-backed device credentials that resist phishing. The
v0.10.0 passcode shape is a stopgap for the "I'd rather not
type a code every time" ergonomic problem; passkeys are the
long-term answer. Out of scope for the current roadmap —
earns a dedicated session if/when the deployment grows enough
that the phishing surface justifies the integration cost.
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.
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.
### 19.3 Working agreement for the queue
+1 -1
View File
@@ -1 +1 @@
0.12.0
0.7.0
-18
View File
@@ -92,21 +92,3 @@ OTC_TTL_MINUTES=10
# 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
# --- v0.12.0: CloudFlare Turnstile gate on OTC dispatch (§6.2, item #10) ---
# Provision a Turnstile site at dash.cloudflare.com → Turnstile → Add
# site. The site key (public) goes in `frontend/.env` as
# VITE_TURNSTILE_SITE_KEY. The secret key (private) goes here and is
# what the backend POSTs to /siteverify alongside the user's response
# token. Leave both unset for dev/test paths; the gate stays open when
# the secret is absent AND TURNSTILE_REQUIRED=false (the default).
CLOUDFLARE_TURNSTILE_SECRET=
# When `true`, /auth/otc/request fails closed (HTTP 500 "auth
# misconfigured") if CLOUDFLARE_TURNSTILE_SECRET is unset. When `false`
# (the default), a missing secret skips verification — useful in dev
# and during the pre-rollout window when the operator hasn't wired
# the secret yet. Flip to `true` once the secret is wired so a future
# config drift surfaces as a loud 500 rather than a silent abuse-
# defense disablement.
TURNSTILE_REQUIRED=false
-107
View File
@@ -26,7 +26,6 @@ from . import (
api_prs,
auth,
db,
docs as docs_mod,
entry as entry_mod,
cache,
funder,
@@ -56,17 +55,6 @@ class FunderCredentialBody(BaseModel):
api_key: str = Field(min_length=1, max_length=2048)
class BetaRequestBody(BaseModel):
# v0.8.0 — captured on the first OTC sign-in. All three fields are
# required so the admin queue has a coherent triage shape.
# The bounds match the v0.7.0 OTC body (320 chars for email-ish
# headers; 4000 for the free-text reason — the same upper bound
# DeclineBody uses elsewhere in this file).
first_name: str = Field(min_length=1, max_length=120)
last_name: str = Field(min_length=1, max_length=120)
beta_request_reason: str = Field(min_length=1, max_length=4000)
def make_router(
config: Config,
gitea: Gitea,
@@ -123,17 +111,6 @@ def make_router(
payload = philosophy.load()
return {"body": payload["body"]}
# ---------------------------------------------------------------
# /api/docs — DOCS.md served verbatim. Sibling of /api/philosophy:
# no auth gate, same disk-first load + cache shape, same intent —
# public read surface for a markdown file checked into the repo.
# ---------------------------------------------------------------
@router.get("/api/docs")
async def get_docs() -> dict[str, Any]:
payload = docs_mod.load()
return {"body": payload["body"]}
# ---------------------------------------------------------------
# Auth surface — reads role from our users table per §6.
# ---------------------------------------------------------------
@@ -143,29 +120,6 @@ def make_router(
user = auth.current_user(request)
if user is None:
return {"authenticated": False, "user": None}
# v0.8.0 + v0.10.0: single round-trip for everything the
# frontend gates UI off of — beta-access state + passcode state.
row = db.conn().execute(
"SELECT first_name, last_name, beta_request_reason, "
"passcode_hash, passcode_set_at "
"FROM users WHERE id = ?",
(user.user_id,),
).fetchone()
first_name = (row["first_name"] if row else None) or ""
last_name = (row["last_name"] if row else None) or ""
beta_request_reason = (row["beta_request_reason"] if row else None) or ""
# "Needs profile" iff the user is pending AND hasn't yet
# filed their beta-request capture. Granted users never see
# the capture prompt; pending users who already filed see
# the /beta-pending page without the capture form.
needs_profile = (
user.permission_state == "pending"
and not first_name
and not last_name
and not beta_request_reason
)
has_passcode = bool(row and row["passcode_hash"])
passcode_set_at = row["passcode_set_at"] if (row and has_passcode) else None
return {
"authenticated": True,
"user": {
@@ -175,70 +129,9 @@ def make_router(
"email": user.email,
"avatar_url": user.avatar_url,
"role": user.role,
"permission_state": user.permission_state,
"first_name": first_name,
"last_name": last_name,
"beta_request_reason": beta_request_reason,
"needs_profile": needs_profile,
"has_passcode": has_passcode,
"passcode_set_at": passcode_set_at,
},
}
# ---------------------------------------------------------------
# v0.8.0: /api/auth/me/beta-request — first-OTC profile capture
# (roadmap item #6). Lands first name, last name, and the free-
# text "why I should be included in the beta" on the signed-in
# user's row. Idempotent for the same already-pending user;
# refuses to overwrite a row that's already granted (so a
# bored already-granted user can't accidentally re-submit the
# form and clobber the admin's audit trail). Uses
# `require_user` rather than `require_contributor` because
# `require_contributor` already enforces `permission_state =
# 'granted'` and would refuse a pending user; the whole point
# of this endpoint is to register the request _from_ a pending
# user.
# ---------------------------------------------------------------
@router.post("/api/auth/me/beta-request")
async def submit_beta_request(body: BetaRequestBody, request: Request) -> dict[str, Any]:
user = auth.require_user(request)
row = db.conn().execute(
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE id = ?",
(user.user_id,),
).fetchone()
if row is None:
# Defensive — the session pointed at a deleted row.
raise HTTPException(404, "User not found")
# Granted users have no business filing a beta request.
# 'revoked' likewise — the request flow is for fresh users
# only. Both shapes refuse with 409 (conflict) so the client
# can distinguish "you already have access" from
# "your access was revoked".
if row["permission_state"] == "granted":
raise HTTPException(409, "Your account is already granted access")
if row["permission_state"] == "revoked":
raise HTTPException(409, "Your account's access has been revoked")
# Re-submission from a pending user updates the row — the
# admin sees the latest text rather than a stale draft.
# The state stays 'pending'; only an admin can flip it.
db.conn().execute(
"""
UPDATE users
SET first_name = ?,
last_name = ?,
beta_request_reason = ?
WHERE id = ?
""",
(
body.first_name.strip(),
body.last_name.strip(),
body.beta_request_reason.strip(),
user.user_id,
),
)
return {"ok": True}
# ---------------------------------------------------------------
# §7: the catalog
# ---------------------------------------------------------------
+1 -10
View File
@@ -520,16 +520,7 @@ def make_router(
@router.get("/api/rfcs/{slug}/graduate/progress")
async def graduate_progress(slug: str, request: 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)
del request
state = _get_active(slug)
if state is None:
raise HTTPException(404, "No graduation in flight for this slug")
-79
View File
@@ -14,8 +14,6 @@ The endpoints in this module are:
- `POST /api/users/me/quiet-hours` set / clear
- `POST /api/users/<id>/notification-mute` §15.8
- `DELETE /api/users/<id>/notification-mute` §15.8
- `GET /api/users/me/cookie-consent` §14.5
- `PUT /api/users/me/cookie-consent` §14.5
- `GET /api/email/unsubscribe` §15.4 one-click
- `POST /api/webhooks/email-bounce` §15.4 receiver
@@ -75,15 +73,6 @@ class BounceBody(BaseModel):
kind: str = Field(default="hard") # 'hard' or 'complaint'
class CookieConsentBody(BaseModel):
# `essential` is always true at the surface; we accept it for symmetry
# but never persist a false value (the framework's strictly-necessary
# cookies are not user-optional per SPEC §14.5).
essential: bool = True
analytics: bool = False
other: bool = False
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
@@ -373,74 +362,6 @@ def make_router(config: Config) -> APIRouter:
)
return {"ok": True}
# ----- Cookie consent (v0.13.0 / roadmap item #11; SPEC §14.5) -----
#
# The shape is intentionally small: three flags + a recorded-at stamp.
# The banner's local-vs-server precedence rule lives in the frontend
# (`consent.js`): on sign-in, the server row (if any) overrides local;
# otherwise local is uploaded.
@router.get("/api/users/me/cookie-consent")
async def get_cookie_consent(request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
row = db.conn().execute(
"""
SELECT essential, analytics, other_cookies, recorded_at
FROM cookie_consent WHERE user_id = ?
""",
(viewer.user_id,),
).fetchone()
if row is None:
return {
"essential": True,
"analytics": False,
"other": False,
"recorded_at": None,
}
return {
"essential": bool(row["essential"]),
"analytics": bool(row["analytics"]),
"other": bool(row["other_cookies"]),
"recorded_at": row["recorded_at"],
}
@router.put("/api/users/me/cookie-consent")
async def set_cookie_consent(body: CookieConsentBody, request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
# `essential` is the framework's strictly-necessary set; the
# surface accepts the flag for symmetry but never persists a
# false value. SPEC §14.5: a deployment that wants to make
# session-cookie storage optional must change the framework
# contract, not flip a flag here.
db.conn().execute(
"""
INSERT INTO cookie_consent
(user_id, essential, analytics, other_cookies, recorded_at)
VALUES (?, 1, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
essential = 1,
analytics = excluded.analytics,
other_cookies = excluded.other_cookies,
recorded_at = excluded.recorded_at
""",
(
viewer.user_id,
1 if body.analytics else 0,
1 if body.other else 0,
),
)
row = db.conn().execute(
"SELECT recorded_at FROM cookie_consent WHERE user_id = ?",
(viewer.user_id,),
).fetchone()
return {
"ok": True,
"essential": True,
"analytics": bool(body.analytics),
"other": bool(body.other),
"recorded_at": row["recorded_at"] if row else None,
}
# ----- Email: one-click unsubscribe + bounce webhook -----
@router.get("/api/email/unsubscribe")
+4 -60
View File
@@ -30,12 +30,6 @@ class SessionUser:
email: str
avatar_url: str
role: str
# v0.8.0 / §6.1 — admission gate. Three states: 'pending' (waiting
# for an admin grant), 'granted' (active contributor), 'revoked'
# (was granted, later removed). Existing rows at migration time
# default to 'granted' so grandfathered users are unaffected; OTC
# provisions fresh users with 'pending' (see `app/otc.py`).
permission_state: str = "granted"
def as_actor(self) -> Actor:
return Actor(
@@ -96,13 +90,6 @@ def allowlist_is_active() -> bool:
def is_allowed_sign_in(profile: dict[str, Any]) -> bool:
"""Decide whether a freshly-completed OAuth profile may sign in.
v0.8.0 (item #6) replaces the allowlist gate with an admin-grant
flow at the OTC `/request` surface, but the Gitea OAuth callback
in `main.py` still consults this helper so the fallback path
keeps the v0.3.0 admission shape during the OAuth migration
window. The eventual removal of the OAuth callback (§19.2)
retires this function alongside it.
Three accept paths:
1. The allowlist is empty (gate off).
2. The Gitea profile's email is in `allowed_emails` (case-insensitive).
@@ -145,27 +132,17 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
existing = c.execute("SELECT * FROM users WHERE gitea_id = ?", (gitea_id,)).fetchone()
if existing is None:
role = "owner" if config.owner_gitea_login and login == config.owner_gitea_login else "contributor"
# v0.8.0: a fresh OAuth-provisioned user is also subject to
# the admin-grant flow. The OAuth fallback only fires for
# users who pass `is_allowed_sign_in` (so they're already on
# the legacy allowlist or are grandfathered by gitea_id);
# 'granted' is the right default here since the allowlist
# check is itself the admin gesture. A future release that
# retires the OAuth callback (§19.2) collapses both paths
# under the same gate.
cur = c.execute(
"""
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
VALUES (?, ?, ?, ?, ?, ?, 'granted')
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role)
VALUES (?, ?, ?, ?, ?, ?)
""",
(gitea_id, login, email, display, avatar, role),
)
user_id = cur.lastrowid
permission_state = "granted"
else:
user_id = existing["id"]
role = existing["role"]
permission_state = existing["permission_state"] or "granted"
c.execute(
"""
UPDATE users
@@ -183,7 +160,6 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
email=email,
avatar_url=avatar,
role=role,
permission_state=permission_state,
)
@@ -202,12 +178,6 @@ def store_session(request: Request, user: SessionUser) -> None:
"email": user.email,
"avatar_url": user.avatar_url,
"role": user.role,
# v0.8.0: persist the admission state on the cookie payload so
# the post-cookie audit doesn't second-guess the row. The DB
# is re-read on every `current_user` call regardless (so an
# admin grant takes effect on the next request); this field
# is purely structural redundancy for the cookie shape.
"permission_state": user.permission_state,
}
@@ -218,7 +188,7 @@ def current_user(request: Request) -> SessionUser | None:
# Re-read the role from the database every request so role changes
# take effect on the next API call without forcing a logout.
row = db.conn().execute(
"SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state FROM users WHERE id = ?",
"SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role FROM users WHERE id = ?",
(raw["user_id"],),
).fetchone()
if row is None:
@@ -229,11 +199,6 @@ def current_user(request: Request) -> SessionUser | None:
# 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.
# v0.8.0: permission_state comes off the row directly. A NULL
# column value (shouldn't happen under the migration's
# NOT NULL DEFAULT, but be defensive) reads as 'granted' so the
# gate fails open for grandfathered surfaces rather than locking
# everyone out on a malformed row.
return SessionUser(
user_id=row["id"],
gitea_id=row["gitea_id"] or 0,
@@ -242,7 +207,6 @@ def current_user(request: Request) -> SessionUser | None:
email=row["email"] or "",
avatar_url=row["avatar_url"] or "",
role=row["role"],
permission_state=row["permission_state"] or "granted",
)
@@ -254,31 +218,11 @@ def require_user(request: Request) -> SessionUser:
def require_contributor(request: Request) -> SessionUser:
"""§6.1: authenticated, not write-muted, and granted by an admin.
v0.8.0 (item #6) widens this gate. A fresh OTC sign-in lands in
`permission_state='pending'`; the user can read everything an
anonymous viewer can read, but every write-shaped endpoint that
funnels through this dependency now refuses with 403 until an
admin grants them. The `pending` blast radius is the same as
anonymous (item #4 / v0.6.0 already audited the anon-write
refusal at every write site), so this widening is structurally
a relabel the same surfaces that already refused 401 to
anonymous now also refuse 403 to pending.
"""
"""§6.1: authenticated, not write-muted."""
user = require_user(request)
row = db.conn().execute("SELECT muted FROM users WHERE id = ?", (user.user_id,)).fetchone()
if row and row["muted"]:
raise HTTPException(status_code=403, detail="Your account is muted")
if user.permission_state != "granted":
# 'pending' is the post-OTC waiting state; 'revoked' is the
# admin-undid-the-grant state. Both refuse with the same 403
# shape; the client distinguishes via `/api/auth/me` which
# carries `permission_state` in the response.
raise HTTPException(
status_code=403,
detail="Your beta access request is in review",
)
return user
-61
View File
@@ -1,61 +0,0 @@
"""User-facing docs source.
Mirrors `philosophy.py` shape. Serves `DOCS.md` from the repo root
the framework's plain-prose user guide to roles, contribution flow,
and notification surfaces, distinct from the binding `SPEC.md`. Read
from disk on first call and cached in-process; the periodic
reconciler can call `refresh()` to pick up out-of-band edits.
`DOCS_PATH` overrides the default location if a deployment hosts the
file elsewhere (a meta-repo working-tree clone, a sync target, etc.).
"""
from __future__ import annotations
import logging
import os
import threading
from pathlib import Path
log = logging.getLogger(__name__)
_DEFAULT_PATH = Path(__file__).resolve().parents[2] / "DOCS.md"
_lock = threading.Lock()
_cache: dict | None = None
def _resolved_path() -> Path:
override = os.environ.get("DOCS_PATH", "").strip()
if override:
return Path(override).expanduser().resolve()
return _DEFAULT_PATH
def load(force: bool = False) -> dict:
"""Return the cached `{body, path, mtime}` payload, reading from disk
on first call or when `force=True`.
"""
global _cache
with _lock:
if _cache is not None and not force:
return _cache
path = _resolved_path()
try:
text = path.read_text(encoding="utf-8")
mtime = path.stat().st_mtime
except FileNotFoundError:
log.warning("DOCS.md not found at %s — serving placeholder", path)
text = (
"# DOCS.md not found\n\n"
"The deployment is missing its user guide. Set "
"DOCS_PATH or place DOCS.md at the project root."
)
mtime = 0.0
_cache = {"body": text, "path": str(path), "mtime": mtime}
return _cache
def refresh() -> dict:
"""Force-reread from disk. Returns the new payload."""
return load(force=True)
+1 -130
View File
@@ -24,9 +24,7 @@ from . import (
email_otc,
hygiene,
otc,
passcode as passcode_mod,
providers as providers_mod,
turnstile,
webhooks,
)
from .bot import Bot
@@ -39,14 +37,6 @@ log = logging.getLogger("rfc_app")
class OtcRequestBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
# v0.12.0 / roadmap item #10: CloudFlare Turnstile token from the
# frontend widget. Optional in the body so a deployment that has
# not yet wired the Turnstile site key (or a dev environment with
# the widget intentionally skipped) still routes through the same
# endpoint; the backend turnstile.verify_token call decides whether
# to admit the request based on `TURNSTILE_REQUIRED` + presence of
# the secret.
turnstile_token: str | None = Field(default=None, max_length=4096)
class OtcVerifyBody(BaseModel):
@@ -54,15 +44,6 @@ class OtcVerifyBody(BaseModel):
code: str = Field(min_length=1, max_length=16)
class PasscodeSetBody(BaseModel):
passcode: str = Field(min_length=1, max_length=64)
class PasscodeVerifyBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
passcode: str = Field(min_length=1, max_length=64)
@asynccontextmanager
async def lifespan(app: FastAPI):
config = load_config()
@@ -173,27 +154,7 @@ def _oauth_router(config) -> APIRouter:
# ---------------------------------------------------------------
@router.post("/auth/otc/request")
async def otc_request(body: OtcRequestBody, request: Request):
# v0.12.0 / roadmap item #10: gate the request on a successful
# Turnstile siteverify before the bcrypt hash + SMTP send. The
# check runs first so a failed challenge spends no rate budget
# and produces no envelope. When the operator has not wired the
# secret AND TURNSTILE_REQUIRED=false (the default), the gate
# opens — see `backend/app/turnstile.py` for the full matrix.
client_ip = request.client.host if request.client else None
ts = turnstile.verify_token(body.turnstile_token, client_ip=client_ip)
if not ts.ok:
if ts.reason == "misconfigured":
# TURNSTILE_REQUIRED=true but the secret is unset. This
# is an operator/config problem, not a client problem;
# surface as 500 so the operator notices in their logs
# rather than blaming the user's browser.
raise HTTPException(500, "auth misconfigured")
# missing-token / failed / network → uniform 400 so the
# response does not enumerate which leg of the challenge
# broke. The reason is in the server logs.
raise HTTPException(400, "verification failed")
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
@@ -211,96 +172,6 @@ def _oauth_router(config) -> APIRouter:
if not result.ok or result.user is None:
raise HTTPException(400, "Invalid or expired code")
auth.store_session(request, result.user)
# v0.8.0: surface `needs_profile` so the Login.jsx surface can
# decide whether to advance to the first/last/why capture step
# or jump straight to "/". `needs_profile=true` iff the user
# is `permission_state='pending'` AND the row has no profile
# fields yet — a fresh OTC user. Grandfathered users
# (`permission_state='granted'`) and pending users who already
# captured their fields both read as false.
row = db.conn().execute(
"SELECT first_name, last_name, beta_request_reason FROM users WHERE id = ?",
(result.user.user_id,),
).fetchone()
first_name = (row["first_name"] if row else None) or ""
last_name = (row["last_name"] if row else None) or ""
beta_request_reason = (row["beta_request_reason"] if row else None) or ""
needs_profile = (
result.user.permission_state == "pending"
and not first_name
and not last_name
and not beta_request_reason
)
return {
"ok": True,
"user": {
"id": result.user.user_id,
"display_name": result.user.display_name,
"email": result.user.email,
"role": result.user.role,
"permission_state": result.user.permission_state,
},
"needs_profile": needs_profile,
}
# ---------------------------------------------------------------
# v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8).
#
# After a successful OTC sign-in, a contributor may set a passcode
# and use email + passcode for subsequent sign-ins. OTC remains the
# forgot-passcode fallback — a verify failure beyond 5 consecutive
# attempts locks the passcode path for 15 minutes; the OTC path is
# unaffected by the lockout.
# ---------------------------------------------------------------
@router.get("/auth/passcode/check")
async def passcode_check(email: str = ""):
"""Does this email have a passcode set? Anonymous endpoint —
the Login.jsx flow calls this after the user types their email
to decide whether to render a passcode input or fall back to
OTC. We surface only the boolean; lockout state, the hash, and
the set-at stamp are not leaked here."""
status = passcode_mod.passcode_status(email)
return {"has_passcode": status.has_passcode}
@router.post("/auth/passcode/set")
async def passcode_set(body: PasscodeSetBody, request: Request):
"""Set or replace the signed-in user's passcode. Requires an
active session (OTC- or passcode-authenticated)."""
user = auth.require_user(request)
try:
passcode_mod.set_passcode(user.user_id, body.passcode)
except passcode_mod.PasscodeValidationError as e:
raise HTTPException(422, str(e))
return {"ok": True}
@router.delete("/auth/passcode")
async def passcode_delete(request: Request):
"""Remove the signed-in user's passcode. The user is back to
OTC-only on next sign-in."""
user = auth.require_user(request)
passcode_mod.clear_passcode(user.user_id)
return {"ok": True}
@router.post("/auth/passcode/verify")
async def passcode_verify(body: PasscodeVerifyBody, request: Request):
"""Sign in with email + passcode. Returns the standard session
payload on success; HTTP 423 with `locked_until` when the
account is in the lockout window; HTTP 400 for every other
failure (the wrong-vs-unknown distinction is intentionally
collapsed so a probing client cannot enumerate emails)."""
result = passcode_mod.verify_passcode(body.email, body.passcode)
if result.reason == "locked":
raise HTTPException(
423,
{
"detail": "Too many failed attempts; sign in with a one-time code instead",
"locked_until": result.locked_until,
},
)
if not result.ok or result.user is None:
raise HTTPException(400, "Invalid passcode")
auth.store_session(request, result.user)
return {
"ok": True,
"user": {
+45 -52
View File
@@ -1,4 +1,4 @@
"""§6.2 / v0.7.0 / v0.8.0: email + one-time-code sign-in.
"""§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
@@ -25,25 +25,14 @@ The shape:
`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`,
and `permission_state = 'pending'` (v0.8.0 see below).
row is provisioned with `gitea_id = NULL`, `gitea_login = NULL`.
The endpoints in `main.py` thin-wrap this module.
v0.8.0 (roadmap item #6) replaces the v0.3.0 `allowed_emails` gate at
the request surface. The request handler used to silently drop OTC
requests for emails not on the allowlist; now any valid email
receives a code. The admission gate moves to `permission_state` on
the freshly-provisioned `users` row: a fresh user lands in 'pending'
and waits for an admin grant before write endpoints accept them.
Read surfaces stay open (the same blast radius v0.6.0 / item #4
already audited for anonymous viewers).
The `allowed_emails` table itself stays in the schema as a
fast-path bypass the admin UI from v0.3.0 continues to manage it,
and a future release (v0.9.0's admin user-management page) collapses
the two admission surfaces into one. The OTC request path no
longer consults the table.
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
@@ -55,7 +44,7 @@ from dataclasses import dataclass
import bcrypt
from . import db
from .auth import SessionUser
from .auth import SessionUser, allowlist_is_active
log = logging.getLogger(__name__)
@@ -112,15 +101,25 @@ def _check_code(code: str, code_hash: str) -> bool:
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
#
# v0.8.0: the allowlist gate from v0.7.0 / v0.3.0 is removed here. Any
# valid email receives a code; the admission gate moved to
# `permission_state` on the freshly-provisioned `users` row (see
# `provision_or_link_user`). The `allowed_emails` table stays in the
# schema (admin UI from v0.3.0 still manages it); v0.9.0's admin
# user-management page will collapse the two surfaces.
# ---------------------------------------------------------------------------
@@ -128,16 +127,14 @@ def _check_code(code: str, code_hash: str) -> bool:
class RequestOutcome:
"""The outcome of a `request_code` call.
`code` is None whenever no code was generated the cooldown
window blocked the request or the email was syntactically
invalid. The caller (the API endpoint) does not surface the
invalid-email shape to the user; it returns 202 either way.
The cooldown shape surfaces as a loud 429 per the v0.7.0
contract.
`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' | 'cooldown' | 'invalid'
reason: str # 'sent' | 'allowlist' | 'cooldown' | 'invalid'
def request_code(email: str) -> RequestOutcome:
@@ -163,6 +160,13 @@ def request_code(email: str) -> RequestOutcome:
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(
@@ -271,16 +275,12 @@ def provision_or_link_user(email: str) -> SessionUser:
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. `permission_state` is
read off the row as-is grandfathered users come through
migration with 'granted' (the column default), so their
contributor capabilities are unaffected.
trip still resolves the same row.
2. Otherwise: a fresh contributor row with `gitea_id = NULL`,
`gitea_login = NULL`, and `permission_state = 'pending'`
(v0.8.0). The display name defaults to the local part of
the email (everything before the `@`); a separate
`POST /auth/me/beta-request` call lands first name / last
name / "why I want access" on the same row.
`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
@@ -307,19 +307,13 @@ def provision_or_link_user(email: str) -> SessionUser:
email=existing["email"] or email,
avatar_url=existing["avatar_url"] or "",
role=existing["role"],
permission_state=existing["permission_state"] or "granted",
)
display = email.split("@", 1)[0] or email
# v0.8.0: 'pending' is the explicit insert value; the migration
# default of 'granted' is what passes grandfathered users
# through. A fresh OTC user lands in 'pending' regardless of
# what the migration default says, so the gate engages reliably
# even if a future migration changes the default.
cur = db.conn().execute(
"""
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
VALUES (NULL, NULL, ?, ?, '', 'contributor', 'pending')
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role)
VALUES (NULL, NULL, ?, ?, '', 'contributor')
""",
(email, display),
)
@@ -332,5 +326,4 @@ def provision_or_link_user(email: str) -> SessionUser:
email=email,
avatar_url="",
role="contributor",
permission_state="pending",
)
-367
View File
@@ -1,367 +0,0 @@
"""§6.2 / v0.10.0: user-set passcodes after OTC (roadmap item #8).
After a successful OTC sign-in, a contributor may set a passcode and
use email + passcode for subsequent sign-ins. OTC remains the fallback
a forgotten passcode is recovered by requesting a fresh OTC.
This module is the state machine behind the four `/auth/passcode/*`
endpoints (`set`, `clear`, `verify`, `check`). The endpoints in
`main.py` thin-wrap these helpers in the same shape the OTC module
uses (see `otc.py`).
Shape:
* `set_passcode(user_id, passcode)` bcrypt-hash the passcode and
write it to `users.passcode_hash` + `users.passcode_set_at`.
Validation (length, denylist) happens here, not at the endpoint,
so the rule lives in one place. Replaces any prior passcode.
* `clear_passcode(user_id)` null out `passcode_hash` and
`passcode_set_at`. The user is back to OTC-only.
* `verify_passcode(email, passcode)` locate the user by email,
check lockout, compare via bcrypt, manage the failure counter,
and return a populated `SessionUser` on success.
* `passcode_status(email)` does this email have a passcode set?
Used by the `/auth/passcode/check` endpoint that the Login.jsx
flow consults after the user types their email.
Lockout is a v1 shape: 5 consecutive failures sets
`passcode_locked_until` to `now + 15 minutes`, after which a verify
attempt that lands inside the window returns HTTP 423. The OTC path
is unaffected by the lockout a user can request and verify a fresh
OTC to sign in while their passcode is locked out, and `verify_code`
in `otc.py` does not consult these columns.
The lockout window and the failure threshold are hard-coded here.
Tuning them via env vars (or moving to per-IP rate-limiting) is a
§19.2 candidate; see SPEC §19.2.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
import bcrypt
from . import db
from .auth import SessionUser
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tunables — intentionally hard-coded in v0.10.0 (see module docstring).
# ---------------------------------------------------------------------------
LOCKOUT_AFTER_FAILED_ATTEMPTS = 5
LOCKOUT_DURATION_MINUTES = 15
PASSCODE_MIN_LENGTH = 4
PASSCODE_MAX_LENGTH = 20
# A small denylist of patterns we never want a passcode to be. The
# rule is "no obvious patterns"; the list is deliberately small —
# every entry here is a verbatim string match. A heavier check
# (sequential digits, single-character runs of length >= N, etc.)
# is a §19.2 candidate.
PASSCODE_DENYLIST: frozenset[str] = frozenset(
{
"0000",
"1111",
"2222",
"3333",
"4444",
"5555",
"6666",
"7777",
"8888",
"9999",
"1234",
"12345",
"123456",
"1234567",
"12345678",
"123456789",
"1234567890",
"0123",
"01234",
"012345",
"0123456",
"01234567",
"012345678",
"0123456789",
"abcd",
"abcde",
"abcdef",
"qwer",
"qwerty",
"asdf",
"asdfg",
"asdfgh",
"aaaa",
"bbbb",
"cccc",
"password",
"letmein",
}
)
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
class PasscodeValidationError(Exception):
"""The proposed passcode failed validation. The endpoint surface
maps this to HTTP 422 with the message intact."""
def _validate(passcode: str) -> str:
"""Return the normalized passcode (stripped) or raise.
Rules:
* 4-20 characters after stripping leading/trailing whitespace.
* Not on the small denylist of obvious patterns.
No character-class restriction beyond that the spec says
"numeric PIN or short alphanumeric"; we don't refuse other
characters because the entropy isn't load-bearing (the per-account
lockout is what carries the security weight, mirroring the OTC
shape from v0.7.0).
"""
pc = (passcode or "").strip()
if not pc:
raise PasscodeValidationError("Passcode is required")
if len(pc) < PASSCODE_MIN_LENGTH:
raise PasscodeValidationError(
f"Passcode must be at least {PASSCODE_MIN_LENGTH} characters"
)
if len(pc) > PASSCODE_MAX_LENGTH:
raise PasscodeValidationError(
f"Passcode must be at most {PASSCODE_MAX_LENGTH} characters"
)
if pc.lower() in PASSCODE_DENYLIST:
raise PasscodeValidationError("Passcode is too common; pick something less obvious")
return pc
# ---------------------------------------------------------------------------
# Hashing
# ---------------------------------------------------------------------------
def _hash(passcode: str) -> str:
return bcrypt.hashpw(passcode.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
def _check(passcode: str, passcode_hash: str) -> bool:
try:
return bcrypt.checkpw(passcode.encode("utf-8"), passcode_hash.encode("ascii"))
except (ValueError, TypeError):
return False
# ---------------------------------------------------------------------------
# Set / clear
# ---------------------------------------------------------------------------
def set_passcode(user_id: int, passcode: str) -> None:
"""Hash and store the passcode. Replaces any prior passcode on the
same row; clears the failure counter and lockout (a user setting a
fresh passcode is implicitly re-authenticating their account)."""
pc = _validate(passcode)
h = _hash(pc)
db.conn().execute(
"""
UPDATE users
SET passcode_hash = ?,
passcode_set_at = datetime('now'),
passcode_failed_attempts = 0,
passcode_locked_until = NULL
WHERE id = ?
""",
(h, user_id),
)
def clear_passcode(user_id: int) -> None:
"""Remove the passcode. The user is back to OTC-only on next sign-in."""
db.conn().execute(
"""
UPDATE users
SET passcode_hash = NULL,
passcode_set_at = NULL,
passcode_failed_attempts = 0,
passcode_locked_until = NULL
WHERE id = ?
""",
(user_id,),
)
# ---------------------------------------------------------------------------
# Check (status surface for the Login.jsx flow)
# ---------------------------------------------------------------------------
@dataclass
class PasscodeStatus:
"""The shape `/auth/passcode/check` returns.
`has_passcode` is the only signal the frontend needs to decide
whether to show a passcode input or an OTC request step. We do
not leak the hash, the set-at timestamp, or the lockout state
a probing client that wants to know "is this account locked
out" can attempt a verify and read the 423.
"""
has_passcode: bool
def passcode_status(email: str) -> PasscodeStatus:
email = (email or "").strip()
if not email or "@" not in email:
return PasscodeStatus(has_passcode=False)
row = db.conn().execute(
"SELECT passcode_hash FROM users WHERE email = ? COLLATE NOCASE",
(email,),
).fetchone()
if row is None:
return PasscodeStatus(has_passcode=False)
return PasscodeStatus(has_passcode=bool(row["passcode_hash"]))
# ---------------------------------------------------------------------------
# Verify
# ---------------------------------------------------------------------------
@dataclass
class VerifyOutcome:
"""Result of a `verify_passcode` call.
`reason` distinguishes the failure modes the endpoint surfaces as
distinct HTTP shapes:
* 'ok' populated `user`, HTTP 200.
* 'unknown' no user with this email, HTTP 400 (generic).
* 'no_passcode' user exists but never set a passcode, HTTP 400
(the frontend should fall back to OTC).
* 'locked' user is currently in the lockout window, HTTP
423. `locked_until` carries the ISO-8601 stamp for the client.
* 'wrong' passcode didn't match. HTTP 400. If the failure
crossed the lockout threshold the row is now locked; the
endpoint surfaces this as a fresh `locked` response on the
next attempt rather than collapsing the two states here.
"""
ok: bool
user: SessionUser | None
reason: str
locked_until: str | None = None
def verify_passcode(email: str, passcode: str) -> VerifyOutcome:
email = (email or "").strip()
passcode = (passcode or "").strip()
if not email or not passcode:
return VerifyOutcome(ok=False, user=None, reason="unknown")
row = db.conn().execute(
"""
SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role,
passcode_hash, passcode_failed_attempts, passcode_locked_until
FROM users
WHERE email = ? COLLATE NOCASE
""",
(email,),
).fetchone()
if row is None:
return VerifyOutcome(ok=False, user=None, reason="unknown")
if not row["passcode_hash"]:
return VerifyOutcome(ok=False, user=None, reason="no_passcode")
# Lockout check: if `passcode_locked_until` is populated and in the
# future, the verify is refused without touching the hash. Once the
# window has elapsed we let the verify proceed; the failed-attempts
# counter is also reset so the user gets a fresh 5-attempt budget.
locked_until = row["passcode_locked_until"]
if locked_until:
still_locked = db.conn().execute(
"SELECT datetime(?) > datetime('now') AS still_locked",
(locked_until,),
).fetchone()["still_locked"]
if still_locked:
return VerifyOutcome(
ok=False,
user=None,
reason="locked",
locked_until=locked_until,
)
# Lockout expired — clear the counter so the next failure starts
# from zero, and continue with the verify.
db.conn().execute(
"""
UPDATE users
SET passcode_failed_attempts = 0,
passcode_locked_until = NULL
WHERE id = ?
""",
(row["id"],),
)
if _check(passcode, row["passcode_hash"]):
# Success: clear the counter (a single success wipes the
# accumulated failures — the threshold tracks *consecutive*
# failures).
db.conn().execute(
"""
UPDATE users
SET passcode_failed_attempts = 0,
passcode_locked_until = NULL,
last_seen_at = datetime('now')
WHERE id = ?
""",
(row["id"],),
)
return VerifyOutcome(
ok=True,
user=SessionUser(
user_id=row["id"],
gitea_id=row["gitea_id"] or 0,
gitea_login=row["gitea_login"] or "",
display_name=row["display_name"],
email=row["email"] or email,
avatar_url=row["avatar_url"] or "",
role=row["role"],
),
reason="ok",
)
# Failure: increment the counter. If this push crosses the
# threshold, stamp the lockout. The next verify attempt against
# the same row returns 423 with the `locked_until` stamp.
next_count = (row["passcode_failed_attempts"] or 0) + 1
if next_count >= LOCKOUT_AFTER_FAILED_ATTEMPTS:
db.conn().execute(
f"""
UPDATE users
SET passcode_failed_attempts = ?,
passcode_locked_until = datetime('now', '+{LOCKOUT_DURATION_MINUTES} minutes')
WHERE id = ?
""",
(next_count, row["id"]),
)
new_locked_until = db.conn().execute(
"SELECT passcode_locked_until FROM users WHERE id = ?",
(row["id"],),
).fetchone()["passcode_locked_until"]
return VerifyOutcome(
ok=False,
user=None,
reason="locked",
locked_until=new_locked_until,
)
db.conn().execute(
"UPDATE users SET passcode_failed_attempts = ? WHERE id = ?",
(next_count, row["id"]),
)
return VerifyOutcome(ok=False, user=None, reason="wrong")
-148
View File
@@ -1,148 +0,0 @@
"""§6.2 / v0.12.0 / roadmap item #10: CloudFlare Turnstile siteverify.
The OTC request endpoint (`/auth/otc/request`) is the abuse hot path
of the auth surface since v0.7.0 the per-email cooldown stops the
trivial back-to-back loop, but it does not stop a distributed scraper
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). v0.12.0 gates the request
endpoint behind a one-step browser-side Turnstile challenge before the
bcrypt hash + SMTP send.
Stateless: no DB writes, no schema change. The siteverify call to
CloudFlare lives entirely in this module; the endpoint handler in
`main.py` thin-wraps `verify_token`.
Tunables (read at call time so tests can monkeypatch):
* `CLOUDFLARE_TURNSTILE_SECRET` the operator-provisioned secret
key from the Turnstile dashboard. Lives in GCP Secret Manager in
production; absent in tests (which monkeypatch the siteverify
transport). When unset, the behavior depends on `TURNSTILE_REQUIRED`:
- `TURNSTILE_REQUIRED=true` fail closed (`misconfigured`).
- `TURNSTILE_REQUIRED=false` (default) skip verification entirely
and admit the request. This is the dev/test path and the
"operator hasn't wired the secret yet" path; production
deployments **should** set `TURNSTILE_REQUIRED=true` once the
secret is in place so a regression in the secret wiring fails
loudly instead of silently disabling abuse defense.
* `TURNSTILE_REQUIRED` `true` / `false` (default `false`).
When `false` and the secret is absent, the gate is open. When
`true` and the secret is absent, the endpoint refuses with a
misconfigured-auth shape rather than silently letting requests
through.
* `TURNSTILE_SITEVERIFY_URL` points at the real CloudFlare
endpoint by default. Override in tests to redirect at a mock
URL when `httpx.MockTransport` isn't ergonomic for the case.
The siteverify contract is documented at
https://developers.cloudflare.com/turnstile/get-started/server-side-validation/.
We POST `secret` + `response` (and optionally `remoteip`) as form
fields and read back `{"success": true|false, ...}`. Any network /
parse failure on the siteverify call is treated as a verification
failure (`network`) the abuse path is to skip the challenge, so
"can't reach CloudFlare" defaults to "refuse the request" when
`TURNSTILE_REQUIRED=true`, and "admit" when `TURNSTILE_REQUIRED=false`.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
import httpx
log = logging.getLogger(__name__)
SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
def _secret() -> str:
return os.environ.get("CLOUDFLARE_TURNSTILE_SECRET", "").strip()
def _required() -> bool:
raw = os.environ.get("TURNSTILE_REQUIRED", "").strip().lower()
return raw in ("1", "true", "yes", "on")
def _siteverify_url() -> str:
return os.environ.get("TURNSTILE_SITEVERIFY_URL", "").strip() or SITEVERIFY_URL
@dataclass
class VerifyOutcome:
"""Result of a Turnstile siteverify call.
`ok`: the request **may proceed**. True both for "siteverify said
success" and for "no secret configured AND not required" (the
dev/test soft-fail path).
`reason`: one of
* 'ok' siteverify returned success.
* 'skipped' no secret configured, TURNSTILE_REQUIRED=false.
The gate is open; the endpoint admits the request.
* 'misconfigured' TURNSTILE_REQUIRED=true but no secret in env.
The endpoint fails closed with 500.
* 'missing-token' the client did not send a token at all and
verification is required.
* 'failed' siteverify returned success=false. The
endpoint refuses with 400.
* 'network' siteverify call raised. Treated as a failure
under TURNSTILE_REQUIRED=true.
"""
ok: bool
reason: str
def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOutcome:
"""Validate a Turnstile token against CloudFlare's siteverify endpoint.
Returns a VerifyOutcome describing whether the calling endpoint
should proceed. The endpoint maps `ok=False` to an HTTP status per
the `reason`:
* 'misconfigured' 500 "auth misconfigured"
* 'missing-token' / 'failed' / 'network' 400 "verification failed"
Tests monkeypatch `httpx.post` (or set `TURNSTILE_SITEVERIFY_URL`
+ a MockTransport client) to avoid touching the real CloudFlare
endpoint. No real keys are ever embedded in tests.
"""
secret = _secret()
required = _required()
if not secret:
if required:
log.warning("Turnstile required but CLOUDFLARE_TURNSTILE_SECRET is unset; failing closed")
return VerifyOutcome(ok=False, reason="misconfigured")
# Dev/test/soft-fail path: no secret, not required → gate is open.
return VerifyOutcome(ok=True, reason="skipped")
if not token or not token.strip():
# Secret is set, so verification is in force. A missing token
# is a hard refuse — the frontend should have rendered the
# widget and collected one.
return VerifyOutcome(ok=False, reason="missing-token")
data = {"secret": secret, "response": token.strip()}
if client_ip:
data["remoteip"] = client_ip
try:
response = httpx.post(_siteverify_url(), data=data, timeout=10.0)
payload = response.json()
except Exception as exc: # network, JSON parse, etc.
log.warning("Turnstile siteverify call failed: %s", exc)
return VerifyOutcome(ok=False, reason="network")
if payload.get("success") is True:
return VerifyOutcome(ok=True, reason="ok")
# `error-codes` is a list of strings on failure; we log the codes
# for the operator without surfacing them to the client.
log.info("Turnstile siteverify rejected token: %s", payload.get("error-codes"))
return VerifyOutcome(ok=False, reason="failed")
-35
View File
@@ -1,35 +0,0 @@
-- 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
);
-76
View File
@@ -1,76 +0,0 @@
-- §6.1 / §6.2 / §14.1 / v0.8.0: open beta-access request flow (roadmap item #6).
--
-- This release replaces v0.3.0's `allowed_emails` allowlist as the
-- admission control. Anyone with a valid email can sign in via the
-- v0.7.0 OTC flow; a fresh user lands in `permission_state='pending'`
-- until an admin grants access. The first-OTC flow captures three
-- profile fields (first name, last name, free-text "why I should be
-- included in the beta") that the admin sees when triaging the
-- request queue. The `allowed_emails` table stays in the schema as a
-- fast-path bypass — populated rows are still readable by the
-- existing admin UI; the OTC `/request` handler no longer consults
-- it. v0.9.0's admin user-management page will replace the
-- allowlist UI entirely.
--
-- Schema additions:
--
-- * `permission_state` — three-state CHECK: 'pending' | 'granted' |
-- 'revoked'. Default 'granted' so every row at migration time
-- passes through unaffected; only newly provisioned OTC users
-- land in 'pending' (the OTC verify path sets the column
-- explicitly on a fresh row, per `app/otc.py`). 'revoked' is the
-- admin gesture for an account that earned a grant then later
-- lost it; v0.8.0 doesn't surface a revoke UI, but the schema
-- slot is here so v0.9.0's admin user-management page can flip
-- the column without another migration.
--
-- * `first_name`, `last_name` — nullable TEXT. Captured on the
-- first OTC sign-in via `POST /auth/me/beta-request`. Existing
-- rows (OAuth-era users, OTC users provisioned in v0.7.0) carry
-- NULL through the migration; the admin queue treats an
-- unpopulated capture as "auto-grandfathered" since the row's
-- `permission_state` is already 'granted'.
--
-- * `beta_request_reason` — nullable TEXT. The free-text "why I
-- should be included" from the capture form. Bounded to ~4000
-- chars at the endpoint layer (no DB-level constraint —
-- SQLite's TEXT is unbounded).
--
-- * `permission_decided_by` — nullable INTEGER. The `users.id` of
-- the admin who flipped `permission_state` from 'pending' to
-- 'granted' (or 'granted' to 'revoked'). NULL for grandfathered
-- rows (they were never decided — they passed through at
-- migration). ON DELETE SET NULL because losing the admin row
-- should not cascade-delete the user whose access they granted.
--
-- * `permission_decided_at` — nullable TEXT timestamp (ISO 8601,
-- same shape as the existing `created_at` / `last_seen_at`).
-- Co-populated with `permission_decided_by` on each decision.
--
-- Grandfathered-row invariant:
--
-- Every row that exists at migration time has
-- `permission_state='granted'` and `permission_decided_by=NULL`
-- (the column default + NULL preservation). v0.8.0's auth gate
-- reads `permission_state='granted'` as the admission check, so
-- no existing user is locked out by the upgrade. v0.7.0's OTC
-- path is patched in the same release to set
-- `permission_state='pending'` explicitly on a fresh row, so the
-- gate engages only for users provisioned after the upgrade.
ALTER TABLE users ADD COLUMN permission_state TEXT NOT NULL DEFAULT 'granted'
CHECK (permission_state IN ('pending', 'granted', 'revoked'));
ALTER TABLE users ADD COLUMN first_name TEXT;
ALTER TABLE users ADD COLUMN last_name TEXT;
ALTER TABLE users ADD COLUMN beta_request_reason TEXT;
ALTER TABLE users ADD COLUMN permission_decided_by INTEGER
REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE users ADD COLUMN permission_decided_at TEXT;
-- Index for the v0.9.0 admin queue: list pending requests ordered by
-- when the user's row was created (the implicit "request received at"
-- timestamp, since v0.8.0 sets pending at the same moment as the row
-- itself is inserted via the OTC verify path).
CREATE INDEX idx_users_permission_state ON users (permission_state);
-52
View File
@@ -1,52 +0,0 @@
-- §6.2 / v0.10.0: user-set passcodes after OTC (roadmap item #8).
--
-- After a successful OTC sign-in, a contributor may set a passcode
-- (numeric PIN or short alphanumeric). Subsequent sign-ins on the same
-- account can use email + passcode instead of email + OTC. OTC remains
-- the structural fallback — a forgotten passcode is recovered by
-- requesting a fresh OTC and signing in via that path. Per-account
-- lockout after 5 consecutive verify failures redirects the user to
-- the OTC path for 15 minutes; the OTC path itself is unaffected by
-- the passcode lockout (a locked-out user can still receive a fresh
-- code and sign in).
--
-- The columns are additive to the `users` table from `012_otc.sql`.
-- v0.8.0's `permission_state` column (roadmap item #6) lands in the
-- driver's integration order ahead of this migration; we do not touch
-- that column here. v0.7.0's nullable-`gitea_id`/`gitea_login` shape
-- is preserved verbatim.
--
-- Storage shape:
--
-- * `passcode_hash` (nullable) — bcrypt hash of the passcode.
-- NULL means "no passcode set"; the user is OTC-only.
-- * `passcode_set_at` (nullable) — timestamp of the most recent
-- `passcode/set` call. Updated when a passcode is set or
-- replaced; cleared when the passcode is removed.
-- * `passcode_failed_attempts` — count of consecutive failed
-- verify attempts since the last successful verify (or since
-- the lockout cleared). Resets to 0 on success and on lockout
-- expiry. Defaults to 0 so existing rows post-migration are
-- not implicitly half-locked.
-- * `passcode_locked_until` (nullable) — if populated and the
-- timestamp is in the future, passcode verify is refused with
-- HTTP 423. Cleared on successful verify after the window
-- expires, or by the operator via direct DB intervention if
-- ever needed (no admin endpoint surfaces this in v1).
--
-- v0.10.0 introduces no new env vars. The lockout window (5 attempts,
-- 15 minutes) is hard-coded in `backend/app/passcode.py`; raising or
-- lowering it is a future-§19.2 candidate. Passcode hashing reuses
-- the bcrypt dependency added in v0.7.0 for OTC; no new secret is
-- required (the existing `SECRET_KEY` continues to sign sessions).
--
-- Note on SQLite: ALTER TABLE ... ADD COLUMN is supported, so this
-- migration does not need the rebuild dance that `012_otc.sql`
-- required. The runner wraps each file in a single BEGIN/COMMIT
-- block — see `backend/app/db.py` — so either every ADD COLUMN
-- here lands or none do.
ALTER TABLE users ADD COLUMN passcode_hash TEXT;
ALTER TABLE users ADD COLUMN passcode_set_at TEXT;
ALTER TABLE users ADD COLUMN passcode_failed_attempts INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN passcode_locked_until TEXT;
@@ -1,476 +0,0 @@
"""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
-390
View File
@@ -1,390 +0,0 @@
"""End-to-end integration tests for v0.8.0's open beta-access request
flow (§6.1 / §14.1, roadmap item #6).
The release replaces v0.3.0's `allowed_emails` allowlist as the
admission gate. Any valid email can sign in via the v0.7.0 OTC flow;
a fresh user lands in `permission_state='pending'` until an admin
grants access. The first-OTC flow captures first name, last name,
and a free-text "why I should be included in the beta" via a new
`POST /api/auth/me/beta-request` endpoint.
The tests prove:
* A fresh OTC user lands `permission_state='pending'` with empty
profile fields, and the verify-response carries `needs_profile=true`.
* `POST /api/auth/me/beta-request` populates the three fields and
leaves the row in `pending`.
* A pending user is refused write endpoints (representative
samples: propose RFC, post discussion thread). The refusal is
403 (not 401 they're authenticated, just not granted).
* An admin-grant flow promotes pending granted. v0.8.0 doesn't
ship an admin UI for this (deferred to item #7 / v0.9.0), so
the test flips the column directly via DB and asserts that
`require_contributor` now admits the user.
* A grandfathered user (existing row pre-migration, default
`permission_state='granted'`) is unaffected write endpoints
accept them.
* The `/auth/otc/request` endpoint accepts any email the
v0.7.0 allowlist gate is gone from this path. The `allowed_emails`
table stays in the schema; the admin UI from v0.3.0 continues to
manage it for the fast-path bypass deployments may use.
"""
from __future__ import annotations
import pytest
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
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 from every OTC envelope in the test buffer."""
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
# ---------------------------------------------------------------------------
# Fresh OTC sign-in lands pending with empty fields
# ---------------------------------------------------------------------------
def test_fresh_otc_user_lands_pending_with_empty_profile(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()
# Request + verify the OTC.
r = client.post("/auth/otc/request", json={"email": "newcomer@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("newcomer@example.com")[-1]
r = client.post("/auth/otc/verify", json={"email": "newcomer@example.com", "code": code})
assert r.status_code == 200, r.text
body = r.json()
# The verify response carries the new fields v0.8.0 added.
assert body["needs_profile"] is True
assert body["user"]["permission_state"] == "pending"
# The row reflects the same: pending state, no profile yet.
row = db.conn().execute(
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE email = ? COLLATE NOCASE",
("newcomer@example.com",),
).fetchone()
assert row is not None
assert row["permission_state"] == "pending"
assert row["first_name"] is None
assert row["last_name"] is None
assert row["beta_request_reason"] is None
# /api/auth/me surfaces the same shape.
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["permission_state"] == "pending"
assert me["user"]["needs_profile"] is True
assert me["user"]["first_name"] == ""
assert me["user"]["last_name"] == ""
assert me["user"]["beta_request_reason"] == ""
# ---------------------------------------------------------------------------
# beta-request endpoint captures the fields and leaves state pending
# ---------------------------------------------------------------------------
def test_beta_request_populates_fields_keeps_state_pending(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()
# Sign in the fresh user via the full OTC flow.
client.post("/auth/otc/request", json={"email": "alice@example.com"})
code = _outbound_otc_codes("alice@example.com")[-1]
client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
# Submit the capture form.
r = client.post(
"/api/auth/me/beta-request",
json={
"first_name": "Alice",
"last_name": "Liddell",
"beta_request_reason": "I want to help write the RFCs.",
},
)
assert r.status_code == 200, r.text
# The row reflects the captured fields; state stays pending.
row = db.conn().execute(
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE email = ? COLLATE NOCASE",
("alice@example.com",),
).fetchone()
assert row["permission_state"] == "pending"
assert row["first_name"] == "Alice"
assert row["last_name"] == "Liddell"
assert row["beta_request_reason"] == "I want to help write the RFCs."
# /api/auth/me now reports needs_profile=false (fields are set).
me = client.get("/api/auth/me").json()
assert me["user"]["permission_state"] == "pending"
assert me["user"]["needs_profile"] is False
assert me["user"]["first_name"] == "Alice"
def test_beta_request_refuses_anonymous(app_with_fake_gitea):
"""The endpoint requires authentication — an anonymous caller can't
file a request without first signing in via OTC."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
r = client.post(
"/api/auth/me/beta-request",
json={"first_name": "A", "last_name": "B", "beta_request_reason": "Hi"},
)
assert r.status_code == 401
def test_beta_request_refuses_granted_user(app_with_fake_gitea):
"""A grandfathered (already granted) user has no business filing a
beta request. The endpoint refuses with 409 so the client can
distinguish the failure from "we don't know you" (401)."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="grandfathered", role="contributor")
sign_in_as(
client,
user_id=1,
gitea_login="grandfathered",
display_name="Grandfathered",
role="contributor",
)
r = client.post(
"/api/auth/me/beta-request",
json={"first_name": "G", "last_name": "F", "beta_request_reason": "x"},
)
assert r.status_code == 409
# ---------------------------------------------------------------------------
# Pending user is refused write endpoints; admin grant promotes them
# ---------------------------------------------------------------------------
def test_pending_user_is_refused_write_endpoints(app_with_fake_gitea):
"""A pending user can read everything anonymous can read, but every
write-shaped endpoint refuses with 403. The refusal shape mirrors
the v0.6.0 / item #4 audit's anon-401 — both are "no contributor
capability"; pending is the authenticated-but-ungranted variant.
Representative samples: propose RFC, post discussion thread.
"""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Sign in via fresh OTC — lands pending.
client.post("/auth/otc/request", json={"email": "pending@example.com"})
code = _outbound_otc_codes("pending@example.com")[-1]
client.post("/auth/otc/verify", json={"email": "pending@example.com", "code": code})
# Reads work — every anonymous surface stays reachable.
assert client.get("/api/health").status_code == 200
assert client.get("/api/rfcs").status_code == 200
assert client.get("/api/philosophy").status_code == 200
# Propose — write-shaped, refused with 403.
r = client.post(
"/api/rfcs/propose",
json={"title": "T", "slug": "t", "pitch": "p", "tags": []},
)
assert r.status_code == 403
# The error body mentions the review state so a UI surface can
# render the right message — but the test asserts only on the
# status code (the body shape is the FastAPI default detail).
def test_admin_grant_promotes_pending_to_granted(app_with_fake_gitea):
"""v0.8.0 doesn't ship an admin UI for this — it's deferred to
item #7 / v0.9.0. For this release, an admin gesture is an
`UPDATE users SET permission_state='granted' WHERE email=?`. The
test flips the column directly via DB and asserts the
`require_contributor` gate now admits the user.
"""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Sign in a fresh OTC user — lands pending.
client.post("/auth/otc/request", json={"email": "promoted@example.com"})
code = _outbound_otc_codes("promoted@example.com")[-1]
client.post("/auth/otc/verify", json={"email": "promoted@example.com", "code": code})
# Before the grant: propose refused with 403.
r = client.post(
"/api/rfcs/propose",
json={"title": "T", "slug": "t-pre", "pitch": "p", "tags": []},
)
assert r.status_code == 403
# The admin gesture (v0.8.0 shape — direct UPDATE; v0.9.0 will
# ship a UI). The test stamps `permission_decided_by` and
# `permission_decided_at` as the v0.9.0 admin UI will, so the
# column population exercises the schema slot. user_id=99 is
# a placeholder admin row — provision it so the FK resolves.
provision_user_row(user_id=99, login="adminuser", role="admin")
db.conn().execute(
"""
UPDATE users
SET permission_state = 'granted',
permission_decided_by = 99,
permission_decided_at = datetime('now')
WHERE email = ?
""",
("promoted@example.com",),
)
# The next request reads the fresh column from the DB. The
# propose endpoint reaches the route body now (it then refuses
# for a different reason — the slug 't-prop' will fail
# the slug-format check or hit a mock-gitea path — but the
# status code is _not_ 403/401, which is the v0.8.0 assertion).
r = client.post(
"/api/rfcs/propose",
json={"title": "Title", "slug": "tprop", "pitch": "Pitch text.", "tags": []},
)
assert r.status_code != 403, r.text
assert r.status_code != 401, r.text
def test_grandfathered_user_is_unaffected_by_migration(app_with_fake_gitea):
"""An existing `users` row at migration time has
`permission_state='granted'` via the column default. The
grandfathered user passes write endpoints without filing a
beta request and without the admin UI. v0.6.0 (anon-write
audit) is the v0.6.0 contract; v0.8.0 widens the gate but
does not break this case.
"""
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=5, login="oldhand", role="contributor")
# provision_user_row uses INSERT OR REPLACE INTO users with
# the column list it knows; permission_state is not in that
# list, so it picks up the column default ('granted') on
# insert. Confirm directly.
row = db.conn().execute(
"SELECT permission_state FROM users WHERE id = 5"
).fetchone()
assert row["permission_state"] == "granted"
sign_in_as(
client,
user_id=5,
gitea_login="oldhand",
display_name="Old Hand",
role="contributor",
)
# Propose is write-shaped; the call should not refuse on
# the permission_state gate. (Subsequent failure modes —
# e.g. mock-gitea wiring — are not the v0.8.0 concern; this
# test asserts on the gate, not the propose body's success.)
r = client.post(
"/api/rfcs/propose",
json={"title": "Title", "slug": "gf-slug", "pitch": "Pitch.", "tags": []},
)
assert r.status_code != 403, r.text
assert r.status_code != 401, r.text
# ---------------------------------------------------------------------------
# /auth/otc/request accepts any email — the v0.7.0 allowlist gate is gone
# ---------------------------------------------------------------------------
def test_otc_request_accepts_any_email_regardless_of_allowlist(app_with_fake_gitea):
"""v0.7.0 silently dropped OTC requests for emails not on the
`allowed_emails` table. v0.8.0 reverses this: the request
endpoint sends a code to any valid email; admission gates at
`permission_state` post-verify instead. The `allowed_emails`
table stays in the schema as a fast-path bypass for
deployments that want to pre-mark known-good emails (the v0.9.0
admin user-management page will collapse the two surfaces).
"""
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 with one specific email so the v0.7.0
# gate would have engaged. v0.8.0 ignores it for the request
# path.
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("known@example.com",))
# An email NOT on the allowlist still gets a code under v0.8.0.
r = client.post("/auth/otc/request", json={"email": "stranger@example.com"})
assert r.status_code == 200
codes = _outbound_otc_codes("stranger@example.com")
assert len(codes) == 1, "OTC code must be sent regardless of allowlist state"
# The row is there and the user can complete sign-in (and will
# land in 'pending' per the other tests).
row = db.conn().execute(
"SELECT 1 FROM otc_codes WHERE email = ?",
("stranger@example.com",),
).fetchone()
assert row is not None
def test_allowlist_table_still_present_in_schema(app_with_fake_gitea):
"""The schema migration leaves the `allowed_emails` table in
place the admin UI from v0.3.0 still manages it for the
fast-path bypass deployments may use. This is a regression net
for "did the v0.8.0 cleanup accidentally drop the table"."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app):
# The table accepts inserts (i.e. it exists) — no schema check
# gymnastics needed.
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("kept@example.com",))
row = db.conn().execute(
"SELECT email FROM allowed_emails WHERE email = ?",
("kept@example.com",),
).fetchone()
assert row is not None
@@ -1,205 +0,0 @@
"""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
+14 -30
View File
@@ -13,13 +13,9 @@ sign-in path. The tests prove:
* Expired codes refuse with 400.
* Already-consumed codes refuse with 400 on re-use.
* Wrong codes refuse with 400.
* Allowlist gate (v0.8.0 update): v0.7.0 silently dropped requests
for emails not on `allowed_emails`. v0.8.0 (item #6) removed
that gate from the request path; the admission gate is now
`permission_state` on the freshly-provisioned `users` row,
asserted in test_beta_access_vertical.py. The tests below
confirm v0.8.0's open-request shape for both on-list and
off-list emails.
* 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.
@@ -205,45 +201,33 @@ def test_otc_request_cooldown_is_per_email_not_global(app_with_fake_gitea):
# ---------------------------------------------------------------------------
# Allowlist gate — v0.8.0 update
#
# v0.7.0 gated the OTC request endpoint on the `allowed_emails` table:
# emails not on the list got a silent drop (still 202, but no code).
# v0.8.0 (roadmap item #6) reverses this: the request endpoint
# accepts any valid email and sends a code. The admission gate moves
# to `permission_state` on the freshly-provisioned `users` row,
# which the next-tier tests in test_beta_access_vertical.py cover.
# The `allowed_emails` table stays in the schema as a fast-path
# bypass for admin convenience.
# Allowlist gate
# ---------------------------------------------------------------------------
def test_otc_request_admits_emails_regardless_of_allowlist_population(app_with_fake_gitea):
"""v0.8.0: the OTC request path no longer consults `allowed_emails`.
Whether the allowlist is empty or populated, every valid email
receives a code; admission gates at `permission_state` post-verify.
"""
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 with one specific email; the v0.7.0
# gate would have engaged here.
# Populate the allowlist so the gate turns on.
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("invited@example.com",))
# The not-on-list email still gets a code under v0.8.0.
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
assert len(_outbound_otc_codes("stranger@example.com")) == 1
# 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):
"""v0.8.0: still works for emails that happen to be on the legacy
allowlist the table is no longer consulted at request time but
populated rows are admitted alongside everyone else (since the
gate is now open at the request surface)."""
from fastapi.testclient import TestClient
from app import db
-532
View File
@@ -1,532 +0,0 @@
"""End-to-end integration tests for the v0.10.0 user-set passcode
vertical (§6.2, roadmap item #8).
After a successful OTC sign-in the user can set a passcode and use
email + passcode for subsequent sign-ins. OTC remains the structural
fallback these tests prove:
* `/auth/passcode/set` requires an active session.
* `/auth/passcode/check` returns `has_passcode` without leaking the
hash, the set-at stamp, or the lockout state.
* Happy path: OTC sign-in set passcode sign out email +
passcode signs in (no OTC roundtrip).
* Wrong passcode increments the failure counter without locking.
* Five consecutive failures lock the passcode path (HTTP 423) and
persist `passcode_locked_until` on the user row.
* The lockout expires after `passcode_locked_until`; a verify
attempt past the window succeeds again and clears the counter.
* The OTC path is unaffected by the passcode lockout a user
whose passcode is locked can still request and verify a fresh
OTC to sign in.
* Clearing the passcode wipes the hash; subsequent verify refuses
with the no-passcode failure shape.
* Setting a new passcode replaces the prior one (and resets the
failure counter / lockout state).
* `passcode_set_at` updates on every set call.
* The validation denylist refuses obvious patterns (e.g. `0000`,
`1234`).
* Passcode length is enforced (4-20).
The fakes from `test_propose_vertical` give us a working app harness.
The OTC envelope buffer from `test_otc_vertical` is reused for the
OTC roundtrips this suite needs.
"""
from __future__ import annotations
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
tmp_env,
)
# ---------------------------------------------------------------------------
# Helpers — mirror the OTC suite's outbound-buffer helpers.
# ---------------------------------------------------------------------------
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]:
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
def _sign_in_via_otc(client, email: str) -> None:
"""Run an OTC request+verify so the client carries an authenticated
session. The cooldown is irrelevant on a fresh email; we don't
need to drop it."""
r = client.post("/auth/otc/request", json={"email": email})
assert r.status_code == 200, r.text
code = _outbound_otc_codes(email)[-1]
r = client.post("/auth/otc/verify", json={"email": email, "code": code})
assert r.status_code == 200, r.text
# ---------------------------------------------------------------------------
# Set passcode — auth-required, happy path
# ---------------------------------------------------------------------------
def test_set_passcode_requires_session(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.post("/auth/passcode/set", json={"passcode": "secret123"})
assert r.status_code == 401
def test_set_passcode_after_otc_landing_persists_hash(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()
_sign_in_via_otc(client, "alice@example.com")
r = client.post("/auth/passcode/set", json={"passcode": "secret123"})
assert r.status_code == 200, r.text
row = db.conn().execute(
"SELECT passcode_hash, passcode_set_at FROM users WHERE email = ? COLLATE NOCASE",
("alice@example.com",),
).fetchone()
assert row is not None
assert row["passcode_hash"] is not None
# Not the plaintext.
assert row["passcode_hash"] != "secret123"
assert row["passcode_set_at"] is not None
# ---------------------------------------------------------------------------
# Check endpoint — leak-free shape
# ---------------------------------------------------------------------------
def test_check_endpoint_returns_false_for_unknown_email(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/auth/passcode/check", params={"email": "nobody@example.com"})
assert r.status_code == 200
assert r.json() == {"has_passcode": False}
def test_check_endpoint_returns_false_for_user_without_passcode(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "bob@example.com")
r = client.get("/auth/passcode/check", params={"email": "bob@example.com"})
assert r.status_code == 200
assert r.json() == {"has_passcode": False}
def test_check_endpoint_returns_true_after_set(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "carol@example.com")
client.post("/auth/passcode/set", json={"passcode": "letmein9"})
# Drop the session so the check is read in the anonymous shape.
client.cookies.clear()
r = client.get("/auth/passcode/check", params={"email": "carol@example.com"})
assert r.status_code == 200
assert r.json() == {"has_passcode": True}
# The response carries ONLY the boolean — no hash, no stamp.
assert set(r.json().keys()) == {"has_passcode"}
# ---------------------------------------------------------------------------
# Verify path — happy path
# ---------------------------------------------------------------------------
def test_verify_passcode_signs_in_user(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "dave@example.com")
client.post("/auth/passcode/set", json={"passcode": "secret123"})
client.cookies.clear()
r = client.post(
"/auth/passcode/verify",
json={"email": "dave@example.com", "passcode": "secret123"},
)
assert r.status_code == 200, r.text
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["email"] == "dave@example.com"
assert me["user"]["has_passcode"] is True
# ---------------------------------------------------------------------------
# Verify path — failure modes
# ---------------------------------------------------------------------------
def test_verify_passcode_wrong_increments_counter_without_locking(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()
_sign_in_via_otc(client, "erin@example.com")
client.post("/auth/passcode/set", json={"passcode": "secret123"})
client.cookies.clear()
# Three bad attempts — under the lockout threshold.
for _ in range(3):
r = client.post(
"/auth/passcode/verify",
json={"email": "erin@example.com", "passcode": "wrongwrong"},
)
assert r.status_code == 400
row = db.conn().execute(
"SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?",
("erin@example.com",),
).fetchone()
assert row["passcode_failed_attempts"] == 3
assert row["passcode_locked_until"] is None
def test_verify_passcode_locks_after_five_failures(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()
_sign_in_via_otc(client, "frank@example.com")
client.post("/auth/passcode/set", json={"passcode": "secret123"})
client.cookies.clear()
# Five bad attempts — the last crosses the threshold and the
# response shape flips to 423.
statuses = []
for _ in range(5):
r = client.post(
"/auth/passcode/verify",
json={"email": "frank@example.com", "passcode": "wrongwrong"},
)
statuses.append(r.status_code)
# First four are 400, the fifth (threshold-crossing) is 423.
assert statuses == [400, 400, 400, 400, 423]
row = db.conn().execute(
"SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?",
("frank@example.com",),
).fetchone()
assert row["passcode_failed_attempts"] >= 5
assert row["passcode_locked_until"] is not None
# Sixth attempt — still locked, still 423, even with the correct
# passcode (lockout overrides the verify).
r = client.post(
"/auth/passcode/verify",
json={"email": "frank@example.com", "passcode": "secret123"},
)
assert r.status_code == 423
def test_verify_passcode_lockout_expires(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()
_sign_in_via_otc(client, "gina@example.com")
client.post("/auth/passcode/set", json={"passcode": "secret123"})
client.cookies.clear()
for _ in range(5):
client.post(
"/auth/passcode/verify",
json={"email": "gina@example.com", "passcode": "wrongwrong"},
)
# Backdate the lockout to the past so the next attempt clears it.
db.conn().execute(
"""
UPDATE users
SET passcode_locked_until = datetime('now', '-1 minute')
WHERE email = ?
""",
("gina@example.com",),
)
r = client.post(
"/auth/passcode/verify",
json={"email": "gina@example.com", "passcode": "secret123"},
)
assert r.status_code == 200, r.text
# Lockout cleared, counter reset.
row = db.conn().execute(
"SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?",
("gina@example.com",),
).fetchone()
assert row["passcode_failed_attempts"] == 0
assert row["passcode_locked_until"] is None
def test_otc_path_unaffected_by_passcode_lockout(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
# Drop the OTC cooldown so the second request lands without a 429.
# The cooldown is re-read from env on every `request_code` call so
# this takes effect mid-process.
monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0")
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "harvey@example.com")
client.post("/auth/passcode/set", json={"passcode": "secret123"})
client.cookies.clear()
# Lock the passcode path.
for _ in range(5):
client.post(
"/auth/passcode/verify",
json={"email": "harvey@example.com", "passcode": "wrongwrong"},
)
# The OTC path is unaffected by the passcode lockout: the user
# can still request and verify a fresh code to sign in.
r = client.post("/auth/otc/request", json={"email": "harvey@example.com"})
assert r.status_code == 200
code = _outbound_otc_codes("harvey@example.com")[-1]
r = client.post("/auth/otc/verify", json={"email": "harvey@example.com", "code": code})
assert r.status_code == 200
# The user is now signed in via OTC even though the passcode
# path is locked. The /api/auth/me payload reflects this.
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["email"] == "harvey@example.com"
# ---------------------------------------------------------------------------
# Clear + replace
# ---------------------------------------------------------------------------
def test_clear_passcode_wipes_the_hash(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()
_sign_in_via_otc(client, "ivy@example.com")
client.post("/auth/passcode/set", json={"passcode": "secret123"})
r = client.delete("/auth/passcode")
assert r.status_code == 200
row = db.conn().execute(
"SELECT passcode_hash, passcode_set_at FROM users WHERE email = ?",
("ivy@example.com",),
).fetchone()
assert row["passcode_hash"] is None
assert row["passcode_set_at"] is None
# Verify against the cleared passcode refuses (no-passcode shape
# collapses to a generic 400).
client.cookies.clear()
r = client.post(
"/auth/passcode/verify",
json={"email": "ivy@example.com", "passcode": "secret123"},
)
assert r.status_code == 400
def test_setting_new_passcode_replaces_old(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "jane@example.com")
client.post("/auth/passcode/set", json={"passcode": "secret123"})
# Replace.
r = client.post("/auth/passcode/set", json={"passcode": "newsecret9"})
assert r.status_code == 200
client.cookies.clear()
# Old passcode refuses.
r = client.post(
"/auth/passcode/verify",
json={"email": "jane@example.com", "passcode": "secret123"},
)
assert r.status_code == 400
# New passcode signs in.
r = client.post(
"/auth/passcode/verify",
json={"email": "jane@example.com", "passcode": "newsecret9"},
)
assert r.status_code == 200
def test_setting_new_passcode_resets_lockout(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
from app import db
monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0")
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "kate@example.com")
client.post("/auth/passcode/set", json={"passcode": "secret123"})
# Lock the passcode path with bad attempts (drop session first).
client.cookies.clear()
for _ in range(5):
client.post(
"/auth/passcode/verify",
json={"email": "kate@example.com", "passcode": "wrongwrong"},
)
row = db.conn().execute(
"SELECT passcode_locked_until FROM users WHERE email = ?",
("kate@example.com",),
).fetchone()
assert row["passcode_locked_until"] is not None
# Sign back in via OTC and reset the passcode.
r = client.post("/auth/otc/request", json={"email": "kate@example.com"})
assert r.status_code == 200
code = _outbound_otc_codes("kate@example.com")[-1]
r = client.post("/auth/otc/verify", json={"email": "kate@example.com", "code": code})
assert r.status_code == 200
r = client.post("/auth/passcode/set", json={"passcode": "freshcode9"})
assert r.status_code == 200
# Lockout cleared on set.
row = db.conn().execute(
"SELECT passcode_locked_until, passcode_failed_attempts FROM users WHERE email = ?",
("kate@example.com",),
).fetchone()
assert row["passcode_locked_until"] is None
assert row["passcode_failed_attempts"] == 0
def test_passcode_set_at_updates_on_each_set(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
import time
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "luke@example.com")
client.post("/auth/passcode/set", json={"passcode": "secret123"})
first_stamp = db.conn().execute(
"SELECT passcode_set_at FROM users WHERE email = ?",
("luke@example.com",),
).fetchone()["passcode_set_at"]
assert first_stamp is not None
# SQLite's datetime('now') has second precision; sleep so the
# stamp visibly advances on the next set.
time.sleep(1.1)
client.post("/auth/passcode/set", json={"passcode": "newcode99"})
second_stamp = db.conn().execute(
"SELECT passcode_set_at FROM users WHERE email = ?",
("luke@example.com",),
).fetchone()["passcode_set_at"]
assert second_stamp is not None
assert second_stamp >= first_stamp
# Lexicographic compare on ISO-8601 datetime strings works for
# the SQLite shape.
assert second_stamp > first_stamp
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def test_set_passcode_refuses_too_short(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "mia@example.com")
r = client.post("/auth/passcode/set", json={"passcode": "abc"})
assert r.status_code == 422
def test_set_passcode_refuses_denylist_pattern(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "nick@example.com")
for bad in ["0000", "1234", "aaaa", "qwerty", "password"]:
r = client.post("/auth/passcode/set", json={"passcode": bad})
assert r.status_code == 422, f"expected 422 for {bad!r}, got {r.status_code}"
# ---------------------------------------------------------------------------
# Auth me payload
# ---------------------------------------------------------------------------
def test_auth_me_carries_has_passcode_flag(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "olga@example.com")
me = client.get("/api/auth/me").json()
assert me["user"]["has_passcode"] is False
assert me["user"]["passcode_set_at"] is None
client.post("/auth/passcode/set", json={"passcode": "secret123"})
me = client.get("/api/auth/me").json()
assert me["user"]["has_passcode"] is True
assert me["user"]["passcode_set_at"] is not None
-220
View File
@@ -1,220 +0,0 @@
"""End-to-end integration tests for the v0.12.0 CloudFlare Turnstile
gate on `/auth/otc/request` (§6.2 / roadmap item #10).
The release gates the OTC request endpoint behind a one-step
browser-side Turnstile challenge before the bcrypt hash + SMTP send.
The tests prove:
* Happy path: with the secret set, a valid token admits the request
and the OTC envelope lands.
* Failure path: with the secret set, a token siteverify rejects
refuses the request with 400 and produces no envelope.
* Missing-token: with the secret set, a request without a token
refuses with 400.
* Missing-secret-soft: with the secret unset AND
`TURNSTILE_REQUIRED=false` (the v0.12.0 default), the request
admits this is the dev / "operator hasn't wired it yet" path.
* Missing-secret-hard: with the secret unset AND
`TURNSTILE_REQUIRED=true`, the request refuses with 500
"auth misconfigured" the production fail-closed path once
the operator has flipped the policy.
The Turnstile siteverify call is mocked at the `httpx.post` boundary
inside `app.turnstile` so no real keys are needed and no real
CloudFlare call is made. The Gitea fakes from `test_propose_vertical`
remain in scope so the rest of the app boots cleanly.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
tmp_env,
)
def _reset_outbound():
from app import email as email_mod
email_mod.reset_sent_envelopes()
def _outbound_otc_envelopes(to_address: str | None = None) -> list[dict]:
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
out.append(env)
return out
def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | None = None):
"""Replace `httpx.post` inside `app.turnstile` with a stub that
returns the requested success shape. The stub does not touch the
real CloudFlare endpoint and never sees a real secret.
"""
captured = {}
def fake_post(url, *, data=None, timeout=None, **kwargs):
captured["url"] = url
captured["data"] = data
body = {"success": bool(success)}
if error_codes is not None:
body["error-codes"] = error_codes
return SimpleNamespace(json=lambda: body)
from app import turnstile as turnstile_mod
monkeypatch.setattr(turnstile_mod.httpx, "post", fake_post)
return captured
# ---------------------------------------------------------------------------
# Happy path: secret set, token valid → admit + OTC envelope lands
# ---------------------------------------------------------------------------
def test_otc_request_admits_when_turnstile_token_is_valid(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
captured = _patch_siteverify(monkeypatch, success=True)
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post(
"/auth/otc/request",
json={"email": "alice@example.com", "turnstile_token": "fake-token-abc"},
)
assert r.status_code == 200, r.text
# The siteverify call was made with the secret + the token we sent.
assert captured["data"]["secret"] == "test-secret-not-real"
assert captured["data"]["response"] == "fake-token-abc"
# And the OTC dispatch ran — exactly one envelope to the address.
envs = _outbound_otc_envelopes("alice@example.com")
assert len(envs) == 1
# ---------------------------------------------------------------------------
# Failure path: secret set, siteverify says success=false → 400 + no envelope
# ---------------------------------------------------------------------------
def test_otc_request_refuses_when_turnstile_siteverify_fails(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
_patch_siteverify(monkeypatch, success=False, error_codes=["invalid-input-response"])
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post(
"/auth/otc/request",
json={"email": "alice@example.com", "turnstile_token": "fake-bad-token"},
)
assert r.status_code == 400, r.text
# The OTC bcrypt + SMTP path did not run — no envelope was buffered.
assert _outbound_otc_envelopes("alice@example.com") == []
# ---------------------------------------------------------------------------
# Missing-token: secret set, no token → 400 + no envelope
# ---------------------------------------------------------------------------
def test_otc_request_refuses_when_turnstile_token_is_missing(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
# Even though we patch httpx.post, the missing-token check fires
# before the siteverify call — so the patch is here only as a
# safety net in case the implementation regresses to making the
# network call anyway.
_patch_siteverify(monkeypatch, success=False)
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post(
"/auth/otc/request",
json={"email": "alice@example.com"}, # no turnstile_token field at all
)
assert r.status_code == 400, r.text
assert _outbound_otc_envelopes("alice@example.com") == []
# ---------------------------------------------------------------------------
# Missing-secret-soft: no secret, TURNSTILE_REQUIRED=false (default) → admit
# ---------------------------------------------------------------------------
def test_otc_request_admits_when_secret_unset_and_not_required(app_with_fake_gitea, monkeypatch):
"""v0.12.0 default: the operator has not yet wired the Turnstile
secret and has not enabled `TURNSTILE_REQUIRED`. The gate stays
open this is the dev / test / pre-rollout path. Once the
operator confirms the secret is in place and flips
`TURNSTILE_REQUIRED=true`, missing-secret becomes fail-closed
(covered in test_otc_request_refuses_when_required_but_secret_unset).
"""
from fastapi.testclient import TestClient
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
# The httpx.post inside turnstile must not be called in this path —
# patch it to a sentinel that explodes if it ever runs.
from app import turnstile as turnstile_mod
def must_not_be_called(*a, **kw):
raise AssertionError("siteverify should not run when no secret is configured")
monkeypatch.setattr(turnstile_mod.httpx, "post", must_not_be_called)
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post(
"/auth/otc/request",
json={"email": "alice@example.com"},
)
assert r.status_code == 200, r.text
# The OTC path ran end-to-end — one envelope to the address.
assert len(_outbound_otc_envelopes("alice@example.com")) == 1
# ---------------------------------------------------------------------------
# Missing-secret-hard: no secret, TURNSTILE_REQUIRED=true → 500 "misconfigured"
# ---------------------------------------------------------------------------
def test_otc_request_refuses_when_required_but_secret_unset(app_with_fake_gitea, monkeypatch):
"""Once the operator has flipped `TURNSTILE_REQUIRED=true` to lock
down production, a missing secret stops being a soft-fail and
becomes a fail-closed 500. This is the regression-detection shape
the §20.4 upgrade-steps MAY block calls out flip the flag once
the secret is wired so a future config drift fails loudly instead
of silently disabling abuse defense.
"""
from fastapi.testclient import TestClient
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post(
"/auth/otc/request",
json={"email": "alice@example.com", "turnstile_token": "doesnt-matter"},
)
assert r.status_code == 500, r.text
assert _outbound_otc_envelopes("alice@example.com") == []
-38
View File
@@ -24,41 +24,3 @@ VITE_APP_NAME=
# VITE_BETA_CONTACT=ben@wiggleverse.org
# VITE_BETA_CONTACT=DM @ben on Matrix
VITE_BETA_CONTACT=
# Optional URL to the deployment's privacy policy (v0.13.0+, SPEC §14.5).
# The framework ships a minimal default privacy policy at `/privacy`
# that describes the framework's stance and lists the cookies the
# framework sets. When this var is set to an http(s) URL, the page
# renders the framework's stub above a link to the configured URL —
# deployments use this to layer their own policy content on top
# without forking the framework. Unset is OK; the stub is sufficient
# for a deployment that has nothing specific to add.
#
# Examples:
# VITE_PRIVACY_POLICY_URL=https://wiggleverse.org/privacy
VITE_PRIVACY_POLICY_URL=
# Optional URL to the deployment's cookies policy (v0.13.0+, SPEC §14.6).
# Same shape as VITE_PRIVACY_POLICY_URL. The framework's default
# `/cookies` page lists exactly which cookies the framework sets
# (rfc_session, the consent-choice localStorage entry); a deployment
# that adds its own cookies (analytics SDK once #13 lands, third-party
# embeds) points this var at a page that documents the full list.
# Unset is OK; the stub is sufficient for a default-config deployment.
#
# Examples:
# VITE_COOKIES_POLICY_URL=https://wiggleverse.org/cookies
VITE_COOKIES_POLICY_URL=
# v0.12.0 / roadmap item #10: CloudFlare Turnstile site key (public).
# Provision a Turnstile site at dash.cloudflare.com → Turnstile → Add
# site. The site key (this var) is embedded into the frontend bundle at
# build time and rendered by the Turnstile widget on the /login email-
# entry step. The secret key (private) lives in the backend env as
# CLOUDFLARE_TURNSTILE_SECRET — see backend/.env.example. Leave unset
# in dev to skip the widget; the backend's TURNSTILE_REQUIRED policy
# decides what happens to a tokenless request.
#
# Examples:
# VITE_TURNSTILE_SITE_KEY=0x4AAAAAAA...
VITE_TURNSTILE_SITE_KEY=
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rfc-app-frontend",
"version": "0.12.0",
"version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
"version": "0.12.0",
"version": "0.7.0",
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.12.0",
"version": "0.7.0",
"type": "module",
"scripts": {
"dev": "vite",
-139
View File
@@ -410,26 +410,6 @@
cursor: pointer; padding: 0;
}
.otc-login .btn-link-quiet:hover { color: #1a1a1a; text-decoration: underline; }
/* v0.8.0 — labels + textarea for the first-OTC profile capture step. */
.otc-field-label {
font-size: 12px; color: #666;
margin: 8px 0 -4px;
font-weight: 600;
}
.otc-login textarea {
width: 100%;
padding: 10px 12px;
font-size: 15px;
border: 1px solid #ddd;
border-radius: 6px;
box-sizing: border-box;
font-family: inherit;
resize: vertical;
}
.otc-login textarea:focus {
outline: none;
border-color: #1a1a1a;
}
.otc-shortcut-hint {
color: #888; font-size: 12px; margin: 4px 0 0;
}
@@ -486,22 +466,6 @@
.btn-link-quiet { color: #666; text-decoration: none; font-size: 13px; }
.btn-link-quiet:hover { color: #1a1a1a; text-decoration: underline; }
/* v0.8.0 thin "your beta access is in review" banner. Shown on every
page (other than /beta-pending itself, which carries the larger
form of the message). Sits just under the app header so it doesn't
compete with the catalog rail. */
.pending-access-banner {
background: #fff8e0;
border-bottom: 1px solid #e6dca0;
color: #4a3f00;
font-size: 13px;
padding: 8px 16px;
text-align: center;
}
.pending-access-banner a {
color: #4a3f00; text-decoration: underline;
}
/* ── §8 RFC view: three-column shape ─────────────────────────────────── */
.main-pane {
@@ -1983,106 +1947,3 @@
.discussion-readonly {
font-size: 12px; color: #666; padding: 4px 0;
}
/* §14.5 — cookie consent banner (v0.13.0) */
.cookie-consent-banner {
position: fixed;
left: 0; right: 0; bottom: 0;
z-index: 1000;
background: #fff;
border-top: 1px solid #d1d5db;
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08);
padding: 20px 24px;
}
.cookie-consent-body {
max-width: 880px; margin: 0 auto;
display: flex; flex-direction: column; gap: 12px;
}
.cookie-consent-title {
margin: 0; font-size: 16px; font-weight: 700; color: #111;
}
.cookie-consent-intro {
margin: 0; font-size: 13px; color: #4b5563; line-height: 1.5;
}
.cookie-consent-choices {
border: none; padding: 0; margin: 0;
display: flex; flex-direction: column; gap: 6px;
}
.cookie-consent-choice {
display: flex; gap: 10px; align-items: flex-start;
padding: 10px 12px; border-radius: 6px;
border: 1px solid #e5e7eb;
cursor: pointer;
}
.cookie-consent-choice.is-selected {
border-color: #111; background: #f9fafb;
}
.cookie-consent-choice input[type=radio] { margin-top: 3px; }
.cookie-consent-choice-text {
display: flex; flex-direction: column; gap: 2px;
}
.cookie-consent-choice-label {
font-size: 13px; font-weight: 600; color: #111;
}
.cookie-consent-choice-desc {
font-size: 12px; color: #6b7280; line-height: 1.5;
}
.cookie-consent-links {
margin: 0; font-size: 12px; color: #6b7280;
}
.cookie-consent-links a { color: #111; text-decoration: underline; }
.cookie-consent-error {
margin: 0; font-size: 12px; color: #b91c1c;
}
.cookie-consent-actions {
display: flex; gap: 8px; justify-content: flex-end;
}
.visually-hidden {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
/* §14.5 / §14.6 — privacy + cookies policy pages */
.policy-page {
max-width: 720px; margin: 0 auto;
padding: 0 32px 80px;
}
.policy-header {
display: flex; align-items: center; gap: 12px;
padding: 20px 0; border-bottom: 1px solid #f3f4f6;
margin-bottom: 24px;
}
.policy-back {
background: none; border: none; cursor: pointer;
color: #6b7280; font-size: 13px; padding: 4px 8px;
}
.policy-back:hover { color: #111; }
.policy-title { font-size: 13px; font-weight: 600; color: #6b7280; }
.policy-body { line-height: 1.7; color: #111; }
.policy-body h1 { font-size: 26px; margin: 0 0 6px; font-weight: 700; }
.policy-body .policy-subtitle { color: #6b7280; margin: 0 0 24px; font-size: 14px; }
.policy-body h2 { font-size: 16px; margin: 28px 0 8px; font-weight: 600; }
.policy-body p { margin: 0 0 12px; }
.policy-body ul { margin: 0 0 16px; padding-left: 22px; }
.policy-body li { margin-bottom: 6px; }
.policy-body code {
background: #f3f4f6; padding: 1px 5px; border-radius: 3px;
font-family: ui-monospace, monospace; font-size: 12px;
}
.policy-body .policy-footnote {
margin-top: 24px; font-size: 12px; color: #6b7280;
}
.policy-table {
width: 100%; border-collapse: collapse;
font-size: 13px; margin: 8px 0 16px;
}
.policy-table th, .policy-table td {
text-align: left; padding: 8px 10px;
border-bottom: 1px solid #f3f4f6;
vertical-align: top;
}
.policy-table th {
font-size: 11px; text-transform: uppercase;
color: #6b7280; letter-spacing: 0.05em; font-weight: 600;
}
+4 -67
View File
@@ -11,13 +11,9 @@ import Landing from './components/Landing.jsx'
import Login from './components/Login.jsx'
import BetaPending from './components/BetaPending.jsx'
import Philosophy from './components/Philosophy.jsx'
import Docs from './components/Docs.jsx'
import NotificationSettings from './components/NotificationSettings.jsx'
import Admin from './components/Admin.jsx'
import ToastHost, { showToast } from './components/ToastHost.jsx'
import CookieConsentBanner from './components/CookieConsentBanner.jsx'
import Privacy from './pages/Privacy.jsx'
import Cookies from './pages/Cookies.jsx'
import './App.css'
export default function App() {
@@ -28,19 +24,8 @@ export default function App() {
const [inboxOpen, setInboxOpen] = useState(false)
const [unreadCount, setUnreadCount] = useState(0)
const [inboxTick, setInboxTick] = useState(0)
// §14.5: a tick that, when bumped, asks <CookieConsentBanner> to
// re-open even if the user has already made a choice. The settings
// "Privacy & cookies" tab dispatches a `rfc-app:cookie-consent-reopen`
// event that bumps this.
const [consentReopenTick, setConsentReopenTick] = useState(0)
const navigate = useNavigate()
useEffect(() => {
const handler = () => setConsentReopenTick(t => t + 1)
window.addEventListener('rfc-app:cookie-consent-reopen', handler)
return () => window.removeEventListener('rfc-app:cookie-consent-reopen', handler)
}, [])
useEffect(() => {
getMe()
.then(setMe)
@@ -88,15 +73,11 @@ export default function App() {
// The deployment is in private beta: anonymous visitors get the full
// app in read-only mode (viewer = null is passed through to every
// component), and write affordances are hidden at the component
// level. v0.8.0 (§6.1 / item #6): authenticated users with
// `permission_state='pending'` also pass through as `viewer` with
// their state attached every write-gated affordance reads the
// state and treats pending the same as anonymous, while reads
// remain open. The /beta-pending page is the home root for a
// pending user.
// level. /beta-pending is the post-OAuth-rejection page reachable by
// anyone. The original §14.1 Landing surface is retained for the
// `/welcome` URL only, in case a deployment wants to link to it.
const viewer = me?.authenticated ? me.user : null
const isAdmin = viewer && (viewer.role === 'owner' || viewer.role === 'admin')
const isPending = viewer && viewer.permission_state === 'pending'
return (
<div className="app">
@@ -112,9 +93,6 @@ export default function App() {
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
About
</Link>
<Link to="/docs" className="header-about" title="User guide">
Docs
</Link>
{viewer && (
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
Settings
@@ -150,18 +128,12 @@ export default function App() {
)}
</div>
</header>
{isPending && <PendingAccessBanner />}
<div className="app-body">
<Routes>
<Route path="/welcome" element={<Landing />} />
<Route path="/login" element={<Login />} />
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
<Route path="/beta-pending" element={<BetaPending />} />
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
<Route path="/docs" element={<DocsWithSidebar viewer={viewer} />} />
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
Available to anonymous and authenticated viewers alike. */}
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
<Route path="/cookies" element={<PolicyShell><Cookies /></PolicyShell>} />
{viewer && (
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={viewer} />} />
)}
@@ -202,18 +174,10 @@ export default function App() {
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
)}
<ToastHost />
<CookieConsentBanner viewer={viewer} forceOpen={consentReopenTick} />
</div>
)
}
function PolicyShell({ children }) {
// §14.5 / §14.6 policy pages reuse the chrome-pane shape so they
// render full-width without the catalog rail. The components inside
// carry their own back affordance per Philosophy.jsx's pattern.
return <main className="chrome-pane">{children}</main>
}
function PhilosophyWithSidebar({ viewer }) {
// The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
// all use the full app body no catalog left pane, no propose modal.
@@ -226,14 +190,6 @@ function PhilosophyWithSidebar({ viewer }) {
)
}
function DocsWithSidebar({ viewer }) {
return (
<main className="chrome-pane">
<Docs authenticated={!!viewer} />
</main>
)
}
function NotificationSettingsWithSidebar({ viewer }) {
return (
<main className="chrome-pane">
@@ -250,26 +206,7 @@ function AdminWithSidebar({ viewer }) {
)
}
function PendingAccessBanner() {
// v0.8.0 thin banner shown on every page (other than /beta-pending
// itself, which carries the same message in larger form) when the
// signed-in user's `permission_state='pending'`. Sign-out works
// normally via the header affordance.
return (
<div className="pending-access-banner">
Your beta access request is in review.{' '}
<Link to="/beta-pending">Learn more </Link>
</div>
)
}
function Welcome({ viewer }) {
// v0.8.0 a pending user landing on "/" gets the same page they'd
// see at /beta-pending, inline. This is the post-OTC home root for
// a user awaiting admin grant.
if (viewer && viewer.permission_state === 'pending') {
return <BetaPending viewer={viewer} />
}
if (!viewer) {
return (
<div className="welcome">
+2 -90
View File
@@ -31,19 +31,11 @@ export async function getMe() {
// migration — the new UI just no longer points at it primarily. These
// two helpers drive the Login.jsx surface.
export async function requestOtc(email, { turnstileToken } = {}) {
// v0.12.0 / roadmap item #10: when the Turnstile widget has produced
// a token, send it alongside the email so the backend can siteverify
// before the OTC dispatch. The backend treats a missing token as
// either soft-fail (no secret wired AND TURNSTILE_REQUIRED=false)
// or hard-fail (verification required) — the frontend stays
// uninvolved in the policy.
const body = { email }
if (turnstileToken) body.turnstile_token = turnstileToken
export async function requestOtc(email) {
const res = await fetch('/auth/otc/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
body: JSON.stringify({ email }),
})
return jsonOrThrow(res)
}
@@ -57,65 +49,6 @@ export async function verifyOtc(email, code) {
return jsonOrThrow(res)
}
// ── v0.8.0: open beta-access request flow (§6.1 / §14.1) ─────────────────
//
// On the first OTC sign-in, the user lands in `permission_state='pending'`
// and `/api/auth/me` reports `needs_profile=true`. The Login.jsx surface
// then prompts for first/last/why and POSTs them here. After this lands,
// the user sees the /beta-pending page until an admin grants access.
export async function submitBetaRequest({ first_name, last_name, beta_request_reason }) {
const res = await fetch('/api/auth/me/beta-request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ first_name, last_name, beta_request_reason }),
})
return jsonOrThrow(res)
}
// ── v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8) ─────────
//
// After a successful OTC sign-in, a contributor may set a passcode and
// use email + passcode for subsequent sign-ins. OTC remains the
// forgot-passcode fallback — 5 consecutive verify failures locks the
// passcode path for 15 minutes (HTTP 423); the OTC path is unaffected.
export async function checkPasscode(email) {
// Anonymous endpoint. Returns `{has_passcode: boolean}` so the
// Login.jsx flow can decide whether to render a passcode input or
// fall back to OTC. We URL-encode the email so addresses with '+'
// round-trip cleanly.
const params = new URLSearchParams({ email })
const res = await fetch(`/auth/passcode/check?${params}`)
return jsonOrThrow(res)
}
export async function verifyPasscode(email, passcode) {
const res = await fetch('/auth/passcode/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, passcode }),
})
return jsonOrThrow(res)
}
export async function setPasscode(passcode) {
// Requires an active session — the server returns 401 if not signed
// in. The signed-in user is the implicit subject; the body carries
// only the new passcode.
const res = await fetch('/auth/passcode/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ passcode }),
})
return jsonOrThrow(res)
}
export async function clearPasscode() {
const res = await fetch('/auth/passcode', { method: 'DELETE' })
return jsonOrThrow(res)
}
export async function listRFCs() {
return jsonOrThrow(await fetch('/api/rfcs'))
}
@@ -599,23 +532,6 @@ export async function setQuietHours({ start, end, timezone } = {}) {
}))
}
// v0.13.0 / roadmap item #11: cookie consent (SPEC §14.5).
export async function getCookieConsent() {
return jsonOrThrow(await fetch('/api/users/me/cookie-consent'))
}
export async function setCookieConsent({ analytics, other } = {}) {
return jsonOrThrow(await fetch('/api/users/me/cookie-consent', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
essential: true,
analytics: !!analytics,
other: !!other,
}),
}))
}
export async function muteUser(userId) {
return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'POST' }))
}
@@ -640,10 +556,6 @@ export async function getPhilosophy() {
return jsonOrThrow(await fetch('/api/philosophy'))
}
export async function getDocs() {
return jsonOrThrow(await fetch('/api/docs'))
}
// ---------------------------------------------------------------------------
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
// typeahead).
+19 -40
View File
@@ -1,59 +1,38 @@
// BetaPending.jsx the "your request is in review" page (§6.1 / §14.1).
// BetaPending.jsx the post-OAuth-rejection page.
//
// v0.3.0 introduced this surface as the post-OAuth-rejection page (a
// user whose email wasn't on the `allowed_emails` table bounced here).
// v0.8.0 (roadmap item #6) repurposes it as the post-OTC pending-grant
// page: any authenticated user whose `permission_state='pending'` lands
// here on root visits, after a fresh-OTC profile capture, or via the
// header "Your beta access is in review" affordance.
//
// The deployment supplies a contact channel via VITE_BETA_CONTACT (an
// email, URL, or short instruction). If unset, we render a generic
// ask-the-operator line.
// When a deployment is in private-beta mode (i.e. its `allowed_emails`
// table has any rows), the OAuth callback redirects unrecognised users
// here instead of provisioning them. The framework cannot know the
// deployment operator's preferred contact channel so the deployment
// supplies one via VITE_BETA_CONTACT (an email, URL, or short
// instruction). If unset, we render a generic ask-the-operator line.
import { Link } from 'react-router-dom'
export default function BetaPending({ viewer }) {
export default function BetaPending() {
const contact = import.meta.env.VITE_BETA_CONTACT || ''
const isPending = viewer?.permission_state === 'pending'
return (
<div className="beta-pending">
<div className="beta-pending-inner">
<h1>
{isPending
? 'Your request is in review.'
: `${import.meta.env.VITE_APP_NAME} is in private Beta.`}
</h1>
{isPending ? (
<>
<p>
Thanks for telling us a bit about yourself. An admin will
review your request and get back to you as soon as we can.
</p>
<p>
While you wait, the catalog on the left lists every super-draft
and active RFC in the framework reading is open. Discussion
and contribution unlock once your access is granted.
</p>
</>
) : (
<p>
Discussion and contribution are gated to invited contributors for
now. Reading is open every super-draft, every active RFC, and
every public conversation is visible without signing in.
</p>
)}
<h1>{import.meta.env.VITE_APP_NAME} is in private Beta.</h1>
<p>
Discussion and contribution are gated to invited emails for now.
Reading is open every super-draft, every active RFC, and every
public conversation is visible without signing in.
</p>
{contact ? (
<p className="beta-pending-contact">
Questions? Contact <strong>{contact}</strong>.
To request access, contact <strong>{contact}</strong> with the
email address you'd like to sign in with.
</p>
) : (
<p className="beta-pending-contact">
Questions? Contact the deployment operator.
To request access, contact the deployment operator with the email
address you'd like to sign in with.
</p>
)}
<div className="beta-pending-actions">
<Link className="btn-primary" to="/">Browse the catalog</Link>
<Link className="btn-primary" to="/">Browse as a guest</Link>
<Link className="btn-link-quiet" to="/philosophy">Read the philosophy </Link>
</div>
</div>
@@ -1,164 +0,0 @@
// CookieConsentBanner.jsx v0.13.0 / roadmap item #11 / SPEC §14.5.
//
// A non-modal bottom-of-page banner that asks the user once which
// categories of cookies they accept. The framework's strictly-necessary
// cookies (session, signed payloads) are always on; the user can opt in
// or out of analytics (which gates the §13 SDK landing in v0.15.0) and
// "other" (third-party embeds, social widgets if a deployment adds any).
//
// Visible until the user makes a choice. Hides itself once the choice
// is recorded. The /settings/notifications "Privacy & cookies" tab
// surfaces the current choice and re-opens the banner via `forceOpen`.
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { getConsent, setConsent, hasChosen, hydrateFromServer } from '../lib/consent.js'
import { getCookieConsent, setCookieConsent } from '../api.js'
const CATEGORIES = [
{
key: 'essential-only',
label: 'Essential only',
description: 'Just the cookies the app needs to keep you signed in and protect submissions. (Sign-in session, signed payloads.)',
flags: { analytics: false, other: false },
},
{
key: 'essential-analytics',
label: 'Essential + analytics',
description: 'Adds anonymous usage analytics so the framework can see which surfaces get used. No third-party scripts beyond the analytics SDK.',
flags: { analytics: true, other: false },
},
{
key: 'essential-analytics-other',
label: 'Essential + analytics + other',
description: 'Adds analytics plus any third-party embeds the deployment configures (e.g. social widgets). Choose this if you want the full surface.',
flags: { analytics: true, other: true },
},
]
function selectionKeyFor(consent) {
if (consent.analytics && consent.other) return 'essential-analytics-other'
if (consent.analytics && !consent.other) return 'essential-analytics'
return 'essential-only'
}
export default function CookieConsentBanner({ viewer, forceOpen, onClosed }) {
const [open, setOpen] = useState(() => forceOpen || !hasChosen())
const [choice, setChoice] = useState(() => selectionKeyFor(getConsent()))
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
// When forceOpen flips (settings "Change" affordance), re-render the
// banner and pre-select the user's current choice.
useEffect(() => {
if (forceOpen) {
setOpen(true)
setChoice(selectionKeyFor(getConsent()))
}
}, [forceOpen])
// Server-side hydrate for authenticated viewers per the v0.13.0
// precedence rule: a server row overrides local; absent server row,
// upload the local choice.
useEffect(() => {
if (!viewer?.user_id) return
let cancelled = false
getCookieConsent()
.then(record => {
if (cancelled) return
if (record.recorded_at) {
// Server is authoritative adopt + hide the banner unless
// the settings page forced it open.
hydrateFromServer(record)
setChoice(selectionKeyFor(record))
if (!forceOpen) setOpen(false)
} else if (hasChosen()) {
// Local has a choice the server doesn't know about yet push.
const local = getConsent()
setCookieConsent({ analytics: local.analytics, other: local.other })
.then(r => hydrateFromServer(r))
.catch(() => {})
}
})
.catch(() => {
// Network or auth error leave the local-only path in place.
})
return () => { cancelled = true }
}, [viewer?.user_id]) // eslint-disable-line react-hooks/exhaustive-deps
if (!open) return null
async function save() {
setSaving(true)
setError(null)
const picked = CATEGORIES.find(c => c.key === choice) || CATEGORIES[0]
try {
setConsent(picked.flags)
if (viewer?.user_id) {
// Best-effort server persistence. A failure here doesn't
// invalidate the local choice; the banner still hides because
// the user expressed their preference. The server can catch up
// on the next sign-in via the hydrate path above.
try {
const r = await setCookieConsent(picked.flags)
hydrateFromServer(r)
} catch (e) {
setError(`Saved locally; server sync failed (${e.message}).`)
}
}
setOpen(false)
onClosed?.()
} finally {
setSaving(false)
}
}
return (
<div className="cookie-consent-banner" role="region" aria-label="Cookie consent">
<div className="cookie-consent-body">
<h2 className="cookie-consent-title">Cookies &amp; privacy</h2>
<p className="cookie-consent-intro">
This site uses cookies. Essential cookies keep you signed in and
protect your submissions; analytics and other cookies are
optional. Choose what you allow you can change this any time
from <Link to="/settings/notifications">Settings &rarr; Privacy &amp; cookies</Link>.
</p>
<fieldset className="cookie-consent-choices">
<legend className="visually-hidden">Cookie categories</legend>
{CATEGORIES.map(c => (
<label key={c.key} className={`cookie-consent-choice ${choice === c.key ? 'is-selected' : ''}`}>
<input
type="radio"
name="cookie-consent-choice"
value={c.key}
checked={choice === c.key}
onChange={() => setChoice(c.key)}
disabled={saving}
/>
<span className="cookie-consent-choice-text">
<span className="cookie-consent-choice-label">{c.label}</span>
<span className="cookie-consent-choice-desc">{c.description}</span>
</span>
</label>
))}
</fieldset>
<p className="cookie-consent-links">
<Link to="/cookies">Cookies policy</Link>
<span aria-hidden> &middot; </span>
<Link to="/privacy">Privacy policy</Link>
</p>
{error && <p className="cookie-consent-error">{error}</p>}
<div className="cookie-consent-actions">
<button
type="button"
className="btn-primary"
onClick={save}
disabled={saving}
>
{saving ? 'Saving…' : 'Save choice'}
</button>
</div>
</div>
</div>
)
}
-51
View File
@@ -1,51 +0,0 @@
// `/docs` the user-facing guide.
//
// Sibling of Philosophy.jsx: same chrome, same data path, different
// source file. Renders DOCS.md verbatim with light chrome around it.
// Reachable anonymously, same as `/philosophy`, so a visitor can read
// the guide before deciding to sign in.
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import MarkdownPreview from './MarkdownPreview.jsx'
import { getDocs } from '../api.js'
export default function Docs({ authenticated }) {
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => {
let active = true
getDocs()
.then(r => { if (active) setBody(r.body || '') })
.catch(e => { if (active) setError(e.message || String(e)) })
.finally(() => { if (active) setLoading(false) })
return () => { active = false }
}, [])
return (
<div className="philosophy-page">
<header className="philosophy-header">
<button
className="philosophy-back"
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
>
Back
</button>
<span className="philosophy-title">User guide</span>
{!authenticated && (
<Link className="philosophy-signin" to="/">Home</Link>
)}
</header>
<article className="philosophy-body">
{loading && <p className="muted">Loading</p>}
{error && <p className="error">Could not load the guide: {error}</p>}
{!loading && !error && (
<MarkdownPreview content={body} />
)}
</article>
</div>
)
}
+34 -507
View File
@@ -1,131 +1,41 @@
// Login.jsx the composed sign-in surface (§6.2) after the v0.12.0
// (CloudFlare Turnstile gate on OTC dispatch, roadmap item #10) /
// v0.10.0 (passcodes, roadmap item #8) rebase onto v0.8.0
// (beta-access-request capture, §6.1 / §14.1, roadmap item #6). v0.7.0
// (roadmap item #5) established the email + OTC scaffolding the later
// releases extended.
// Login.jsx v0.7.0's primary sign-in surface (§6.2).
//
// v0.12.0: the email-entry step renders a Turnstile widget. The token
// it produces is sent to `/auth/otc/request` alongside the email. The
// passcode step also renders a widget for the "Use a code instead"
// fallback dispatch (same backend endpoint, same gate). When the
// `VITE_TURNSTILE_SITE_KEY` build var is unset, the widget renders
// nothing the form still submits and the backend's TURNSTILE_REQUIRED
// policy decides admission.
// 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.
//
// Four-to-six-step flow (most users see three; the longest path is
// pending-user with no passcode, who never sees the passcode steps):
// 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.
//
// 1. 'email' Enter email GET /auth/passcode/check.
// * has_passcode=true step 'passcode'.
// * has_passcode=false POST /auth/otc/request,
// step 'code'.
// 429 on either dispatch surfaces a "wait a
// moment" hint and keeps the user on step 1.
//
// 2a. 'passcode' Enter passcode POST /auth/passcode/verify.
// * 200 redirect to "/".
// * 423 (lockout, 5 consecutive failures)
// auto-fall back to OTC by requesting a fresh
// code and advancing to step 'code'.
// * 400 wrong passcode; user can retry or
// click "Use a code instead" to fall back
// manually.
//
// 2b. 'code' Enter the six-digit code POST /auth/otc/verify.
// On 200, fetch /api/auth/me and branch:
// * needs_profile === true 'capture-profile'
// * has_passcode === false 'offer-passcode'
// * otherwise redirect to "/".
// needs_profile WINS over has_passcode a
// pending user goes through the §6.1 capture
// flow first; setting a passcode while waiting
// for admin grant gains them nothing.
// Cmd/Ctrl+Enter on the code field is the
// keyboard shortcut.
//
// 3. 'capture-profile' (v0.8.0, §6.1) First name, last name, and "why
// I should be included in the beta" POST
// /api/auth/me/beta-request redirect to
// /beta-pending. The user's row stays
// permission_state='pending' until an admin
// grants access; they can set a passcode later
// from settings, or on a future sign-in once
// granted.
//
// 4a. 'offer-passcode' (v0.10.0) "Set a passcode for faster sign-in
// next time?" Yes 'set-passcode'. Skip "/".
//
// 4b. 'set-passcode' Pick a passcode (420 chars) POST
// /auth/passcode/set redirect to "/". A
// "Skip for now" link also redirects to "/".
//
// Server-side, /auth/otc/request returns 202 uniformly and
// /auth/passcode/check returns has_passcode=false for an unknown
// email, so this surface never distinguishes "we couldn't reach you"
// from "we don't know you" an unknown email always lands in the
// OTC path with no account-enumeration signal.
//
// 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. We hide
// the fallback on 'capture-profile' so a half-captured pending user
// doesn't bail out into the OAuth path mid-form.
// 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,
submitBetaRequest,
checkPasscode,
verifyPasscode,
setPasscode as apiSetPasscode,
} from '../api'
import TurnstileWidget, { turnstileEnabled } from './TurnstileWidget'
import { requestOtc, verifyOtc } from '../api'
export default function Login() {
// Steps: 'email' 'passcode' or 'code' (on the OTC path, after
// verify) one of: 'capture-profile' (pending user), 'offer-passcode'
// (no passcode yet), or straight to "/". 'set-passcode' is reached
// from 'offer-passcode'.
const [step, setStep] = useState('email')
const [email, setEmail] = useState('')
const [code, setCode] = useState('')
const [passcode, setPasscode] = useState('')
const [newPasscode, setNewPasscode] = useState('')
// v0.8.0 capture-profile fields.
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [reason, setReason] = useState('')
const [status, setStatus] = useState('')
const [busy, setBusy] = useState(false)
// v0.12.0: Turnstile token captured by the widget. `null` means no
// challenge solved yet (or the site key is unset, in which case the
// widget surfaces null on mount). The token is single-use; we clear
// it back to null right after we send it so a second request on the
// same form remount re-challenges. `turnstileReady` is true once the
// widget has produced a token OR the widget is not configured at
// build time (no site key) the submit button reads from it so the
// form locks up when the operator has wired Turnstile but the user
// hasn't solved the challenge yet.
const [turnstileToken, setTurnstileToken] = useState(null)
const turnstileOn = turnstileEnabled()
const turnstileReady = !turnstileOn || !!turnstileToken
const emailRef = useRef(null)
const codeRef = useRef(null)
const passcodeRef = useRef(null)
const newPasscodeRef = useRef(null)
const firstNameRef = useRef(null)
const navigate = useNavigate()
useEffect(() => {
if (step === 'email') emailRef.current?.focus()
else if (step === 'code') codeRef.current?.focus()
else if (step === 'passcode') passcodeRef.current?.focus()
else if (step === 'capture-profile') firstNameRef.current?.focus()
else if (step === 'set-passcode') newPasscodeRef.current?.focus()
else codeRef.current?.focus()
}, [step])
async function submitEmail(e) {
@@ -137,93 +47,20 @@ export default function Login() {
setBusy(true)
setStatus('')
try {
const { has_passcode } = await checkPasscode(email.trim())
if (has_passcode) {
setStep('passcode')
setStatus('')
} else {
await requestOtc(email.trim(), { turnstileToken })
// v0.12.0: the token is single-use; drop it so a re-request
// from the code step (via "Use a different email" back to
// email) starts with a fresh challenge.
setTurnstileToken(null)
setStep('code')
setStatus('Check your inbox — a six-digit code is on the way.')
}
await requestOtc(email.trim())
setStep('code')
setStatus('Check your inbox — a six-digit code is on the way.')
} catch (err) {
// Any failure consumes the token from CloudFlare's side; clear
// so the widget re-renders a fresh challenge on retry.
setTurnstileToken(null)
if (err.status === 429) {
setStatus('Slow down — wait a minute before requesting another code.')
} else if (err.status === 400) {
setStatus("Couldn't verify you're human. Please retry the challenge.")
} else {
setStatus(err.message || 'Could not start sign-in. Try again.')
setStatus(err.message || 'Could not request a code. Try again.')
}
} finally {
setBusy(false)
}
}
async function submitPasscode(e) {
if (e) e.preventDefault()
if (!passcode.trim()) {
setStatus('Enter your passcode.')
return
}
setBusy(true)
setStatus('')
try {
await verifyPasscode(email.trim(), passcode.trim())
// Reload so App.jsx's getMe() picks up the fresh session. A
// returning passcode user is by definition already past the
// §6.1 capture step (they couldn't have set a passcode while
// pending), so we go straight to "/".
window.location.assign('/')
} catch (err) {
if (err.status === 423) {
// Lockout auto-fall back to OTC. The OTC request endpoint
// is independent of the passcode lockout, so this lands a
// fresh code in the user's inbox immediately.
setPasscode('')
try {
// v0.12.0: pass whatever token the widget on the passcode
// step has produced. If the operator has Turnstile required
// and the user hasn't solved the passcode-step widget, the
// backend refuses and we bounce them back to email-entry
// with a clear status (see catch below).
await requestOtc(email.trim(), { turnstileToken })
setTurnstileToken(null)
setStep('code')
setStatus(
'Too many failed attempts. We sent a one-time code to your email — use it to sign in.',
)
} catch (e2) {
setTurnstileToken(null)
if (e2.status === 429) {
setStep('code')
setStatus(
'Too many failed attempts. Wait a minute, then request a one-time code to sign in.',
)
} else if (e2.status === 400) {
setStep('email')
setStatus(
'Too many failed attempts. Solve the challenge below to receive a one-time code.',
)
} else {
setStatus(
'Too many failed attempts. Use the "Use a code instead" link to sign in via email.',
)
}
}
} else {
setStatus('Wrong passcode. Try again, or use a one-time code instead.')
}
setBusy(false)
}
}
async function submitCode(e) {
if (e) e.preventDefault()
if (!code.trim() || code.trim().length !== 6) {
@@ -234,38 +71,9 @@ export default function Login() {
setStatus('')
try {
await verifyOtc(email.trim(), code.trim())
// OTC verified the server has signed in the user. Fetch the
// canonical /api/auth/me to decide where to land:
// * needs_profile §6.1 capture (then /beta-pending).
// * no passcode §6.2 offer-passcode (then /).
// * otherwise /.
// needs_profile wins over has_passcode: a pending user can't yet
// do anything that benefits from faster sign-in, so we don't
// distract them with the passcode offer mid-admission.
const meResp = await fetch('/api/auth/me', { credentials: 'include' })
let me = null
if (meResp.ok) {
try {
me = await meResp.json()
} catch (_) {
me = null
}
}
if (me?.needs_profile === true) {
setStep('capture-profile')
setStatus('')
setBusy(false)
return
}
if (me?.has_passcode === false) {
setStep('offer-passcode')
setStatus('')
setBusy(false)
return
}
// Either /me returned the granted-with-passcode shape, or the
// call failed but the session cookie is set fall through to
// a hard reload so App.jsx re-fetches and renders accordingly.
// 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.')
@@ -273,54 +81,6 @@ export default function Login() {
}
}
async function submitProfile(e) {
if (e) e.preventDefault()
const fn = firstName.trim()
const ln = lastName.trim()
const why = reason.trim()
if (!fn || !ln || !why) {
setStatus('All three fields are required.')
return
}
setBusy(true)
setStatus('')
try {
await submitBetaRequest({
first_name: fn,
last_name: ln,
beta_request_reason: why,
})
// Hard-load so App.jsx re-fetches /api/auth/me and picks up
// the captured fields. The user stays permission_state='pending'
// until an admin grants access the next thing they should
// see is the "your request is in review" page.
window.location.assign('/beta-pending')
} catch (err) {
setStatus(err.message || 'Could not submit your request. Try again.')
setBusy(false)
}
}
async function submitNewPasscode(e) {
if (e) e.preventDefault()
const pc = newPasscode.trim()
if (pc.length < 4) {
setStatus('Passcode must be at least 4 characters.')
return
}
setBusy(true)
setStatus('')
try {
await apiSetPasscode(pc)
window.location.assign('/')
} catch (err) {
// 422 carries the validation message verbatim (denylist /
// length); surface it as-is so the user knows what to change.
setStatus(err.message || 'Could not set passcode. Try a different one.')
setBusy(false)
}
}
function onCodeKey(e) {
// §6.2 ergonomic: Cmd/Ctrl+Enter submits from the code field.
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
@@ -328,69 +88,12 @@ export default function Login() {
}
}
function onPasscodeKey(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
submitPasscode(e)
}
}
function onReasonKey(e) {
// §6.1 ergonomic: Cmd/Ctrl+Enter submits the capture form from
// the reason textarea (the multi-line input that would otherwise
// swallow Enter as a newline).
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
submitProfile(e)
}
}
function onNewPasscodeKey(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
submitNewPasscode(e)
}
}
function backToEmail() {
setStep('email')
setCode('')
setPasscode('')
setStatus('')
}
async function fallbackToOtc() {
// Manual "Use a code instead" from the passcode step. Same shape
// as the email-step OTC dispatch. v0.12.0: pass through whatever
// Turnstile token the passcode-step widget has produced (or null
// when the widget is disabled at build time).
setBusy(true)
setStatus('')
try {
await requestOtc(email.trim(), { turnstileToken })
setTurnstileToken(null)
setPasscode('')
setStep('code')
setStatus('Check your inbox — a six-digit code is on the way.')
} catch (err) {
setTurnstileToken(null)
if (err.status === 429) {
setStatus('Slow down — wait a minute before requesting another code.')
} else if (err.status === 400) {
// v0.12.0: the widget rejected or no token was sent. Bounce
// the user back to the email step so they get a fresh
// challenge alongside the email input.
setStep('email')
setStatus("Couldn't verify you're human. Please retry the challenge.")
} else {
setStatus(err.message || 'Could not request a code. Try again.')
}
} finally {
setBusy(false)
}
}
function skipPasscodeOffer() {
window.location.assign('/')
}
return (
<div className="otc-login">
<div className="otc-login-inner">
@@ -398,8 +101,7 @@ export default function Login() {
{step === 'email' && (
<form onSubmit={submitEmail}>
<p className="otc-hint">
Enter your email. If you've set a passcode, you'll enter that
next; otherwise we'll send a one-time code.
Enter your email. We'll send you a one-time code.
</p>
<input
ref={emailRef}
@@ -411,72 +113,11 @@ export default function Login() {
required
disabled={busy}
/>
{/*
v0.12.0: CloudFlare Turnstile widget. Renders nothing
when VITE_TURNSTILE_SITE_KEY is unset (and turnstileReady
defaults to true in that case so the submit gate doesn't
lock up). On every challenge the widget calls onToken
with the fresh token; we feed it to /auth/otc/request.
*/}
<TurnstileWidget onToken={setTurnstileToken} />
<button
type="submit"
disabled={busy || !email.trim() || !turnstileReady}
>
{busy ? 'Checking…' : 'Continue'}
<button type="submit" disabled={busy || !email.trim()}>
{busy ? 'Sending…' : 'Send code'}
</button>
</form>
)}
{step === 'passcode' && (
<form onSubmit={submitPasscode}>
<p className="otc-hint">
Enter the passcode for <strong>{email}</strong>.
</p>
<input
ref={passcodeRef}
type="password"
autoComplete="current-password"
value={passcode}
onChange={e => setPasscode(e.target.value)}
onKeyDown={onPasscodeKey}
placeholder="Your passcode"
required
disabled={busy}
/>
{/*
v0.12.0: a second Turnstile widget for the
"Use a code instead" fallback dispatch. The passcode
verify path does not consume a Turnstile token (it has
its own 5-attempt lockout shape from v0.10.0), but if
the user falls back to OTC the same /auth/otc/request
endpoint runs and needs a token. We render the widget
on this step too so the fallback works without bouncing
back to email-entry first.
*/}
<TurnstileWidget onToken={setTurnstileToken} />
<div className="otc-actions">
<button type="submit" disabled={busy || !passcode.trim()}>
{busy ? 'Signing in…' : 'Sign in'}
</button>
<button
type="button"
className="btn-link-quiet"
onClick={fallbackToOtc}
disabled={busy || !turnstileReady}
>
Use a code instead
</button>
<button
type="button"
className="btn-link-quiet"
onClick={backToEmail}
disabled={busy}
>
Use a different email
</button>
</div>
</form>
)}
{step === 'code' && (
<form onSubmit={submitCode}>
<p className="otc-hint">
@@ -514,126 +155,12 @@ export default function Login() {
</p>
</form>
)}
{step === 'capture-profile' && (
<form onSubmit={submitProfile}>
<p className="otc-hint">
You're signed in. {import.meta.env.VITE_APP_NAME} is in private
beta tell us a bit about yourself and an admin will review
your request.
</p>
<label className="otc-field-label">First name</label>
<input
ref={firstNameRef}
type="text"
autoComplete="given-name"
value={firstName}
onChange={e => setFirstName(e.target.value)}
required
disabled={busy}
maxLength={120}
/>
<label className="otc-field-label">Last name</label>
<input
type="text"
autoComplete="family-name"
value={lastName}
onChange={e => setLastName(e.target.value)}
required
disabled={busy}
maxLength={120}
/>
<label className="otc-field-label">
Why you'd like to be included in the beta
</label>
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
onKeyDown={onReasonKey}
required
disabled={busy}
rows={5}
maxLength={4000}
placeholder="A sentence or two is plenty."
/>
<div className="otc-actions">
<button
type="submit"
disabled={busy || !firstName.trim() || !lastName.trim() || !reason.trim()}
>
{busy ? 'Submitting…' : 'Submit request'}
</button>
</div>
<p className="otc-shortcut-hint">
Tip: <kbd></kbd>+<kbd>Enter</kbd> (or <kbd>Ctrl</kbd>+<kbd>Enter</kbd>) to submit.
</p>
</form>
)}
{step === 'offer-passcode' && (
<div className="otc-offer-passcode">
<p className="otc-hint">
You're signed in. Want to set a passcode for faster sign-in
next time? You can always use a one-time code instead and
you can change or remove the passcode from your settings.
</p>
<div className="otc-actions">
<button
type="button"
onClick={() => { setStep('set-passcode'); setStatus('') }}
>
Set a passcode
</button>
<button
type="button"
className="btn-link-quiet"
onClick={skipPasscodeOffer}
>
Skip for now
</button>
</div>
</div>
)}
{step === 'set-passcode' && (
<form onSubmit={submitNewPasscode}>
<p className="otc-hint">
Pick a passcode (420 characters). You'll use it with your
email to sign in next time.
</p>
<input
ref={newPasscodeRef}
type="password"
autoComplete="new-password"
value={newPasscode}
onChange={e => setNewPasscode(e.target.value)}
onKeyDown={onNewPasscodeKey}
placeholder="New passcode"
required
disabled={busy}
minLength={4}
maxLength={20}
/>
<div className="otc-actions">
<button type="submit" disabled={busy || newPasscode.trim().length < 4}>
{busy ? 'Saving…' : 'Save passcode'}
</button>
<button
type="button"
className="btn-link-quiet"
onClick={skipPasscodeOffer}
disabled={busy}
>
Skip for now
</button>
</div>
</form>
)}
{status && <p className="otc-status">{status}</p>}
{step !== 'capture-profile' && (
<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>
)}
<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,12 +28,7 @@ import {
unmuteUser,
muteUser,
searchUsers,
getCookieConsent,
getMe,
setPasscode,
clearPasscode,
} 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.'
@@ -53,223 +48,10 @@ export default function NotificationSettings({ viewer }) {
<QuietHoursSection />
<WatchesSection />
<MutesSection viewer={viewer} />
<SignInSection />
<PrivacyCookiesSection />
</div>
)
}
// §6.2 sign-in (v0.10.0 / roadmap item #8): passcode management
function SignInSection() {
// Source of truth for `has_passcode` and `passcode_set_at` is the
// /api/auth/me payload (v0.10.0 added both fields). We re-read after
// every mutation so the surface reflects what just landed.
const [me, setMe] = useState(null)
const [error, setError] = useState(null)
const [mode, setMode] = useState('idle') // 'idle' | 'set' | 'change'
const [draft, setDraft] = useState('')
const [busy, setBusy] = useState(false)
const [savedNote, setSavedNote] = useState('')
useEffect(() => {
getMe()
.then(payload => setMe(payload.user || null))
.catch(e => setError(e.message))
}, [])
async function refresh() {
const payload = await getMe()
setMe(payload.user || null)
}
async function save(e) {
if (e) e.preventDefault()
const pc = draft.trim()
if (pc.length < 4) {
setError('Passcode must be at least 4 characters.')
return
}
setBusy(true)
setError(null)
setSavedNote('')
try {
await setPasscode(pc)
setDraft('')
setMode('idle')
setSavedNote('Passcode saved.')
await refresh()
} catch (err) {
// 422 carries the validation message verbatim (denylist /
// length); surface it as-is so the user knows what to change.
setError(err.message || 'Could not save passcode. Try a different one.')
} finally {
setBusy(false)
setTimeout(() => setSavedNote(''), 2000)
}
}
async function remove() {
if (!confirm('Remove your passcode? You will sign in with a one-time code next time.')) {
return
}
setBusy(true)
setError(null)
setSavedNote('')
try {
await clearPasscode()
setSavedNote('Passcode removed.')
await refresh()
} catch (err) {
setError(err.message || 'Could not remove passcode.')
} finally {
setBusy(false)
setTimeout(() => setSavedNote(''), 2000)
}
}
if (!me) return <SectionShell title="Sign-in" subtitle={error || 'Loading…'} />
const hasPasscode = !!me.has_passcode
return (
<SectionShell
title="Sign-in"
subtitle="How you sign in. A passcode lets you skip the one-time-code email; the one-time-code path is always available as a fallback (and as the recovery path if you forget your passcode)."
>
<div className="settings-row">
<span className="settings-note">
<strong>Passcode:</strong>{' '}
{hasPasscode ? 'Set.' : 'Not set — you sign in with a one-time code each time.'}
</span>
</div>
{hasPasscode && me.passcode_set_at && (
<p className="settings-note muted">Set on {me.passcode_set_at}.</p>
)}
{mode === 'idle' && (
<div className="settings-row">
{hasPasscode ? (
<>
<button
className="btn-primary"
onClick={() => { setMode('change'); setDraft(''); setError(null) }}
disabled={busy}
>
Change passcode
</button>
<button
className="btn-link-muted"
onClick={remove}
disabled={busy}
>
Remove passcode
</button>
</>
) : (
<button
className="btn-primary"
onClick={() => { setMode('set'); setDraft(''); setError(null) }}
disabled={busy}
>
Set passcode
</button>
)}
</div>
)}
{(mode === 'set' || mode === 'change') && (
<form className="settings-row" onSubmit={save}>
<label>
{mode === 'change' ? 'New passcode' : 'Passcode'}
<input
type="password"
autoComplete="new-password"
value={draft}
onChange={e => setDraft(e.target.value)}
placeholder="420 characters"
minLength={4}
maxLength={20}
required
disabled={busy}
/>
</label>
<button className="btn-primary" type="submit" disabled={busy || draft.trim().length < 4}>
{busy ? 'Saving…' : 'Save'}
</button>
<button
type="button"
className="btn-link-muted"
onClick={() => { setMode('idle'); setDraft(''); setError(null) }}
disabled={busy}
>
Cancel
</button>
</form>
)}
{savedNote && <p className="settings-note">{savedNote}</p>}
{error && <p className="settings-note warning">{error}</p>}
</SectionShell>
)
}
// §14.5 cookie / privacy consent (v0.13.0 / roadmap item #11)
function PrivacyCookiesSection() {
const [consent, setConsent] = useState(() => getConsent())
useEffect(() => {
// Pull the server-side row on mount; if it has a recorded_at the
// local snapshot is updated via hydrate.
getCookieConsent()
.then(record => { if (record.recorded_at) hydrateFromServer(record) })
.catch(() => {})
return onConsentChange(next => setConsent(next))
}, [])
function reopenBanner() {
// App.jsx listens for this event and bumps the forceOpen tick on
// <CookieConsentBanner>. The banner pre-selects the current choice
// from the snapshot, so the user can revise rather than restart.
window.dispatchEvent(new CustomEvent('rfc-app:cookie-consent-reopen'))
}
const summary = (() => {
if (!consent.recorded_at) {
return 'No choice recorded — the consent banner is being shown to you.'
}
if (consent.analytics && consent.other) {
return 'Essential + analytics + other.'
}
if (consent.analytics) {
return 'Essential + analytics.'
}
return 'Essential only.'
})()
return (
<SectionShell
title="Privacy & cookies"
subtitle="What categories of cookies you've allowed. Essential cookies are always on; analytics and other categories are opt-in."
>
<div className="settings-row">
<span className="settings-note"><strong>Current choice:</strong> {summary}</span>
</div>
{consent.recorded_at && (
<p className="settings-note muted">Recorded {consent.recorded_at}.</p>
)}
<div className="settings-row">
<button className="btn-primary" onClick={reopenBanner}>
Change
</button>
<Link to="/cookies" className="btn-link-muted">Cookies policy</Link>
<Link to="/privacy" className="btn-link-muted">Privacy policy</Link>
</div>
</SectionShell>
)
}
// §15.4 email category toggles
function EmailPreferencesSection() {
-120
View File
@@ -1,120 +0,0 @@
// TurnstileWidget.jsx v0.12.0 / roadmap item #10.
//
// Renders the CloudFlare Turnstile JS widget on the email-entry step of
// `/login`. Reads the site key from `import.meta.env.VITE_TURNSTILE_SITE_KEY`
// (Vite convention VITE_* prefix is build-time embedded). When the
// site key is unset/empty, this component renders nothing and reports
// a `null` token through `onToken` so the parent form can still submit.
// The backend's `TURNSTILE_REQUIRED` policy decides what happens to a
// request that arrives without a token; the frontend is intentionally
// not in that loop. See `backend/app/turnstile.py` for the matrix.
//
// The CloudFlare script is loaded once per page on first widget mount.
// Subsequent mounts (e.g. user goes back to email-entry after a failed
// OTC request) reuse the script tag and re-render the widget on the
// fresh container `div`. Unmounting removes the widget instance via
// `turnstile.remove(widgetId)` so a remount produces a new challenge
// rather than reusing a stale, already-consumed token.
//
// Turnstile contract:
// * `data-callback` fires with the token string on a successful
// challenge; the token is single-use and expires after ~5 minutes.
// * `data-error-callback` fires on a failed challenge (network,
// blocked, etc.); we surface a `null` token so the parent shows
// a retry hint.
// * `data-expired-callback` fires when the token times out before
// submission; we also drop to `null` and re-render so the user
// gets a fresh challenge on retry.
//
// We do **not** import the CloudFlare script at build time; loading it
// dynamically here keeps the bundle clean of an external request the
// page may not need (anonymous viewers reading RFCs never see Login).
import { useEffect, useRef } from 'react'
const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
const SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
// Promise-keyed: only one script tag, only one resolution chain.
let scriptLoadPromise = null
function loadTurnstileScript() {
if (typeof window === 'undefined') return Promise.resolve(null)
if (window.turnstile) return Promise.resolve(window.turnstile)
if (scriptLoadPromise) return scriptLoadPromise
scriptLoadPromise = new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${TURNSTILE_SCRIPT_URL}"]`)
if (existing) {
existing.addEventListener('load', () => resolve(window.turnstile))
existing.addEventListener('error', reject)
return
}
const script = document.createElement('script')
script.src = TURNSTILE_SCRIPT_URL
script.async = true
script.defer = true
script.addEventListener('load', () => resolve(window.turnstile))
script.addEventListener('error', reject)
document.head.appendChild(script)
})
return scriptLoadPromise
}
export function turnstileEnabled() {
return !!SITE_KEY
}
export default function TurnstileWidget({ onToken, theme = 'auto' }) {
const containerRef = useRef(null)
const widgetIdRef = useRef(null)
useEffect(() => {
if (!SITE_KEY) {
// No site key configured surface a null token immediately so
// the parent form's submit-disabled gate doesn't lock up
// waiting on a challenge that will never arrive. The backend
// decides whether a tokenless request is admitted.
onToken?.(null)
return undefined
}
let cancelled = false
loadTurnstileScript()
.then(turnstile => {
if (cancelled || !turnstile || !containerRef.current) return
widgetIdRef.current = turnstile.render(containerRef.current, {
sitekey: SITE_KEY,
theme,
callback: token => onToken?.(token),
'error-callback': () => onToken?.(null),
'expired-callback': () => onToken?.(null),
})
})
.catch(() => {
// Script load failure surface null so the parent can decide
// what to do (today: still let submit through; the backend
// policy decides admission).
if (!cancelled) onToken?.(null)
})
return () => {
cancelled = true
if (widgetIdRef.current && window.turnstile) {
try {
window.turnstile.remove(widgetIdRef.current)
} catch (_) {
// Already gone or never registered nothing to clean up.
}
widgetIdRef.current = null
}
}
// We intentionally do not list `onToken` in the dependency array;
// a parent re-rendering with a fresh closure should not tear down
// and rebuild the widget (which would consume a fresh challenge).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (!SITE_KEY) return null
return <div ref={containerRef} className="turnstile-widget" />
}
-154
View File
@@ -1,154 +0,0 @@
// consent.js — cookie / privacy consent state (v0.13.0, SPEC §14.5).
//
// The framework's analytics SDK gating (roadmap item #13, target v0.15.0)
// will read from this module. v0.13.0 ships the storage + the banner +
// the on-change pub/sub; no analytics SDK ships yet.
//
// Shape of a consent record:
//
// { essential: true, analytics: bool, other: bool, recorded_at: string | null }
//
// `essential` is always true at the API surface; it's included for
// symmetry. `recorded_at` is null when the user has not yet made a
// choice — the banner is shown until it's non-null.
//
// Precedence:
// - Anonymous viewer: localStorage is the only source.
// - Authenticated viewer: on sign-in, the server row (if any) overrides
// local; if the server has no row, the local choice is uploaded.
//
// The fan-out is intentionally tiny — three flags. The banner writes
// once; subscribers re-read on demand via `getConsent()` and can
// register `onConsentChange(cb)` to be notified of subsequent updates.
//
// IMPORTANT: don't import this from analytics SDKs that themselves
// set cookies on load. Read consent first, then conditionally `import()`
// the SDK module — that's the contract item #13 will follow.
const LS_KEY = 'rfc-app.cookie-consent.v1'
const DEFAULT = Object.freeze({
essential: true,
analytics: false,
other: false,
recorded_at: null,
})
const listeners = new Set()
function readLocal() {
try {
const raw = localStorage.getItem(LS_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object') return null
return normalize(parsed)
} catch {
return null
}
}
function writeLocal(record) {
try {
localStorage.setItem(LS_KEY, JSON.stringify(normalize(record)))
} catch {
// localStorage may be unavailable (private mode, disabled storage).
// In that case we behave as if no choice was ever made — the banner
// shows on every load. Acceptable per §14.5: the user can still
// refuse to consent on each visit.
}
}
function normalize(record) {
return {
essential: true,
analytics: !!record.analytics,
other: !!record.other,
recorded_at: record.recorded_at || null,
}
}
// In-memory snapshot. Initialised lazily on first read so the module
// import order doesn't matter; refreshed by `setConsent` and
// `hydrateFromServer`.
let _snapshot = null
function snapshot() {
if (_snapshot == null) {
_snapshot = readLocal() || { ...DEFAULT }
}
return _snapshot
}
function emit() {
for (const cb of listeners) {
try { cb(snapshot()) } catch {}
}
}
/** Read the current consent record. Always returns a normalized object;
* `recorded_at: null` means the user has not yet chosen. */
export function getConsent() {
return snapshot()
}
/** True if the user has made a choice. The banner uses this to decide
* whether to render itself on load. */
export function hasChosen() {
return snapshot().recorded_at != null
}
/** Subscribe to consent updates. Returns an unsubscribe function. */
export function onConsentChange(cb) {
listeners.add(cb)
return () => listeners.delete(cb)
}
/** Write a new choice locally and emit. Returns the new snapshot. The
* server-side persistence path is handled separately by the banner /
* settings surface via the API client; this helper is for both anon
* and authenticated callers because localStorage is the always-on
* layer (the server row is a backup that survives sign-out). */
export function setConsent({ analytics = false, other = false } = {}) {
const next = normalize({
analytics,
other,
recorded_at: new Date().toISOString(),
})
_snapshot = next
writeLocal(next)
emit()
return next
}
/** Adopt a server-side record as authoritative. Called by the banner /
* settings surface after sign-in when the server returns a non-null
* recorded_at. Updates local + memory + emits to subscribers. */
export function hydrateFromServer(record) {
if (!record || !record.recorded_at) return snapshot()
const next = normalize(record)
_snapshot = next
writeLocal(next)
emit()
return next
}
/** Reset local state used by the settings "Change" affordance to
* re-prompt the banner. Does not touch the server row; the user must
* re-confirm a choice and the banner uploads on save. */
export function clearLocal() {
try { localStorage.removeItem(LS_KEY) } catch {}
_snapshot = { ...DEFAULT }
emit()
return _snapshot
}
// Cross-tab sync: if another tab writes the key, mirror the change here.
// Wrapped in a guard so SSR / non-browser test contexts don't blow up.
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('storage', e => {
if (e.key !== LS_KEY) return
_snapshot = readLocal() || { ...DEFAULT }
emit()
})
}
-125
View File
@@ -1,125 +0,0 @@
// Cookies.jsx v0.13.0 / roadmap item #11 / SPEC §14.6.
//
// Lists the framework's cookies, by category, with each cookie's
// purpose. Deployments override via `VITE_COOKIES_POLICY_URL` (linked
// below the framework's stub list, same shape as the privacy page).
//
// Keeping the list in source makes the framework self-documenting:
// when a future framework release adds or removes a cookie, this page
// is the change-record. Item #13's analytics SDK will add its own row
// to the analytics-category list in v0.15.0.
import { useNavigate, Link } from 'react-router-dom'
const COOKIES = [
{
name: 'rfc_session',
category: 'Essential',
purpose: "Signed session cookie that remembers who you're signed in as. itsdangerous-signed; HttpOnly; SameSite=Lax.",
lifetime: 'Session (cleared on sign-out).',
},
{
name: 'rfc-app.cookie-consent.v1',
category: 'Essential',
purpose: 'localStorage entry (not a cookie strictly, but tracked here for symmetry) that remembers your consent choice on this device. Cleared on browser data reset.',
lifetime: 'Until cleared.',
},
]
export default function Cookies() {
const navigate = useNavigate()
const deploymentUrl = (import.meta.env.VITE_COOKIES_POLICY_URL || '').trim()
const appName = import.meta.env.VITE_APP_NAME || 'this deployment'
return (
<div className="policy-page">
<header className="policy-header">
<button
className="policy-back"
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
>
Back
</button>
<span className="policy-title">Cookies policy</span>
</header>
<article className="policy-body">
<h1>Cookies policy</h1>
<p className="policy-subtitle">
What {appName} stores in your browser, by category.
</p>
<h2>Categories</h2>
<ul>
<li>
<strong>Essential</strong> required for the app to keep
you signed in, protect submissions, and remember your
consent choice. Cannot be switched off (without these the
app cannot function).
</li>
<li>
<strong>Analytics</strong> optional anonymous usage
telemetry. Off by default; opt-in via the consent banner.
As of v0.13.0 no analytics SDK ships; roadmap item #13
(v0.15.0) adds one behind this gate.
</li>
<li>
<strong>Other</strong> third-party embeds, social
widgets, or anything else the deployment chooses to enable.
Off by default; opt-in via the consent banner. The
framework ships no such cookies by default.
</li>
</ul>
<h2>Current cookies set by the framework</h2>
<table className="policy-table">
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th>Purpose</th>
<th>Lifetime</th>
</tr>
</thead>
<tbody>
{COOKIES.map(c => (
<tr key={c.name}>
<td><code>{c.name}</code></td>
<td>{c.category}</td>
<td>{c.purpose}</td>
<td>{c.lifetime}</td>
</tr>
))}
</tbody>
</table>
<h2>Manage your choice</h2>
<p>
Change your consent any time from{' '}
<Link to="/settings/notifications">
Settings &rarr; Privacy &amp; cookies
</Link>. The "Change" affordance re-opens the consent banner
with your current selection pre-loaded.
</p>
{deploymentUrl ? (
<>
<h2>Deployment-specific cookies</h2>
<p>
This deployment may add additional cookies on top of the
framework's. See the full deployment policy at:
</p>
<p>
<a href={deploymentUrl} target="_blank" rel="noopener noreferrer">
{deploymentUrl}
</a>
</p>
</>
) : null}
<p className="policy-footnote">
See also the <Link to="/privacy">privacy policy</Link>.
</p>
</article>
</div>
)
}
-118
View File
@@ -1,118 +0,0 @@
// Privacy.jsx v0.13.0 / roadmap item #11 / SPEC §14.5.
//
// The framework's default privacy policy page. Reachable by anonymous
// and authenticated viewers alike at `/privacy`. The text below is a
// minimal stub that describes the framework's stance; deployments are
// expected to override it via the `VITE_PRIVACY_POLICY_URL` env var.
//
// When `VITE_PRIVACY_POLICY_URL` is set:
// - http(s) URL the page renders the framework's stub above a
// "Read the full deployment policy" link to the configured URL.
// We don't iframe-embed third-party policy hosts because their
// Content-Security-Policy frequently refuses framing; the link is
// the predictable affordance.
//
// The framework's stub is intentionally short the rules that matter
// to a user are: (1) what categories of cookies the app sets, (2) how
// to change consent, (3) where to reach the deployment operator with a
// complaint. Each deployment's content repo can carry a fuller version.
import { useNavigate, Link } from 'react-router-dom'
export default function Privacy() {
const navigate = useNavigate()
const deploymentUrl = (import.meta.env.VITE_PRIVACY_POLICY_URL || '').trim()
const appName = import.meta.env.VITE_APP_NAME || 'this deployment'
return (
<div className="policy-page">
<header className="policy-header">
<button
className="policy-back"
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
>
Back
</button>
<span className="policy-title">Privacy policy</span>
</header>
<article className="policy-body">
<h1>Privacy policy</h1>
<p className="policy-subtitle">
What {appName} stores, why, and how to control it.
</p>
<h2>What we store</h2>
<p>
{appName} runs on the Wiggleverse RFC framework. The framework
stores the identity you sign in with (your Gitea login,
display name, email, and avatar URL), the proposals and edits
you author, the discussion threads you participate in, and
your notification preferences. Authoring is public by design
this is a framework for public-async RFC work, and threads,
changes, and PRs are visible to anyone who reaches the
deployment. Settings (notification toggles, quiet hours, mute
list, cookie consent) are private to your account.
</p>
<h2>Cookies</h2>
<p>
The app sets a small set of cookies. The full list is on the{' '}
<Link to="/cookies">cookies policy page</Link>. You can choose
which categories you allow from the consent banner shown on
your first visit or from <Link to="/settings/notifications">
Settings &rarr; Privacy &amp; cookies</Link> any time
afterwards.
</p>
<h2>Analytics</h2>
<p>
The framework supports an optional anonymous analytics layer
gated behind your consent choice. As of v0.13.0 no analytics
SDK ships in the framework; deployments that enable analytics
do so via a later framework version (roadmap item #13). The
consent toggle exists today so the gate is already in place
when the SDK lands.
</p>
<h2>Your data, your control</h2>
<ul>
<li>Revoke cookie consent any time from settings.</li>
<li>
Edit notification preferences including the global email
opt-out from{' '}
<Link to="/settings/notifications">notification settings</Link>.
</li>
<li>
Your authored content (proposals, threads, edits) is public
and not retractable from the meta-repo's Git history. If you
need a redaction, reach the deployment operator directly.
</li>
</ul>
{deploymentUrl ? (
<>
<h2>Deployment-specific policy</h2>
<p>
This deployment may layer additional policy on top of the
framework's defaults. Read the full deployment policy at:
</p>
<p>
<a href={deploymentUrl} target="_blank" rel="noopener noreferrer">
{deploymentUrl}
</a>
</p>
</>
) : (
<>
<h2>Deployment contact</h2>
<p>
For deployment-specific privacy questions data subject
requests, redaction requests, complaints contact the
operator of {appName}.
</p>
</>
)}
</article>
</div>
)
}