Compare commits

..

5 Commits

Author SHA1 Message Date
Ben Stull b3f1b15f65 Release 0.12.0: CloudFlare Turnstile on OTC email-entry 2026-05-28 03:52:45 -07:00
Ben Stull 6fb68a95c7 Release 0.11.0: trust device for 30 days 2026-05-28 03:46:24 -07:00
Ben Stull 7872b921ed Release 0.9.0: admin user-management page + new-request notifications 2026-05-28 03:39:25 -07:00
Ben Stull de28272914 Release 0.14.0: public /docs user guide (DOCS.md served at /docs)
Ships DOCS.md and the /docs route — a plain-prose translation of
SPEC.md for users, distinct from the binding spec. Originating need
was the admin-vs-owner role distinction on /admin/users (§6.1);
response is a single guide that covers the framework's user-facing
surfaces end-to-end (roles, proposing, branches, PRs, graduation,
notifications). Mirrors /philosophy: markdown file at repo root +
sibling backend loader + MarkdownPreview render. No schema migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 03:08:28 -07:00
Ben Stull 55beba5c0a Release 0.10.0: user-set passcodes (OTC stays as fallback)
After a successful OTC sign-in, a contributor can set a passcode
(4-20 characters, bcrypt-hashed) and use email + passcode for
subsequent sign-ins. OTC remains the structural fallback: five
consecutive failed verifies lock the passcode path for 15 minutes
(HTTP 423), and a forgotten passcode is recovered by requesting a
fresh code. Migration 015_passcode.sql adds four nullable columns
to the users table; existing rows pass through as OTC-only and can
opt into a passcode from a new Sign-in tab in /settings/notifications.
The /login surface is extended to a five-step flow (email → either
passcode or OTC code → optional post-OTC passcode offer → optional
set-passcode). SPEC corrections per §19.3 rule 2: §6 names the
three auth paths, §14.1 documents the stepped login flow, §17 lists
the four new /auth/passcode/* endpoints, §19.2 surfaces four new
candidates and refreshes the cross-refs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:39:27 -07:00
32 changed files with 6384 additions and 338 deletions
+774 -105
View File
@@ -23,6 +23,780 @@ 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.11.0 — 2026-05-28
**Minor — schema migration required; no new env vars.** This release
ships the "trust this device for 30 days" gesture (roadmap item #9,
SPEC §6.2). After a successful OTC or passcode sign-in, the user
can check a single checkbox to mint a server-issued opaque
device-trust token; the token rides as a long-lived HttpOnly +
Secure + SameSite=Lax cookie, and the matching row's hash lives in a
new `device_trust` table. On a subsequent visit, the cookie is
presented at `POST /auth/device-trust/start` — if a non-expired,
non-revoked row matches, the session is re-established without
another OTC / passcode roundtrip. A new `/settings/notifications`
"Trusted devices" section lists active rows (created-at, last-seen,
expiry, rough UA label) with per-row "Revoke" and a "Revoke all
devices" button. The cookie is "essential" per the v0.13.0 cookie-
consent contract — it is part of authentication, not analytics — and
is set regardless of the user's analytics / other-cookies choice.
The session model gains a cookie, not a session-store change: the
existing `rfc_session` cookie still carries the in-flight session
state; the new `rfc_device_trust` cookie is consulted only by
`/auth/device-trust/start` to bootstrap a fresh session on a return
visit. The raw token only ever lives in the outbound `Set-Cookie`
header and the inbound `Cookie` header; server-side storage is the
bcrypt hash; constant-time comparison via `bcrypt.checkpw` on the
candidate walk. The raw token is never logged.
### Added
- **`device_trust` table** (`backend/migrations/017_device_trust.sql`).
Per-row id, `user_id` (FK with cascade), `device_token_hash`
(bcrypt at rest, unique index documents the no-collision
invariant), `created_at`, `expires_at` (`created_at + 30 days`),
`user_agent` (verbatim, app-layer-truncated to 1024 chars),
`last_seen_at` (refreshed on every successful lookup), `revoked_at`
(NULL means active). Secondary index on `(user_id, revoked_at)` so
the /settings list query is a covering walk.
- **`backend/app/device_trust.py`** — sibling of `otc.py` and
`passcode.py`. Carries `issue(user_id, user_agent)`,
`lookup(raw_token)`, `list_for_user(user_id)`, `revoke(user_id,
row_id)`, and `revoke_all(user_id)`. The 30-day window and the
cookie name (`rfc_device_trust`) live as module-level constants;
env-ifying them is a §19.2 candidate.
- **`§17` endpoints**
- `POST /auth/device-trust/start` — anonymous-reachable. Reads the
`rfc_device_trust` cookie; on a hit, signs the user in. On a
miss (expired, revoked, or unknown), clears the stale cookie and
returns 401.
- `GET /api/auth/me/devices` — list active trusted devices for
the signed-in user.
- `DELETE /api/auth/me/devices/{id}` — revoke a single row.
User-id scope enforced in SQL so a hostile client cannot
revoke another user's row by guessing ids.
- `DELETE /api/auth/me/devices` — revoke every active row.
- **OTC and passcode verify bodies** gain an optional
`trust_device: bool` field (default false). When true and verify
succeeds, the endpoint mints a fresh device-trust row and sets
the cookie on the response. Pre-v0.11.0 clients that omit the
field continue to behave as before.
- **Login.jsx** gains a "Trust this device for 30 days" checkbox
on both the OTC and passcode verify steps, plus a silent on-mount
call to `POST /auth/device-trust/start` so a returning user with
a valid cookie skips the email step entirely. A failure is
intentionally invisible — the user proceeds to the normal email
step.
- **`/settings/notifications` "Trusted devices" section** — lists
active rows with per-row "Revoke" + a "Revoke all devices" button
(with a `confirm()` prompt because the gesture is broad). The
surface intentionally does not single out the row whose cookie
the current request carries so a user can revoke "this device"
alongside any other from one place.
### Changed
- **`backend/app/main.py`** — the OTC and passcode verify endpoints
now also accept the `trust_device` flag and accept an injected
`Response` so they can attach the cookie. Two helpers
(`_set_device_trust_cookie`, `_clear_device_trust_cookie`) carry
the cookie attribute set in one place so the contract is
consistent across endpoints. The `Response` import is added
alongside the existing FastAPI re-exports.
- **`backend/app/api.py`** — imports `device_trust as device_trust_mod`
alongside `auth`/`db`; mounts the three `/api/auth/me/devices*`
endpoints immediately after `/api/auth/me/beta-request` so the
auth-shaped neighborhood stays clustered.
- **`frontend/src/api.js`** — exports `startDeviceTrust()`,
`listMyDevices()`, `revokeMyDevice(id)`, `revokeAllMyDevices()`.
`verifyOtc` and `verifyPasscode` accept an optional
`{ trustDevice }` argument that rides on the POST body.
### Upgrade steps (from 0.10.0)
- You **MUST** apply schema migration `017_device_trust.sql`. The
migration creates a single new table with one secondary index;
the framework runs migrations automatically at process start, so
no manual step is required beyond restarting the backend so the
migration runner picks the file up.
- You **MUST** rebuild the frontend and restart the backend after
upgrading. `frontend/package.json#version` and `VERSION` both
move to `0.11.0` and the new `Set-Cookie` shape requires the
backend to be on the matching version.
- You **MUST** serve the deployment over HTTPS. The
`rfc_device_trust` cookie is set with `Secure=True` — a
cleartext deployment will never receive the cookie back from
the browser, so the trust gesture will appear to silently fail.
Production OHM deployments already serve over HTTPS; local
development against `http://localhost` is unaffected (no cookie
is set, the OTC/passcode paths continue to work).
- You **MAY** announce the new feature to your users. Existing
signed-in sessions are unaffected — the device-trust cookie is
opt-in on the next sign-in, and a user who never checks the box
keeps the v0.10.0 behavior verbatim.
## 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.9.0 — 2026-05-28
**Minor — no schema migration; reuses v0.8.0 columns and existing
SMTP.** This release ships the admin user-management surface at
`/admin/users` and the new-beta-request notifications that feed
it (roadmap item #7, SPEC §6.1 / §15 / §17). The two halves
compose: admins receive an inbox + email signal the moment a
pending user submits the v0.8.0 capture form; clicking through
lands on the page where they Grant or Revoke access.
The page consumes the v0.8.0 schema columns
(`permission_state`, `first_name`, `last_name`,
`beta_request_reason`, `permission_decided_by`,
`permission_decided_at`) without adding new ones — migration
slot 016 stays reserved for a future release. The single new
write endpoint, `POST /api/admin/users/<id>/permission`,
replaces v0.8.0's documented manual `UPDATE users SET
permission_state='granted'` gesture with an audited UI flip.
Admin notifications ride the existing §15 chokepoint — the
`new_beta_request` event_kind is added to the enum with
category `admin-actionable`, fan-out is to every owner / admin
minus the requester themselves, and the §15.4 email dispatch
only reaches recipients whose `email_admin_actionable` toggle
is on (the default for owners + admins). The event is the
framework's first non-RFC-scoped notification — `rfc_slug` is
NULL and the email deep-link points `/admin/users` instead of
`/rfc/<slug>`.
Decision on `/admin/allowlist`: the surface stays as a
sibling sub-tab, not folded into `/admin/users`. The two have
different keys (allowlist by email pre-sign-up, user list by
user_id post-sign-up) and a union row would be confusing
rather than clarifying. The allowlist's fast-path-bypass role
from v0.8.0 is unchanged; retiring the table outright is
deferred to a later session (see §19.2).
### Upgrade steps (from 0.10.0)
1. **MAY** rebuild the frontend. The build is the same shape
as v0.10.0; the lockfile pins to `0.9.0` so `npm install`
in `frontend/` updates it cleanly. No new env vars on the
frontend; the existing `VITE_APP_NAME` requirement carries
over.
2. **MAY** restart the backend. No schema migration runs in
this release — every v0.9.0 column is from
`014_beta_access.sql` (v0.8.0). Restart only if you want
the new endpoints registered in this version's binary.
3. **SHOULD** verify SMTP can reach the deployment's admin
inbox before the first pending user submits the capture
form. v0.9.0 reuses the v0.7.0 SMTP configuration
(`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
`SMTP_STARTTLS`, `EMAIL_FROM`, `EMAIL_FROM_NAME`); a
misconfigured deployment will still write the inbox row,
but the admin won't hear about it through email. No new
env var is required — the framework reuses the existing
admin-user list (role IN ('owner', 'admin')) as the
notification recipients.
4. **SHOULD** announce the surface to existing admins. Wording
suggestion: "There's now a Users tab in /admin where you can
Grant or Revoke beta access — and you'll get an email +
inbox row when a fresh request lands. The manual SQL gesture
from v0.8.0 still works but is no longer the documented
path."
5. **MAY** drain the existing pending queue through the new UI.
If your deployment carried pending users through the v0.8.0
manual-UPDATE window, the Pending bucket on `/admin/users`
surfaces all of them with their captured profile. Granting
from the UI stamps `permission_decided_by` /
`permission_decided_at`, which any prior manual UPDATE
gestures may have left NULL (no harm done — the v0.8.0
contract didn't require those stamps).
### Added
- **`POST /api/admin/users/<id>/permission`** — body
`{state: 'pending'|'granted'|'revoked'}`. Flips the column,
stamps `permission_decided_by` + `permission_decided_at`,
and writes a `permission_events` row with event_kind in
`{permission_granted, permission_revoked,
permission_repended}`. Refuses 422 on self-flip (symmetric
to `set_mute` / `set_role` self-action refusals) and 422
on invalid state. Returns `{ok, permission_state, changed}`
where `changed=false` indicates a no-op (the requested
state already matched).
- **Widened `GET /api/admin/users` response** carrying
`permission_state`, `first_name`, `last_name`,
`beta_request_reason`, `created_at`,
`permission_decided_at`, plus joined
`permission_decided_by_login` /
`permission_decided_by_display`. Sort order surfaces
`pending` rows first (the admin queue), then `granted`,
then `revoked`; within a bucket, owners precede admins
precede contributors, with recency as the tiebreaker.
- **`new_beta_request` event_kind** in the §15.1 enum. Fired
by `notify.fan_out_new_beta_request` from the first
successful `POST /api/auth/me/beta-request` (re-submits
from the same pending user don't re-fire — the row's
first-time-complete check guards against carpet-bombing).
Recipients: every owner + admin minus the requester
themselves. Category: `admin-actionable`. Deep-link:
`/admin/users`. Actor: the requester per §15.9.
- **`/admin/users` page enhancements** in `Admin.jsx`. The
Users tab gains a state filter chip row (All / Pending /
Granted / Revoked with counts), a Grant / Revoke control
column, a Permission state badge, and an expandable
"why they want access" row beneath each pending user.
Sign-up timestamp surfaces in a new column.
- **`frontend/src/api.js#setUserPermission`** — client for
the new endpoint, neighboring `setUserMute` and
`setUserRole`.
- **`/beta-pending` copy update** in `BetaPending.jsx`. The
"your request is in review" page now honestly references
the admin-email signal v0.9.0 ships and admits the
framework does not commit to an SLA — turnaround depends
on operator availability, and the deployment operator is
the right person to ask if a wait runs long. No
deployment-specific text is baked in; per-deployment copy
lives in §13 of the deployment's repo, not in the
framework.
- **`backend/tests/test_admin_users_vertical.py`** — 10 new
tests covering: a beta-request submission fans
notifications to every admin + owner (and not to the
requester or to contributors); the requester's profile
fields land in the notification payload; re-submitting
the capture form does not re-fan; the `admin-actionable`
category mapping is wired; the `/api/admin/users`
listing carries the v0.8.0 columns with `pending` rows
sorted first; the permission-flip endpoint promotes
pending → granted with the right audit shape; the
endpoint promotes granted → revoked; the endpoint
refuses self-flip with 422; the endpoint refuses
non-admin callers with 403 and anonymous callers with
401; the endpoint refuses invalid states with 422; a
state-already-matches flip returns `changed=false`
without writing an audit row.
### Changed
- **`backend/app/api.py`** — the `POST /api/auth/me/beta-request`
handler now calls `notify.fan_out_new_beta_request` after the
capture UPDATE lands, gated on the row not previously having
all three profile fields populated (so re-submits don't
re-fan).
- **`backend/app/api_admin.py`** — the `list_users` query joins
against `users d ON d.id = u.permission_decided_by` for the
deciding-admin handle. The new `set_permission` endpoint
lives alongside `set_role` / `set_mute`.
- **`backend/app/notify.py`** — adds
`CATEGORY_ADMIN_ACTIONABLE`, the `fan_out_new_beta_request`
helper, and the `new_beta_request` arm in `render_summary`.
- **`backend/app/email.py`** — the `_EVENT_TO_CATEGORY` map
carries `new_beta_request → admin-actionable`, and
`_deep_link` routes framework-scoped admin signals to
`/admin/users` instead of `/rfc/<slug>`.
- **`frontend/src/components/Admin.jsx`** — the `UsersTab`
component is rewritten with state filter chips, a per-row
`UserRow` + `PermissionCell` decomposition, and a
`useMemo`-cached counts table. The pending-row reason
blockquote renders as a secondary `<tr>` beneath the user
row when present.
- **`frontend/src/components/BetaPending.jsx`** — pending-state
copy revised to reference the admin email signal honestly.
- **`frontend/src/App.css`** — new admin-chip / permission-badge
/ user-row-reason rules.
- **`SPEC.md`** §6 opening, §15.1 event-kinds enum, §17 admin
endpoints, §19.2 candidates list — per §19.3 rule-2.
- **`VERSION`** → `0.9.0`. `frontend/package.json#version` and
the lockfile mirror.
### Environment variables
None new. The release reuses the v0.7.0 SMTP configuration
(`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
`SMTP_STARTTLS`, `EMAIL_FROM`, `EMAIL_FROM_NAME`,
`EMAIL_ENABLED`, `EMAIL_BUNDLE_THRESHOLD`) and the existing
admin-user list (role IN ('owner', 'admin')) as the
notification recipients. A deployment whose SMTP is misconfigured
will still see the inbox rows; only the email channel is muted.
### Deferred to later releases
- **Grant / revoke notification to the user** — symmetric
signal: when an admin grants or revokes access, fire a
`personal-direct` notification (event_kind
`permission_change_affecting_me`, already in the §15.1
enum) so the affected user sees the state change in
their inbox and email. v0.9.0 audits the gesture in
`permission_events` but does not yet escape the
app-internal log to the user. See §19.2.
- **Decline-with-reason on revoke** — the current Revoke
gesture takes only a confirmation; a follow-up release
may capture a free-text reason in
`permission_events.details`. See §19.2.
- **Allowlist deprecation** — the `/admin/allowlist` sub-tab
stays in place in v0.9.0 (the two surfaces have different
keys and a union row would be confusing). Retiring the
table outright is deferred to a session that can re-read
the post-v0.9.0 operator experience and decide whether
the fast-path-bypass role is still pulling weight. See
§19.2.
## 0.8.0 — 2026-05-28
**Minor — schema migration required; admission semantics shift.**
@@ -209,107 +983,6 @@ direct DB `UPDATE`.
state is wired in the schema and the auth gate; v0.9.0 ships
the admin UI that flips the column.
## 0.13.0 — 2026-05-28
**Minor — schema migration required; new optional env vars.** This
release ships the cookie / privacy consent surface (roadmap item #11,
SPEC §14.5 / §14.6). Every viewer — authenticated and anonymous alike —
now sees a non-modal bottom-of-page banner on first visit asking which
categories of cookies they allow (essential / essential + analytics /
essential + analytics + other). The choice persists in `localStorage`
for anonymous viewers and in a new `cookie_consent` table for
authenticated viewers, with server-side overriding local on sign-in.
The framework also ships default `/privacy` and `/cookies` policy pages
that deployments can layer their own policy URL on top of via two new
optional env vars. No analytics SDK ships in this release — the
consent infrastructure is wired so item #13 (v0.15.0) can read from
`frontend/src/lib/consent.js` when the SDK lands.
### Added
- **Cookie consent banner** (`frontend/src/components/CookieConsentBanner.jsx`).
Non-modal, bottom of viewport. Three single-select choices with
inline descriptions. Visible until the user makes a choice; hides
thereafter. Reachable for revision via the settings surface.
- **Consent helper** (`frontend/src/lib/consent.js`). Exports
`getConsent()`, `hasChosen()`, `onConsentChange(cb)`, `setConsent()`,
`hydrateFromServer()`, `clearLocal()`. Cross-tab sync via the
`storage` event. Item #13's analytics SDK reads consent here before
importing.
- **Privacy and cookies policy pages**
(`frontend/src/pages/Privacy.jsx`, `frontend/src/pages/Cookies.jsx`).
Default minimal policies that describe the framework's stance and
list the cookies the framework sets. Deployments override via the
two new env vars below; the framework's stub always renders above
the link so the framework-level contract stays visible.
- **"Privacy & cookies" tab** in `/settings/notifications` showing
the current consent choice, the recorded-at stamp, and a "Change"
button that re-opens the banner via a custom DOM event.
- **`§17` endpoints** —
- `GET /api/users/me/cookie-consent` — read the current consent
record.
- `PUT /api/users/me/cookie-consent` — write a new consent record.
Upserts a single row per user, stamps `recorded_at` to now,
accepts `essential` for symmetry but always persists it as true.
- **Schema migration** `013_cookie_consent.sql` — new
`cookie_consent` table keyed by `user_id`, three flags
(`essential`, `analytics`, `other_cookies`), and `recorded_at`.
(Renumbered from `012_*` during driver integration because v0.7.0
also added a `012_otc.sql` migration that landed in the integration
order before this one.)
- **SPEC `§14.5` Cookie / privacy consent** — settles the banner
shape, the three-category single-select, the storage shape (local
for anon, server row for authenticated), the precedence rule on
sign-in, and the `consent.js` helper surface for downstream
callers including item #13.
- **SPEC `§14.6` Privacy and cookies policy pages** — settles the
`/privacy` and `/cookies` routes, the framework's stub content, and
the `VITE_PRIVACY_POLICY_URL` / `VITE_COOKIES_POLICY_URL` override
shape.
- **SPEC `§5`** — names the `cookie_consent` table in the canonical
app-tables list.
- **SPEC `§17`** — lists the two new cookie-consent endpoints.
- **SPEC `§19.2`** — surfaces four candidates: policy content via
content-repo file vs env var, GPC / DNT headers, multi-language
consent text, and the item #13 analytics-SDK gating dependency.
### Changed
- **`frontend/.env.example`** — documents the two new optional env
vars `VITE_PRIVACY_POLICY_URL` and `VITE_COOKIES_POLICY_URL`. Unset
is supported; defaults render the framework's stub.
- **`backend/app/api_notifications.py`** — module docstring grew two
endpoint lines; the new endpoints sit alongside the existing
`/api/users/me/*` neighbors.
- **`frontend/src/App.jsx`** — registers `/privacy` and `/cookies`
routes (anonymous-reachable), wires `<CookieConsentBanner>` into
the global chrome, and listens for a `rfc-app:cookie-consent-reopen`
custom event to re-open the banner from the settings surface.
### Upgrade steps (from 0.7.0)
- You **MUST** rebuild the frontend and restart the backend after
upgrading. `frontend/package.json#version` and `VERSION` both move
to `0.13.0` and the build embeds the new env-var contract.
- You **MUST** apply schema migration `013_cookie_consent.sql`. The
migration creates a single new table keyed by `user_id` with three
flag columns and a `recorded_at` stamp. The framework runs
migrations automatically at process start; no manual step is
required beyond restarting the backend so the migration runner
picks the file up.
- You **MAY** set `VITE_PRIVACY_POLICY_URL` to an http(s) URL that
points at your deployment's full privacy policy. The framework's
`/privacy` page renders its built-in stub above a link to the
configured URL. Unset is supported — the stub is sufficient for a
default-config deployment.
- You **MAY** set `VITE_COOKIES_POLICY_URL` to an http(s) URL that
points at your deployment's full cookies policy. Same shape as the
privacy URL.
- You **MAY** announce the new consent banner to your users. Existing
authenticated users will see the banner on their next visit
(because their `cookie_consent` row does not yet exist); their
current sessions remain valid.
## 0.7.0 — 2026-05-28
**Minor — schema migration required; new auth path is additive.**
@@ -897,7 +1570,3 @@ 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
@@ -0,0 +1,605 @@
# 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`.
+363 -102
View File
@@ -339,6 +339,16 @@ and exact columns are illustrative; the implementing session can adjust.
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.
- `device_trust` — per-row record of the §6.2 device-trust gesture
(v0.11.0, roadmap item #9). One row per `(user, trusted device)`
pair; a user with three trusted devices has three rows. Columns:
`id`, `user_id` (FK users, ON DELETE CASCADE), `device_token_hash`
(bcrypt at rest, with a unique index documenting the no-collision
invariant of the 256-bit CSPRNG token space), `created_at`,
`expires_at` (`created_at + 30 days`), `user_agent` (verbatim,
application-layer-truncated to 1024 chars), `last_seen_at`
(refreshed on every successful lookup), `revoked_at` (NULL means
active). The raw token never lives in this table — only the hash.
**Super-draft scoping.** For rows in `threads` and `changes` where the
entry referenced by `rfc_slug` is in state `super-draft`, `branch_name`
@@ -356,18 +366,54 @@ merge with no data movement.
Authorization is owned by the app. Gitea sees only the bot account.
Authentication, as of v0.7.0, is by email + one-time-code: a visitor
enters their email address, receives a six-digit code via SMTP, and
exchanges the code for a session. The Gitea OAuth callback that
v0.1 used as the human sign-in path remains as a migration fallback
during the v0.7.0 window — `users.gitea_id` is preserved on existing
rows so a grandfathered user signing in via either path resolves to
the same row — but the primary surface points at OTC. `users.email`
is the identity key for everything provisioned after v0.7.0;
`users.gitea_id` is the grandfathering linker (nullable, partial-
unique). The Gitea bot user + token are still required for server-
side git operations (repo reads, PR creation); only the operator-
facing sign-in surface moved.
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. **Device trust (cookie-only, 30 days).** Added in v0.11.0
(roadmap item #9). After a successful OTC or passcode sign-in,
the visitor may check "trust this device for 30 days." The
framework then mints a server-issued opaque token, hashes it
(bcrypt) into the `device_trust` table, and sets a long-lived
HttpOnly + Secure + SameSite=Lax cookie carrying the raw token.
On a subsequent visit, `POST /auth/device-trust/start` resolves
the cookie and re-establishes the session without an OTC /
passcode roundtrip. The user can list and revoke their trusted
devices from the `/settings/notifications` "Trusted devices"
section; a revoked or expired cookie is cleared on the next
request. The cookie is "essential" per §14.5 — it is part of
authentication, not analytics, and is set regardless of the
user's analytics / other-cookies choice. The raw token only
ever lives in the outbound `Set-Cookie` header and the inbound
`Cookie` header; server-side storage is the hash, with
constant-time comparison on lookup.
4. **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
@@ -379,9 +425,23 @@ 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.
it — but the OTC request path no longer consults it. v0.9.0
(roadmap item #7) shipped the user-management surface that
consumes the `permission_state` column: `/admin/users` carries
every user with their state, profile, and sign-up reason, plus
Grant / Revoke controls that flip the column and write a
`permission_events` audit row. The capture-form submission also
fans a `new_beta_request` notification out to every admin/owner
through the §15 substrate, so the queue surfaces in the inbox +
email channels admins already have.
The `/admin/allowlist` sub-tab stays in place alongside
`/admin/users` rather than merging: the two surfaces have
different keys (allowlist by email pre-sign-up, user list by
user_id post-sign-up) and a union row would be confusing rather
than clarifying. The allowlist's role narrowed to "fast-path
bypass for known-good emails" with the v0.8.0 admission shift;
v0.9.0 retains that role unchanged.
### 6.1 Four roles, each a strict superset of the one below
@@ -2035,6 +2095,30 @@ 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
@@ -2243,8 +2327,14 @@ signal taxonomy this section commits to. The starting set:
`graduation_complete`, `graduation_rolled_back`, `rfc_withdrawn`,
`rfc_reopened`, `claim_opened`, `claim_merged`,
`permission_change_affecting_me`, `app_wide_mute_set`,
`app_wide_mute_lifted`, `digest_emitted`. The enum is extensible; the
build session adjusts as new gestures are wired in.
`app_wide_mute_lifted`, `new_beta_request`, `digest_emitted`.
The enum is extensible; the build session adjusts as new gestures
are wired in. The `new_beta_request` event (v0.9.0, roadmap item
#7) is framework-scoped rather than RFC-scoped — the row's
`rfc_slug` is NULL and the deep-link points `/admin/users`
instead of `/rfc/<slug>` — but otherwise rides the standard
fan-out chokepoint with category `admin-actionable` so the §15.4
email gate only reaches owners/admins.
### 15.2 The inbox
@@ -2698,9 +2788,19 @@ The follow-up session will refine this. A minimal starting set:
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. Per §19.2's
expected next session, this endpoint is the lead-up to the
Cloudflare-Turnstile abuse-mitigation overlay.
`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.
- `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
@@ -2728,6 +2828,60 @@ The follow-up session will refine this. A minimal starting set:
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.
- `POST /auth/device-trust/start` — unauthenticated. Reads the
`rfc_device_trust` cookie (set previously by an OTC or passcode
verify with `trust_device: true`). On a non-expired, non-revoked
match, re-establishes the session and returns HTTP 200 with the
minimal user payload. On a miss (no cookie, expired, revoked, or
unknown), returns HTTP 401 and clears the stale cookie via the
response's `Set-Cookie` header. The failure modes collapse to
one shape so a probing client cannot enumerate "your row was
revoked" vs. "this token never existed". v0.11.0.
- `GET /api/auth/me/devices` — authenticated. Returns the active
(`revoked_at IS NULL` AND `expires_at > now`) device-trust rows
for the signed-in user: `id`, `created_at`, `expires_at`,
`last_seen_at`, `user_agent`. The bcrypt hash is structurally
private and is never surfaced. v0.11.0.
- `DELETE /api/auth/me/devices/{id}` — authenticated. Stamps
`revoked_at` on the row with id `{id}` belonging to the
signed-in user. The user-id scope is enforced in SQL so a
hostile client cannot revoke another user's row by guessing
ids; a row that does not match returns HTTP 404. v0.11.0.
- `DELETE /api/auth/me/devices` — authenticated. Revokes every
active row for the signed-in user; returns the count revoked.
v0.11.0.
- `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.
@@ -2870,8 +3024,15 @@ The follow-up session will refine this. A minimal starting set:
- `POST /api/rfcs/<slug>/prs/<pr_number>/withdraw` — withdraw per §10.8.
- `POST /api/rfcs/<slug>/prs/<pr_number>/resolution-branch` — cut a
fresh resolution branch and replay per §10.9.
- `GET /api/admin/users` — list users with role and write-mute state,
for the §6 / Slice 7 admin surface.
- `GET /api/admin/users` — list users for the §6 / Slice 7 admin
surface. v0.9.0 (roadmap item #7) widened the payload to carry
`permission_state`, `first_name`, `last_name`, `beta_request_reason`,
`created_at`, `permission_decided_at`, and the joined
`permission_decided_by_login` / `permission_decided_by_display`
for the user-management page. Sort order surfaces `pending` rows
first (the daily admin queue), then `granted`, then `revoked`;
within a bucket, owners precede admins precede contributors,
with recency as the tiebreaker.
- `POST /api/admin/users/<id>/role` — set role. Only owners may grant
or revoke `owner`; admins may flip contributor ↔ admin freely. An
owner-self-demotion is refused on this endpoint; owner succession
@@ -2880,6 +3041,16 @@ The follow-up session will refine this. A minimal starting set:
write-mute (not the §15.8 notification mutes). Refused on owners
and admins — for them, the role-change channel is the right
refusal. Writes a `permission_events` row.
- `POST /api/admin/users/<id>/permission` — v0.9.0 (roadmap item #7).
Flip `permission_state` between `pending`, `granted`, and `revoked`.
Stamps `permission_decided_by` + `permission_decided_at` on the
row and writes a `permission_events` row with event_kind in
`{permission_granted, permission_revoked, permission_repended}`.
Refuses with 422 if the admin tries to flip their own row
(symmetric to the `set_mute` / `set_role` self-action refusals).
v0.8.0 shipped the column shape with no admin UI — operators ran
a manual `UPDATE users` to grant access; v0.9.0 retires the
manual gesture.
- `GET /api/admin/audit` — paged read of the `actions` log with
filters `action_kind`, `actor_user_id`, `rfc_slug`, plus `before_id`
for the page boundary. Returns the joined actor login/display so
@@ -3719,56 +3890,64 @@ on every other page until an admin grants. v0.8.0 (roadmap item
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.
- **Admin user-management page** (`/admin/users`). *Shipped in
v0.9.0 (roadmap item #7).* The listing surfaces every user with
permission_state, profile fields, sign-up reason, and a Grant /
Revoke control set; the `POST /api/admin/users/<id>/permission`
endpoint flips the column and writes a `permission_events` row.
v0.9.0 left the `/admin/allowlist` sub-tab in place rather than
merging (see allowlist deprecation below). The grant/revoke
notify-the-user surface is deferred (see the new candidate
below).
- **Allowlist deprecation.** *Decision deferred past v0.9.0.*
v0.9.0 considered merging `/admin/allowlist` into the new
`/admin/users` page but kept the surface as a sibling sub-tab:
the two have different keys (allowlist by email pre-sign-up,
user list by user_id post-sign-up) and a union row would be
confusing rather than clarifying. The fast-path-bypass role
the allowlist has carried since v0.8.0 stays intact; the
cutover to retire the table outright is a later session.
Decision points unchanged from v0.8.0: 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
once the v0.9.0 admin queue has run long enough to confirm the
allowlist's bypass role is no longer pulling weight.
- **Admin notification on new beta request.** *Shipped in v0.9.0
(roadmap item #7).* The `POST /api/auth/me/beta-request`
handler now calls `notify.fan_out_new_beta_request`, which
fans a `new_beta_request` event (category `admin-actionable`,
rfc_slug NULL) out to every owner / admin. The §15 chokepoint
handles the SSE broadcast and the §15.4 email dispatch; the
email reaches only recipients whose `email_admin_actionable`
toggle is on (the default for owners + admins).
- **Grant / revoke notification to the user.** *Surfaced by
v0.9.0.* The new flip endpoint stamps `permission_decided_by` +
writes a `permission_events` row but does not yet signal the
affected user that their state changed. A future release could
fire a `personal-direct` notification (event_kind
`permission_change_affecting_me`, already in the §15.1 enum) so
a granted user sees "Your beta-access request was approved" in
their inbox and email, and a revoked user sees a parallel
refusal notice. Decision points: does revocation include a
reason field (probably yes — symmetric with §9.3's decline
comment); does grant carry a welcome message (probably no —
the existing welcome surfaces are sufficient); does the
notification escape the §15.8 mute path (probably yes — it's
a personal-direct admission state change). Earns its session
as a follow-up to the v0.9.0 page.
- **Decline-with-reason on permission revoke.** *Surfaced by
v0.9.0.* The current Revoke gesture takes only a confirmation;
there is no audit-visible reason captured. A future release
could add a free-text reason input that lands in the
`permission_events.details` JSON column (no schema change
needed — the column is already JSON-shaped). This is the
symmetric companion to the §9.3 proposal-decline contract.
Earns its session alongside the grant/revoke notification
candidate above.
- **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`
@@ -3783,36 +3962,118 @@ Candidates surfaced during v0.8.0 (open beta-access request flow,
message), and whether the `/auth/login` and `/auth/callback`
routes get a tombstone redirect to `/login` or just 404. Earns
its session once the OTC adoption curve flattens.
- **Device trust (30-day skip).** *Surfaced by v0.7.0 — the
signed-in cookie already lasts 30 days via SessionMiddleware,
but every sign-in still requires a fresh OTC.* The roadmap
item-#9 candidate adds a "trust this device" affordance on the
verify step that issues a longer-lived rotating token, so
returning visitors on the same device skip the OTC step. The
shape question is whether the trust is a signed cookie distinct
from the session, a row in a `device_trust` table keyed by a
random device-id, or a property of the session itself; and
whether the trust survives password-equivalent events (none
exist yet — passcodes are item #8 / v0.10.0) or only survives
explicit logout. Earns its session as the v0.11.0 design pass.
- **Cloudflare Turnstile (or equivalent) on `/auth/otc/request`.**
*Surfaced by v0.7.0 — the endpoint is now the new abuse hot
path.* Per-email cooldown stops the trivial loop; what it
doesn't stop is a distributed scrape that fans out across a
large invitee list to harvest the "this email is admitted vs.
this email is not" signal indirectly (timing differences, SMTP
bounce-rate observation). The roadmap item-#10 candidate gates
the request endpoint behind a one-step browser-side challenge
before the bcrypt hash + SMTP send. Open questions: which
provider (Turnstile is the default since it's free and
privacy-respecting; hCaptcha and reCAPTCHA are also viable);
how the deployment configures it (`TURNSTILE_SITE_KEY` +
`TURNSTILE_SECRET_KEY` env vars, gated by `if
config.turnstile_site_key:` at the handler so existing
deployments don't break); whether the verify endpoint also
gets a challenge (probably yes for parity); and how the test
harness mocks the challenge. Earns its session as the v0.12.0
design pass.
- **Device trust (30-day skip).** *Settled in v0.11.0 (roadmap
item #9). The shape: a distinct `rfc_device_trust` cookie
(HttpOnly + Secure + SameSite=Lax + 30-day Max-Age) carrying a
server-issued opaque token, keyed against a `device_trust` table
whose rows store the bcrypt hash. `POST /auth/device-trust/start`
resolves a presented cookie at next visit. A
`/settings/notifications` "Trusted devices" section lists active
rows with per-row + bulk revoke. The trust outlives a sign-out
(sign-out clears the session cookie, not the device-trust
cookie) and is not affected by passcode set/change/clear — the
next two items below carry the remaining open questions.*
- **Cross-device session revocation surface.** v0.11.0's
`/settings/notifications → Trusted devices` revokes the
long-lived device-trust grants. What it does NOT revoke is an
active session cookie sitting in another browser, or the
v0.10.0 passcode-failure-counter shape, or a stale
password-equivalent that some future release ships. The natural
next step is a single "active sessions and devices" surface
that lists everything currently authenticating as this user —
device-trust rows + active session cookies (if/when the
framework moves to server-side sessions) + future credential
shapes — and lets the user kill any of them with one gesture.
Earns its session when a second cross-cutting concern lands
(the most likely first trigger: future Yubikey / WebAuthn
support, which surfaces another credential to revoke).
- **Password-equivalent change invalidates device trust.** v0.11.0
intentionally leaves device-trust rows live across a passcode
set / change / clear. The argument is structural: the user has
the v0.10.0 lockout, the v0.11.0 per-device revoke list, and a
fresh sign-in path via OTC, so the cookie is not a high-value
bypass relative to the keys-to-the-account a passcode change
signals. The argument against is the conventional "changing a
password should kill every active session" expectation users
bring from other systems. This earns its own session once the
evidence is in: either a security-review finding that says
"this is the wrong default," or user feedback that says "I
expected my old laptop to sign out when I changed my passcode."
- **Device-trust window tunables via env.** v0.11.0 hard-codes
the 30-day window in `backend/app/device_trust.py`
(`TRUST_DURATION_DAYS = 30`). Surfacing it as an env var
(`DEVICE_TRUST_DURATION_DAYS`?) is small and obvious; deferring
follows the same pattern as the v0.10.0 passcode-lockout
hard-coding — name the tunable when a deployment wants it
different rather than shipping a knob that has no operator
asking for it.
- **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):
+1 -1
View File
@@ -1 +1 @@
0.8.0
0.12.0
+18
View File
@@ -92,3 +92,21 @@ 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
+97 -6
View File
@@ -26,10 +26,13 @@ from . import (
api_prs,
auth,
db,
device_trust as device_trust_mod,
docs as docs_mod,
entry as entry_mod,
cache,
funder,
health,
notify,
philosophy,
providers as providers_mod,
)
@@ -122,6 +125,17 @@ 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.
# ---------------------------------------------------------------
@@ -131,13 +145,12 @@ def make_router(
user = auth.current_user(request)
if user is None:
return {"authenticated": False, "user": None}
# v0.8.0: surface `permission_state` plus the capture-flow
# readiness signal (`needs_profile`). The frontend gates
# the /beta-pending page and the inline banner off these
# fields, and decides whether to prompt for the first/last/why
# capture on first OTC sign-in.
# 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 FROM users WHERE id = ?",
"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 ""
@@ -153,6 +166,8 @@ def make_router(
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": {
@@ -167,6 +182,8 @@ def make_router(
"last_name": last_name,
"beta_request_reason": beta_request_reason,
"needs_profile": needs_profile,
"has_passcode": has_passcode,
"passcode_set_at": passcode_set_at,
},
}
@@ -222,8 +239,82 @@ def make_router(
user.user_id,
),
)
# v0.9.0 (roadmap item #7): notify every admin/owner of the
# fresh request. Only the first submission is the
# "newly-pending" gesture — re-submits from the same user
# would otherwise carpet the admin inbox. We fire only when
# this is the row's first time getting all three fields
# populated (the prior row carried at least one NULL).
prior = row # captured before the UPDATE above
was_already_complete = bool(
prior["first_name"] and prior["last_name"] and prior["beta_request_reason"]
)
if not was_already_complete:
notify.fan_out_new_beta_request(requester_user_id=user.user_id)
return {"ok": True}
# ---------------------------------------------------------------
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
#
# The mint path lives on the OAuth router (issuing the cookie is
# coupled to OTC/passcode verify). This module owns the read/revoke
# surface the /settings/devices page calls.
# ---------------------------------------------------------------
@router.get("/api/auth/me/devices")
async def list_my_devices(request: Request) -> dict[str, Any]:
"""Active device-trust rows for the signed-in user.
Active = not revoked, not expired. The current request's
device (if any) is *not* singled out here the surface
shows the same row shape for every device so the user can
revoke any of them without the page leaking which row
carries the cookie they're using right now.
"""
user = auth.require_user(request)
rows = device_trust_mod.list_for_user(user.user_id)
return {
"items": [
{
"id": r.id,
"created_at": r.created_at,
"expires_at": r.expires_at,
"last_seen_at": r.last_seen_at,
"user_agent": r.user_agent,
}
for r in rows
]
}
@router.delete("/api/auth/me/devices/{device_id}")
async def revoke_my_device(device_id: int, request: Request) -> dict[str, Any]:
"""Revoke a single device-trust row for the signed-in user.
The user-id scope is enforced in SQL so a hostile client
cannot revoke another user's row by guessing ids. A row that
doesn't exist, doesn't belong to this user, or is already
revoked reads as 404 the wrong-vs-already-revoked
distinction would only help a probing client enumerate ids.
"""
user = auth.require_user(request)
ok = device_trust_mod.revoke(user.user_id, device_id)
if not ok:
raise HTTPException(404, "Device not found")
return {"ok": True}
@router.delete("/api/auth/me/devices")
async def revoke_all_my_devices(request: Request) -> dict[str, Any]:
"""Revoke every active device-trust row for the signed-in user.
The user's current request stays authenticated via its
session cookie; the device-trust cookie carried on the
current device is also revoked, but `rfc_session` keeps the
request flow alive until sign-out / expiry.
"""
user = auth.require_user(request)
count = device_trust_mod.revoke_all(user.user_id)
return {"ok": True, "revoked": count}
# ---------------------------------------------------------------
# §7: the catalog
# ---------------------------------------------------------------
+117 -4
View File
@@ -50,6 +50,16 @@ class MuteBody(BaseModel):
muted: bool
class PermissionStateBody(BaseModel):
# v0.9.0: the admin flip from the user-management page (roadmap
# item #7). `pending` is not surfaceable from the admin UI —
# only the OTC verify path lands a row in `pending` — but we
# accept it in the pattern in case a future restore-to-queue
# gesture wants to re-pend a granted user; today the UI only
# exposes `granted` and `revoked`.
state: str = Field(pattern="^(pending|granted|revoked)$")
class AllowlistAddBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
note: str | None = Field(default=None, max_length=200)
@@ -68,13 +78,41 @@ def make_router(config: Config) -> APIRouter:
@router.get("/api/admin/users")
async def list_users(request: Request) -> dict[str, Any]:
"""v0.9.0: the user-management surface (roadmap item #7).
The listing carries every column the admin queue needs to triage
pending beta-access requests alongside the existing role/mute
affordances. Sort order surfaces pending requests first (so the
admin lands on the inbox shape), then granted, then revoked;
within a state, ownership/role and recency are the tiebreakers
so the legacy ordering (owner first, then admin, then by name)
is preserved inside the granted bucket.
`permission_decided_by_login` joins the deciding admin row so
the UI can render "granted by @ben" without a second round-trip.
"""
auth.require_admin(request)
rows = db.conn().execute(
"""
SELECT id, gitea_login, display_name, email, role, muted,
created_at, last_seen_at
FROM users
ORDER BY role = 'owner' DESC, role = 'admin' DESC, display_name COLLATE NOCASE
SELECT u.id, u.gitea_login, u.display_name, u.email, u.role, u.muted,
u.created_at, u.last_seen_at,
u.permission_state, u.first_name, u.last_name,
u.beta_request_reason,
u.permission_decided_by, u.permission_decided_at,
d.gitea_login AS decided_by_login,
d.display_name AS decided_by_display
FROM users u
LEFT JOIN users d ON d.id = u.permission_decided_by
ORDER BY
CASE u.permission_state
WHEN 'pending' THEN 0
WHEN 'granted' THEN 1
WHEN 'revoked' THEN 2
ELSE 3
END,
u.role = 'owner' DESC, u.role = 'admin' DESC,
COALESCE(u.last_seen_at, u.created_at) DESC,
u.display_name COLLATE NOCASE
"""
).fetchall()
return {
@@ -88,6 +126,13 @@ def make_router(config: Config) -> APIRouter:
"muted": bool(r["muted"]),
"created_at": r["created_at"],
"last_seen_at": r["last_seen_at"],
"permission_state": r["permission_state"] or "granted",
"first_name": r["first_name"] or "",
"last_name": r["last_name"] or "",
"beta_request_reason": r["beta_request_reason"] or "",
"permission_decided_at": r["permission_decided_at"],
"permission_decided_by_login": r["decided_by_login"],
"permission_decided_by_display": r["decided_by_display"],
}
for r in rows
]
@@ -136,6 +181,74 @@ def make_router(config: Config) -> APIRouter:
)
return {"ok": True, "role": body.role, "changed": True}
# ----- Permission state (§6.1, v0.9.0 roadmap item #7) -----
@router.post("/api/admin/users/{user_id}/permission")
async def set_permission(user_id: int, body: PermissionStateBody, request: Request) -> dict[str, Any]:
"""Flip a user's `permission_state` between pending/granted/revoked.
v0.8.0 wired the column shape but shipped no admin UI for it
the grant gesture was a manual `UPDATE users` against the DB.
v0.9.0 (roadmap item #7) lands the admin user-management page;
this endpoint is its single write surface.
Audit shape: every flip writes a `permission_events` row with
event_kind in {'permission_granted', 'permission_revoked',
'permission_repended'} so §6.5's log carries the change. The
`permission_decided_by` / `permission_decided_at` columns on
the user row are co-stamped so the user listing can render
"granted by @ben at <date>" without a second join through
the audit table.
Refuses with 422 if the admin tries to flip their own row
(no self-grant / self-revoke; symmetric to set_mute's
self-mute refusal and set_role's self-downgrade refusal).
"""
viewer = auth.require_admin(request)
target = db.conn().execute(
"SELECT id, role, permission_state FROM users WHERE id = ?",
(user_id,),
).fetchone()
if target is None:
raise HTTPException(404, "User not found")
if target["id"] == viewer.user_id:
raise HTTPException(422, "You cannot change your own permission state")
before = target["permission_state"] or "granted"
after = body.state
if before == after:
return {"ok": True, "permission_state": after, "changed": False}
db.conn().execute(
"""
UPDATE users
SET permission_state = ?,
permission_decided_by = ?,
permission_decided_at = datetime('now')
WHERE id = ?
""",
(after, viewer.user_id, user_id),
)
event_kind = {
"granted": "permission_granted",
"revoked": "permission_revoked",
"pending": "permission_repended",
}[after]
db.conn().execute(
"""
INSERT INTO permission_events
(actor_user_id, subject_user_id, event_kind, details)
VALUES (?, ?, ?, ?)
""",
(
viewer.user_id,
user_id,
event_kind,
json.dumps({"before": before, "after": after}),
),
)
return {"ok": True, "permission_state": after, "changed": True}
# ----- Write-mute (§6.2) -----
@router.post("/api/admin/users/{user_id}/mute")
+351
View File
@@ -0,0 +1,351 @@
"""§6.2 / v0.11.0: trust device for 30 days (roadmap item #9).
After a successful OTC or passcode sign-in, a contributor may check
"trust this device for 30 days." The framework then issues a
server-issued opaque token, hashes it (bcrypt) for storage in the
`device_trust` table, and sets a long-lived cookie carrying the raw
token. On a subsequent visit, the cookie is presented at
`/auth/device-trust/start`; if a non-expired, non-revoked row matches,
the session is re-established without another OTC / passcode round
trip.
The shape:
* `issue(user_id, user_agent)` mint a fresh CSPRNG token, hash it,
insert a row, and return the raw token + row id so the endpoint
can set the cookie. The 30-day expiry is the only knob; the
`revoked_at` column stays NULL.
* `lookup(raw_token)` walk the user's active rows (the unique
index keys on the hash, so we read a small candidate set), check
the bcrypt hash in constant time, drop any row whose `expires_at`
has passed or whose `revoked_at` is non-NULL, and return the
matched row or None. On a hit, refresh `last_seen_at`.
* `list_for_user(user_id)` return the active rows for the
/settings/devices surface. Revoked + expired rows are filtered out
so the surface only shows live trust grants.
* `revoke(user_id, row_id)` stamp `revoked_at` on the row. The
next lookup refuses the cookie token (the row is dead).
* `revoke_all(user_id)` bulk-revoke every active row for the user.
The /settings/devices surface's "revoke all" button calls this.
Cookie shape: `rfc_device_trust`. HttpOnly, Secure, SameSite=Lax,
Max-Age=2592000 (30 days), Path=/. The cookie value is the raw token;
server-side storage is the hash. The cookie is "essential" per the
v0.13.0 cookie-consent banner (it is part of authentication, not
analytics), so the framework sets it regardless of analytics /
other-cookies choices.
Constant-time comparison: bcrypt's `checkpw` is already constant-time
over the hash bytes. We walk the candidate set linearly with `_check`
which delegates to `bcrypt.checkpw`; no early-exit shortcut leaks
which row was the match.
The raw token never appears in a log line or an exception message;
the helpers carry the token only as a parameter and forget it after
hashing.
The cookie sits orthogonal to the §6.1 `permission_state` gate: a
revoked or pending user with a valid device-trust cookie still
re-establishes their session (the cookie identifies the user, not
their admission state), and the existing `require_contributor` /
`require_admin` dependencies in `auth.py` continue to refuse the
unrelated write surfaces.
"""
from __future__ import annotations
import logging
import secrets
from dataclasses import dataclass
import bcrypt
from . import db
from .auth import SessionUser
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tunables — hard-coded in v0.11.0 (§19.2 candidate to env-ify later).
# ---------------------------------------------------------------------------
TRUST_DURATION_DAYS = 30
COOKIE_NAME = "rfc_device_trust"
COOKIE_MAX_AGE_SECONDS = TRUST_DURATION_DAYS * 24 * 60 * 60
# 256 bits of CSPRNG entropy. `secrets.token_urlsafe(32)` yields ~43
# URL-safe characters; the bcrypt hash is what's stored, so the raw
# token only ever lives in the cookie.
TOKEN_BYTES = 32
# User-Agent header values seen in the wild can be unbounded; clamp
# to a reasonable ceiling so a hostile UA doesn't bloat the row.
USER_AGENT_MAX_LENGTH = 1024
# ---------------------------------------------------------------------------
# Issue
# ---------------------------------------------------------------------------
@dataclass
class IssueOutcome:
"""The shape returned from `issue`.
`raw_token` is the cookie value to send to the client; it never
appears in storage. `row_id` is the surrogate key for the
/settings/devices UI to address the row by id.
"""
raw_token: str
row_id: int
def _new_token() -> str:
return secrets.token_urlsafe(TOKEN_BYTES)
def _hash(token: str) -> str:
return bcrypt.hashpw(token.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
def _check(token: str, token_hash: str) -> bool:
try:
return bcrypt.checkpw(token.encode("utf-8"), token_hash.encode("ascii"))
except (ValueError, TypeError):
return False
def _trim_user_agent(ua: str) -> str:
ua = (ua or "").strip()
if len(ua) > USER_AGENT_MAX_LENGTH:
return ua[:USER_AGENT_MAX_LENGTH]
return ua
def issue(user_id: int, user_agent: str) -> IssueOutcome:
"""Mint a fresh device-trust token + row for `user_id`.
The row's expiry is set 30 days in the future. The hash, not the
raw token, lands in the database. The caller (the endpoint) sets
the cookie with the raw token returned here.
"""
raw = _new_token()
h = _hash(raw)
ua = _trim_user_agent(user_agent)
cur = db.conn().execute(
f"""
INSERT INTO device_trust (user_id, device_token_hash, expires_at, user_agent)
VALUES (?, ?, datetime('now', '+{TRUST_DURATION_DAYS} days'), ?)
""",
(user_id, h, ua),
)
row_id = cur.lastrowid
return IssueOutcome(raw_token=raw, row_id=row_id)
# ---------------------------------------------------------------------------
# Lookup
# ---------------------------------------------------------------------------
@dataclass
class LookupOutcome:
"""The result of `lookup`.
`user` is populated only on a hit. `reason` distinguishes the
failure modes so the endpoint can decide whether to clear the
cookie ('expired', 'revoked', 'unknown') or just refuse ('invalid').
"""
ok: bool
user: SessionUser | None
reason: str # 'ok' | 'invalid' | 'unknown' | 'expired' | 'revoked'
row_id: int | None = None
def lookup(raw_token: str) -> LookupOutcome:
"""Resolve a presented cookie token to a user.
A hit refreshes `last_seen_at` on the matched row. A miss returns
a reason so the endpoint can clear the stale cookie if the row
was revoked or expired (vs. simply unknown, which probably means
the cookie was forged or the row was wiped by a /settings/devices
revoke from another browser).
"""
raw = (raw_token or "").strip()
if not raw:
return LookupOutcome(ok=False, user=None, reason="invalid")
# The unique index on `device_token_hash` would let us SELECT by
# hash if bcrypt were a stable hash, but bcrypt incorporates a
# per-row salt — equal tokens produce different hashes. We walk
# the candidate set instead. In practice the set is small (a
# human has a handful of trusted devices) and bcrypt is cheap on
# the order of milliseconds; the walk is bounded by the user's
# active device count.
#
# We don't pre-filter by `revoked_at IS NULL` here so that a
# token presented for a recently-revoked row produces a
# 'revoked' outcome (the endpoint surfaces a different shape).
# Same for expired: we let the walk hit and classify after.
rows = db.conn().execute(
"""
SELECT id, user_id, device_token_hash, expires_at, revoked_at
FROM device_trust
ORDER BY id DESC
""",
).fetchall()
matched = None
for row in rows:
if _check(raw, row["device_token_hash"]):
matched = row
break
if matched is None:
return LookupOutcome(ok=False, user=None, reason="unknown")
if matched["revoked_at"] is not None:
return LookupOutcome(ok=False, user=None, reason="revoked", row_id=matched["id"])
expired = db.conn().execute(
"SELECT datetime(?) < datetime('now') AS expired",
(matched["expires_at"],),
).fetchone()["expired"]
if expired:
return LookupOutcome(ok=False, user=None, reason="expired", row_id=matched["id"])
# Refresh last-seen so the /settings/devices surface can show the
# user when each device was last active. This is the only write
# the lookup path does on the hot read.
db.conn().execute(
"UPDATE device_trust SET last_seen_at = datetime('now') WHERE id = ?",
(matched["id"],),
)
user_row = db.conn().execute(
"""
SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state
FROM users
WHERE id = ?
""",
(matched["user_id"],),
).fetchone()
if user_row is None:
# The user row was deleted but the device_trust row hadn't
# cascaded yet (shouldn't happen under the FK ON DELETE
# CASCADE — be defensive anyway). Treat as 'unknown' so the
# endpoint clears the cookie.
return LookupOutcome(ok=False, user=None, reason="unknown", row_id=matched["id"])
# Also stamp last_seen_at on the user row so the user's overall
# activity stamp keeps pace with cookie-only sign-ins.
db.conn().execute(
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
(matched["user_id"],),
)
return LookupOutcome(
ok=True,
user=SessionUser(
user_id=user_row["id"],
gitea_id=user_row["gitea_id"] or 0,
gitea_login=user_row["gitea_login"] or "",
display_name=user_row["display_name"],
email=user_row["email"] or "",
avatar_url=user_row["avatar_url"] or "",
role=user_row["role"],
permission_state=user_row["permission_state"] or "granted",
),
reason="ok",
row_id=matched["id"],
)
# ---------------------------------------------------------------------------
# List / revoke (for the /settings/devices surface)
# ---------------------------------------------------------------------------
@dataclass
class DeviceRow:
"""The shape the /settings/devices endpoint returns.
Note the absence of `device_token_hash` the hash is structurally
private, and the surface has no use for it.
"""
id: int
created_at: str
expires_at: str
last_seen_at: str
user_agent: str
def list_for_user(user_id: int) -> list[DeviceRow]:
"""Active device-trust rows for the user, freshest first.
Filters out revoked rows and rows whose expiry has passed; the
surface only shows live trust grants. A user wondering "which
devices are signed in" gets the answer that matches what the
framework would actually accept on a presented cookie.
"""
rows = db.conn().execute(
"""
SELECT id, created_at, expires_at, last_seen_at, user_agent
FROM device_trust
WHERE user_id = ?
AND revoked_at IS NULL
AND datetime(expires_at) > datetime('now')
ORDER BY last_seen_at DESC, id DESC
""",
(user_id,),
).fetchall()
return [
DeviceRow(
id=row["id"],
created_at=row["created_at"],
expires_at=row["expires_at"],
last_seen_at=row["last_seen_at"],
user_agent=row["user_agent"] or "",
)
for row in rows
]
def revoke(user_id: int, row_id: int) -> bool:
"""Revoke a single device-trust row for the given user.
Returns True iff a row was matched (still active, belongs to the
user). The user-id scope is enforced in SQL so a hostile client
cannot revoke another user's row by guessing ids.
"""
cur = db.conn().execute(
"""
UPDATE device_trust
SET revoked_at = datetime('now')
WHERE id = ?
AND user_id = ?
AND revoked_at IS NULL
""",
(row_id, user_id),
)
return cur.rowcount > 0
def revoke_all(user_id: int) -> int:
"""Revoke every active device-trust row for the user. Returns the
count of rows touched.
The /settings/devices "revoke all" button calls this. The user's
current request stays authenticated via its session cookie; the
device-trust cookie on the current device is also revoked, but
the session middleware's `rfc_session` cookie keeps the request
flow alive until the user signs out or the session cookie
expires.
"""
cur = db.conn().execute(
"""
UPDATE device_trust
SET revoked_at = datetime('now')
WHERE user_id = ?
AND revoked_at IS NULL
""",
(user_id,),
)
return cur.rowcount
+61
View File
@@ -0,0 +1,61 @@
"""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)
+11
View File
@@ -139,6 +139,10 @@ _EVENT_TO_CATEGORY: dict[str, str] = {
"graduation_complete": "personal-direct",
"super_draft_graduation_ready": "admin-actionable",
"claim_opened": "structural",
# v0.9.0: roadmap item #7. A fresh beta-access request lands as
# an admin-actionable signal so it consults `email_admin_actionable`
# and reaches owners/admins only.
"new_beta_request": "admin-actionable",
}
@@ -285,6 +289,13 @@ def _deep_link(payload: dict, cfg: EmailConfig) -> str:
slug = payload.get("rfc_slug")
pr = payload.get("pr_number")
branch = payload.get("branch_name")
event_kind = payload.get("event_kind")
# v0.9.0: framework-scoped admin signals link to the admin
# surface, not /rfc/... The `new_beta_request` event is the
# canonical example; future framework-scoped admin events
# may reuse the same branch.
if event_kind == "new_beta_request":
return f"{cfg.app_url}/admin/users"
if slug and pr:
return f"{cfg.app_url}/rfc/{slug}/pr/{pr}"
if slug and branch:
+240 -4
View File
@@ -10,8 +10,8 @@ import logging
import secrets
from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI, HTTPException, Request
from fastapi.responses import RedirectResponse
from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import BaseModel, Field
from starlette.middleware.sessions import SessionMiddleware
@@ -20,11 +20,14 @@ from . import (
auth,
cache,
db,
device_trust as device_trust_mod,
digest,
email_otc,
hygiene,
otc,
passcode as passcode_mod,
providers as providers_mod,
turnstile,
webhooks,
)
from .bot import Bot
@@ -37,11 +40,36 @@ 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):
email: str = Field(min_length=3, max_length=320)
code: str = Field(min_length=1, max_length=16)
# v0.11.0 — "trust this device for 30 days" checkbox on the Login.jsx
# OTC step. When true and verify succeeds, the server issues a fresh
# device-trust row and sets the `rfc_device_trust` cookie on the
# response. Defaults to false so existing clients that don't send
# the flag continue to behave the way they did pre-v0.11.0.
trust_device: bool = False
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)
# v0.11.0 — same trust-device opt-in as the OTC verify body.
trust_device: bool = False
@asynccontextmanager
@@ -107,6 +135,48 @@ def create_app() -> FastAPI:
app = create_app()
def _set_device_trust_cookie(response: Response, raw_token: str) -> None:
"""Attach the v0.11.0 device-trust cookie to the response.
HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. The
cookie value is the raw token; server-side storage is the hash.
The cookie is "essential" per the v0.13.0 cookie-consent contract
(it is part of authentication), so we set it regardless of the
user's analytics / other-cookies choice.
Secure=True means the cookie is only ever sent over HTTPS. The
SessionMiddleware in `create_app` keeps `https_only=False` for
dev parity, but the device-trust cookie holds a 30-day credential
and must not travel cleartext production deployments serve over
HTTPS, so Secure on the device-trust cookie is non-negotiable.
"""
response.set_cookie(
key=device_trust_mod.COOKIE_NAME,
value=raw_token,
max_age=device_trust_mod.COOKIE_MAX_AGE_SECONDS,
path="/",
secure=True,
httponly=True,
samesite="lax",
)
def _clear_device_trust_cookie(response: Response) -> None:
"""Delete the device-trust cookie on the response.
Used when the framework detects a presented cookie that is
expired, revoked, or otherwise stale the next request from
this device will not carry a dead token.
"""
response.delete_cookie(
key=device_trust_mod.COOKIE_NAME,
path="/",
secure=True,
httponly=True,
samesite="lax",
)
def _oauth_router(config) -> APIRouter:
router = APIRouter()
@@ -154,7 +224,27 @@ def _oauth_router(config) -> APIRouter:
# ---------------------------------------------------------------
@router.post("/auth/otc/request")
async def otc_request(body: OtcRequestBody):
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")
outcome = otc.request_code(body.email)
if outcome.reason == "cooldown":
# Loud failure per the rate-limit primitive — the abuse
@@ -167,7 +257,7 @@ def _oauth_router(config) -> APIRouter:
return {"ok": True}
@router.post("/auth/otc/verify")
async def otc_verify(body: OtcVerifyBody, request: Request):
async def otc_verify(body: OtcVerifyBody, request: Request, response: Response):
result = otc.verify_code(body.email, body.code)
if not result.ok or result.user is None:
raise HTTPException(400, "Invalid or expired code")
@@ -192,6 +282,18 @@ def _oauth_router(config) -> APIRouter:
and not last_name
and not beta_request_reason
)
# v0.11.0 — opt-in device trust. The checkbox lives on the
# Login.jsx OTC step; when true, the server mints a fresh
# device-trust row and sets the long-lived cookie. The cookie
# is "essential" per the v0.13.0 consent contract (it is part
# of authentication, not analytics) so it lands regardless of
# the user's analytics / other-cookies choice. We capture the
# User-Agent at issuance so the /settings/devices surface can
# render a rough device label.
if body.trust_device:
ua = request.headers.get("user-agent", "")
outcome = device_trust_mod.issue(result.user.user_id, ua)
_set_device_trust_cookie(response, outcome.raw_token)
return {
"ok": True,
"user": {
@@ -204,4 +306,138 @@ def _oauth_router(config) -> APIRouter:
"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, response: Response):
"""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).
v0.11.0: the body's `trust_device` flag, if true, mints a
fresh device-trust row and sets the long-lived cookie. Same
opt-in contract as `/auth/otc/verify`."""
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)
if body.trust_device:
ua = request.headers.get("user-agent", "")
outcome = device_trust_mod.issue(result.user.user_id, ua)
_set_device_trust_cookie(response, outcome.raw_token)
return {
"ok": True,
"user": {
"id": result.user.user_id,
"display_name": result.user.display_name,
"email": result.user.email,
"role": result.user.role,
},
}
# ---------------------------------------------------------------
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
#
# The /auth/device-trust/start endpoint resolves a presented
# `rfc_device_trust` cookie. If it matches a non-expired,
# non-revoked row, the session is re-established and the client
# is told to skip OTC/passcode entry. A stale cookie (expired or
# revoked) is cleared on the response. A miss is structurally
# silent — the client falls back to the email step.
#
# The endpoint is anonymous-reachable: a returning visitor with
# the cookie hits this before the email step. We do not gate it
# on a session because the entire point is to establish one.
# ---------------------------------------------------------------
@router.post("/auth/device-trust/start")
async def device_trust_start(request: Request):
"""Sign in via a presented device-trust cookie.
On a hit, re-establishes the session in the cookie store and
returns a user payload shaped like /auth/otc/verify (minus
`needs_profile`, which a returning device-trust user is
structurally past they signed in at least once before).
On a miss, returns 401 + clears the stale cookie. An
'unknown' miss (cookie present but no row matches) also
clears, since the token is dead to the server either way.
Note on response construction: we return a `JSONResponse`
directly rather than raising `HTTPException` on the miss
path because FastAPI's exception handler builds a new
response from scratch and would drop any `set_cookie` /
`delete_cookie` calls. The hand-built `JSONResponse` lets
us attach the cookie-clear header alongside the 401.
"""
raw = request.cookies.get(device_trust_mod.COOKIE_NAME, "")
if not raw:
return JSONResponse({"detail": "No device trust"}, status_code=401)
outcome = device_trust_mod.lookup(raw)
if not outcome.ok or outcome.user is None:
# Clear the stale cookie so subsequent requests don't
# keep replaying a dead token. We surface 401 in all
# cases so a probing client can't tell "your row was
# revoked" from "this token never existed".
response = JSONResponse({"detail": "Device trust invalid"}, status_code=401)
_clear_device_trust_cookie(response)
return response
auth.store_session(request, outcome.user)
return {
"ok": True,
"user": {
"id": outcome.user.user_id,
"display_name": outcome.user.display_name,
"email": outcome.user.email,
"role": outcome.user.role,
"permission_state": outcome.user.permission_state,
},
}
return router
+72
View File
@@ -64,6 +64,7 @@ log = logging.getLogger(__name__)
CATEGORY_PERSONAL = "personal-direct"
CATEGORY_STRUCTURAL = "structural"
CATEGORY_CHURN = "churn"
CATEGORY_ADMIN_ACTIONABLE = "admin-actionable"
# Action kinds whose actor's first interaction with a slug triggers
# auto-watch per §15.6. The substantive-gesture list in the spec is
@@ -208,6 +209,67 @@ def fan_out_from_action(
)
def fan_out_new_beta_request(
*,
requester_user_id: int,
) -> None:
"""v0.9.0 (roadmap item #7): announce a fresh beta-access request to
every admin/owner.
Called from `POST /api/auth/me/beta-request` after the row's
first/last/why fields are populated. Fan-out shape mirrors the §15
chokepoint contract: one row per recipient, written via `_emit_one`
so the SSE broadcast + email dispatch run through the same surface
every other notification uses. The event has no rfc_slug (it is
framework-scoped, not RFC-scoped); the deep-link payload points
`/admin/users` instead of `/rfc/<slug>`.
Actor is the requester per §15.9 (the underlying user, never the
bot). Category is `admin-actionable` so the §15.4 email gate
consults `email_admin_actionable` (owners/admins-only by
construction) and the digest exclusion rules treat it identically
to other admin-actionable signals (graduation_ready et al).
Recipients are owners + admins minus the requester themselves
(a self-promotion shouldn't reach the requester's own inbox). The
requester is never in the role set in practice the endpoint
refuses 'granted'/'revoked' callers and a fresh OTC user lands
`contributor`+`pending` but we filter regardless so the call
is robust to future changes in the auth gate.
"""
requester = db.conn().execute(
"SELECT first_name, last_name, email, display_name FROM users WHERE id = ?",
(requester_user_id,),
).fetchone()
if requester is None:
return
first = (requester["first_name"] or "").strip()
last = (requester["last_name"] or "").strip()
email = requester["email"] or ""
display = requester["display_name"] or email or "a new user"
full_name = (f"{first} {last}").strip() or display
details = {
"requester_user_id": requester_user_id,
"requester_first_name": first,
"requester_last_name": last,
"requester_email": email,
"requester_display": full_name,
}
for recipient_id in _admin_user_ids():
if recipient_id == requester_user_id:
continue
_emit_one(
recipient_user_id=recipient_id,
event_kind="new_beta_request",
category=CATEGORY_ADMIN_ACTIONABLE,
actor_user_id=requester_user_id,
rfc_slug=None,
branch_name=None,
pr_number=None,
details=details,
)
def fan_out_chat_message(
*,
actor_user_id: int,
@@ -707,6 +769,16 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str |
return f"{actor} began graduating {title}."
if event_kind == "pr_conflict_with_main":
return f"{actor} started a resolution branch on {title}."
if event_kind == "new_beta_request":
# v0.9.0: framework-scoped, not RFC-scoped. The actor (the
# requester) and the captured full name + email read as
# one self-contained sentence; the inbox row and the email
# body share this text per §15.4.
full_name = extras.get("requester_display") or actor
email_addr = extras.get("requester_email") or ""
if email_addr:
return f"New beta-access request from {full_name} ({email_addr})."
return f"New beta-access request from {full_name}."
return f"{event_kind} on {title}"
+367
View File
@@ -0,0 +1,367 @@
"""§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
@@ -0,0 +1,148 @@
"""§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")
+52
View File
@@ -0,0 +1,52 @@
-- §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;
+75
View File
@@ -0,0 +1,75 @@
-- §6.2 / v0.11.0: trust device for 30 days (roadmap item #9).
--
-- After a successful OTC or passcode sign-in, the user can check
-- "trust this device for 30 days." The framework then issues a
-- server-issued opaque device-trust token, stores its hash on this
-- table, and sets a long-lived HttpOnly + Secure + SameSite=Lax
-- cookie carrying the raw token. On a subsequent visit, the cookie is
-- presented at `/auth/device-trust/start`; if the server can match the
-- hash to a non-expired non-revoked row, the user is signed in without
-- another OTC / passcode round-trip.
--
-- v0.11.0 introduces no new env vars. The 30-day window is hard-coded
-- in `backend/app/device_trust.py`; raising or lowering it (or making
-- it user-selectable) is a §19.2 candidate, alongside the cross-device
-- session-revocation surface this table will eventually share with the
-- v0.10.0 passcode-lockout shape (see SPEC §19.2 / SESSIONS-AND-DEVICES).
--
-- Storage shape:
--
-- * `id` — surrogate key. Lets the revoke-device UI address a single
-- row by id without leaking the token shape.
-- * `user_id` — FK into users(id) with cascade on delete. A deleted
-- user automatically loses every trusted device.
-- * `device_token_hash` — bcrypt hash of the random opaque token
-- issued at trust-time. The raw token only ever lives in the
-- outbound `Set-Cookie` header and the inbound `Cookie` header;
-- server-side storage is the hash, so a DB compromise does not
-- hand attackers a stash of valid device tokens.
-- * `created_at` — when the row was issued.
-- * `expires_at` — `created_at + 30 days`. A row past this timestamp
-- is dead; the lookup path refuses it without further checks.
-- * `user_agent` — the User-Agent header captured at issuance.
-- Stored verbatim (truncated to 1024 chars at the application
-- layer) so the revoke-device UI can show a rough device label.
-- Not used for any auth decision — purely a hint to the user
-- reviewing their device list.
-- * `last_seen_at` — refreshed every time the row authenticates a
-- request. Lets the revoke-device UI surface "last used 3 days
-- ago" so the user can tell which row corresponds to which
-- device.
-- * `revoked_at` — NULL means active; non-NULL stamps when the user
-- (or admin) revoked the row. Lookups treat any non-NULL value
-- as "this row is dead" without consulting the expiry; the
-- revoke gesture is intentionally one-way (a revoked device must
-- re-trust to come back online).
--
-- Indexing: a unique index on `device_token_hash` so collisions are
-- detectable at insert time (the token space is 256 bits of CSPRNG
-- entropy, so a collision is structurally impossible, but the
-- declaration documents the invariant). A separate index on
-- `(user_id, revoked_at)` so the revoke-device UI's list query is
-- a covering walk.
--
-- The bcrypt dependency reused here was added in v0.7.0 for OTC and
-- extended in v0.10.0 for passcodes; v0.11.0 needs no new dep.
--
-- The cookie shape: `rfc_device_trust` carries the raw token,
-- HttpOnly, Secure, SameSite=Lax, Max-Age=2592000 (30 days). It is
-- "essential" per the v0.13.0 cookie-consent banner (it is part of
-- authentication, not analytics), so it is set regardless of the
-- user's analytics / other-cookies choices.
CREATE TABLE device_trust (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_token_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
user_agent TEXT NOT NULL DEFAULT '',
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
revoked_at TEXT
);
CREATE UNIQUE INDEX idx_device_trust_token_hash ON device_trust (device_token_hash);
CREATE INDEX idx_device_trust_user ON device_trust (user_id, revoked_at);
+425
View File
@@ -0,0 +1,425 @@
"""End-to-end integration tests for v0.9.0's admin user-management page
and new-beta-request notifications (roadmap item #7, §6.1 / §15).
The release lands two halves of the same surface:
* **Admin notification on new beta request.** When a pending user
submits `POST /api/auth/me/beta-request`, every owner/admin
receives a `new_beta_request` notification (the §15 substrate
insert lands the row; the §15.4 email path dispatches subject to
the recipient's `email_admin_actionable` toggle).
* **Admin user-management surface** at `/admin/users`. The
`GET /api/admin/users` listing carries every user with their
permission_state, profile fields, sign-up reason, and decision
audit. The new `POST /api/admin/users/<id>/permission` endpoint
flips the column and writes a `permission_events` row.
The tests prove:
* The first beta-request submission fans a `new_beta_request`
row out to every admin/owner (and not to the requester
themselves). The row carries the captured profile in
`payload.extras`.
* Re-submitting the form from the same pending user doesn't
re-fan (we only notify on the row's first complete state).
* `GET /api/admin/users` carries the v0.9.0 columns
(permission_state, first/last/reason, decided_by).
* `POST /api/admin/users/<id>/permission` flips the state,
stamps decided_by/at, and writes a `permission_events` row.
* The endpoint refuses self-flip (422) and refuses non-admin
callers (403).
* The endpoint accepts only the three valid states (422 on
anything else).
"""
from __future__ import annotations
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]:
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 _provision_pending_user(client, email: str) -> int:
"""Sign in a fresh OTC user (lands `pending`) and return their user_id."""
from app import db
_reset_outbound()
client.post("/auth/otc/request", json={"email": email})
code = _outbound_otc_codes(email)[-1]
client.post("/auth/otc/verify", json={"email": email, "code": code})
row = db.conn().execute(
"SELECT id FROM users WHERE email = ? COLLATE NOCASE", (email,)
).fetchone()
return row["id"]
# ---------------------------------------------------------------------------
# Admin notification on beta-request submission
# ---------------------------------------------------------------------------
def test_beta_request_submission_notifies_every_admin(app_with_fake_gitea):
"""First-time submission of a beta-request fans a notification out
to every owner and admin. The requester themselves never receives
a row (filtered out by user_id even if they happened to be in the
admin set, which they aren't in practice — fresh OTC users are
`contributor`+`pending`)."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# Provision two admins and one owner so the fan-out has multiple
# targets. The OWNER_GITEA_LOGIN-derived ownership doesn't fire
# here (no OAuth round-trip in this path); we seed the role
# directly.
provision_user_row(user_id=10, login="ownerzero", role="owner")
provision_user_row(user_id=11, login="admin_one", role="admin")
provision_user_row(user_id=12, login="admin_two", role="admin")
provision_user_row(user_id=13, login="contrib_one", role="contributor")
# Sign in a fresh OTC user → permission_state='pending'.
requester_id = _provision_pending_user(client, "newbie@example.com")
# Capture-form submit.
r = client.post(
"/api/auth/me/beta-request",
json={
"first_name": "Newt",
"last_name": "Newcomer",
"beta_request_reason": "I want to write the Human RFC.",
},
)
assert r.status_code == 200, r.text
# Every owner + admin gets a `new_beta_request` notification.
# The contributor (id=13) does not. The requester (whoever id
# they got) does not.
rows = db.conn().execute(
"""
SELECT recipient_user_id, event_kind, actor_user_id, payload
FROM notifications
WHERE event_kind = 'new_beta_request'
"""
).fetchall()
recipients = sorted(r["recipient_user_id"] for r in rows)
assert recipients == [10, 11, 12], f"unexpected recipients: {recipients}"
# Actor is the requester (§15.9: never the bot).
for r in rows:
assert r["actor_user_id"] == requester_id
import json as _json
extras = _json.loads(r["payload"])
assert extras["requester_first_name"] == "Newt"
assert extras["requester_last_name"] == "Newcomer"
assert extras["requester_email"] == "newbie@example.com"
def test_beta_request_resubmit_does_not_re_notify(app_with_fake_gitea):
"""Once a user has completed the capture form, re-submitting it
(the endpoint is idempotent for pending users) must not re-fan a
fresh notification to every admin that would carpet-bomb the
inbox on every typo correction."""
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=20, login="adminzero", role="admin")
_provision_pending_user(client, "carpet@example.com")
body = {
"first_name": "Carpet",
"last_name": "Bomb",
"beta_request_reason": "first draft",
}
r1 = client.post("/api/auth/me/beta-request", json=body)
assert r1.status_code == 200
# Re-submit with edited reason — endpoint accepts (idempotent
# update), but the admin inbox stays at one row.
body2 = dict(body, beta_request_reason="cleaner final draft")
r2 = client.post("/api/auth/me/beta-request", json=body2)
assert r2.status_code == 200
rows = db.conn().execute(
"SELECT COUNT(*) AS n FROM notifications WHERE event_kind = 'new_beta_request'"
).fetchone()
assert rows["n"] == 1
def test_beta_request_notification_is_admin_actionable_category(app_with_fake_gitea):
"""The §15.4 category mapping must route `new_beta_request` to the
admin-actionable bucket so the email gate consults
`email_admin_actionable` (and skips for non-admin recipients).
"""
from app import email as email_mod
assert email_mod.category_for("new_beta_request", "structural") == "admin-actionable"
# ---------------------------------------------------------------------------
# /api/admin/users — listing carries the v0.9.0 columns
# ---------------------------------------------------------------------------
def test_admin_users_listing_carries_permission_columns(app_with_fake_gitea):
"""The Users tab consumes this shape — confirm every required
column is on the response."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# Seed an admin and a pending user with all the v0.8.0 columns
# populated. Direct-DB insert avoids the OTC dance (which would
# overwrite the cookie); the test above proves the capture
# pathway end-to-end and this one just exercises the listing
# surface's shape.
provision_user_row(user_id=30, login="ben", role="owner")
db.conn().execute(
"""
INSERT INTO users (id, gitea_id, gitea_login, email,
display_name, avatar_url, role,
permission_state, first_name, last_name,
beta_request_reason)
VALUES (31, NULL, NULL, 'pendinguser@example.com',
'pendinguser', '', 'contributor',
'pending', 'Penn', 'Ding', 'I want in.')
"""
)
sign_in_as(
client, user_id=30, gitea_login="ben",
display_name="Ben", role="owner",
)
r = client.get("/api/admin/users")
assert r.status_code == 200
items = r.json()["items"]
assert isinstance(items, list)
pending = next(
(i for i in items if i["email"] == "pendinguser@example.com"), None,
)
assert pending is not None
assert pending["permission_state"] == "pending"
assert pending["first_name"] == "Penn"
assert pending["last_name"] == "Ding"
assert pending["beta_request_reason"] == "I want in."
assert pending["permission_decided_at"] is None
assert pending["permission_decided_by_login"] is None
# Pending bucket is listed first (sort order).
assert items[0]["permission_state"] == "pending"
# ---------------------------------------------------------------------------
# /api/admin/users/<id>/permission — the flip endpoint
# ---------------------------------------------------------------------------
def test_permission_flip_grant_promotes_pending_to_granted(app_with_fake_gitea):
"""The end-to-end gesture: a fresh OTC user lands pending, an admin
flips them to granted via the endpoint, the row reflects the new
state + decided_by/at, and a `permission_events` audit row lands."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# Pending user.
pending_id = _provision_pending_user(client, "flip@example.com")
# Admin acting on them.
provision_user_row(user_id=40, login="adminflipper", role="admin")
sign_in_as(
client, user_id=40, gitea_login="adminflipper",
display_name="Admin Flipper", role="admin",
)
r = client.post(
f"/api/admin/users/{pending_id}/permission",
json={"state": "granted"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["permission_state"] == "granted"
assert body["changed"] is True
# Row reflects the new state + decision stamp.
row = db.conn().execute(
"SELECT permission_state, permission_decided_by, permission_decided_at "
"FROM users WHERE id = ?",
(pending_id,),
).fetchone()
assert row["permission_state"] == "granted"
assert row["permission_decided_by"] == 40
assert row["permission_decided_at"] is not None
# Audit row landed in permission_events.
events = db.conn().execute(
"""
SELECT actor_user_id, subject_user_id, event_kind
FROM permission_events
WHERE event_kind = 'permission_granted'
"""
).fetchall()
assert len(events) == 1
assert events[0]["actor_user_id"] == 40
assert events[0]["subject_user_id"] == pending_id
def test_permission_flip_revoke_promotes_granted_to_revoked(app_with_fake_gitea):
"""Revoke is the symmetric gesture. Used when an account earned a
grant then later lost it (§6.1 / `revoked` state)."""
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=50, login="goner", role="contributor")
# Default permission_state is 'granted' via the column default.
provision_user_row(user_id=51, login="adminrevoker", role="admin")
sign_in_as(
client, user_id=51, gitea_login="adminrevoker",
display_name="Admin Revoker", role="admin",
)
r = client.post(
"/api/admin/users/50/permission",
json={"state": "revoked"},
)
assert r.status_code == 200, r.text
row = db.conn().execute(
"SELECT permission_state FROM users WHERE id = 50"
).fetchone()
assert row["permission_state"] == "revoked"
events = db.conn().execute(
"SELECT event_kind FROM permission_events "
"WHERE event_kind = 'permission_revoked' AND subject_user_id = 50"
).fetchall()
assert len(events) == 1
def test_permission_flip_refuses_self(app_with_fake_gitea):
"""Symmetric to set_mute / set_role: an admin can't self-flip.
The state-change channel for one's own grant is somebody else's
hand."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=60, login="selfflipper", role="admin")
sign_in_as(
client, user_id=60, gitea_login="selfflipper",
display_name="Self Flipper", role="admin",
)
r = client.post(
"/api/admin/users/60/permission",
json={"state": "revoked"},
)
assert r.status_code == 422
def test_permission_flip_refuses_non_admin(app_with_fake_gitea):
"""The endpoint is admin-only (§17 admin/* requires require_admin).
A contributor caller is refused 403; an anonymous caller 401."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=70, login="target", role="contributor")
provision_user_row(user_id=71, login="contrib", role="contributor")
sign_in_as(
client, user_id=71, gitea_login="contrib",
display_name="Contrib", role="contributor",
)
r = client.post(
"/api/admin/users/70/permission",
json={"state": "granted"},
)
assert r.status_code == 403
client.cookies.clear()
r = client.post(
"/api/admin/users/70/permission",
json={"state": "granted"},
)
assert r.status_code == 401
def test_permission_flip_refuses_invalid_state(app_with_fake_gitea):
"""Pydantic regex pattern refuses anything outside the three
canonical states with 422."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=80, login="targetx", role="contributor")
provision_user_row(user_id=81, login="adminx", role="admin")
sign_in_as(
client, user_id=81, gitea_login="adminx",
display_name="Admin X", role="admin",
)
r = client.post(
"/api/admin/users/80/permission",
json={"state": "banished"},
)
assert r.status_code == 422
def test_permission_flip_no_op_when_state_already_matches(app_with_fake_gitea):
"""An admin flipping a granted user to granted gets 200 with
`changed: false` no audit row, no decided_at update."""
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=90, login="alreadygranted", role="contributor")
provision_user_row(user_id=91, login="adminN", role="admin")
sign_in_as(
client, user_id=91, gitea_login="adminN",
display_name="Admin N", role="admin",
)
before_events = db.conn().execute(
"SELECT COUNT(*) AS n FROM permission_events"
).fetchone()["n"]
r = client.post(
"/api/admin/users/90/permission",
json={"state": "granted"},
)
assert r.status_code == 200
body = r.json()
assert body["changed"] is False
after_events = db.conn().execute(
"SELECT COUNT(*) AS n FROM permission_events"
).fetchone()["n"]
assert after_events == before_events
+494
View File
@@ -0,0 +1,494 @@
"""End-to-end integration tests for the v0.11.0 trust-device vertical
(§6.2, roadmap item #9).
After a successful OTC or passcode sign-in with `trust_device=true`
on the body, the server mints a fresh `device_trust` row and sets the
`rfc_device_trust` cookie. On a subsequent visit, the cookie carries
a session re-established by `POST /auth/device-trust/start`. The
tests below prove:
* `trust_device=false` (default, including omitted) on OTC verify
does NOT set the device-trust cookie and does NOT insert a row.
* `trust_device=true` on OTC verify DOES set the cookie (HttpOnly +
Secure + SameSite=Lax + 30-day Max-Age) and DOES insert a row.
The row's hash is NOT the raw token; only the hash lives in the
database.
* Same shape for passcode verify.
* On a returning visit with the cookie, `POST /auth/device-trust/start`
re-establishes the session `GET /api/auth/me` reads the right
user without an OTC roundtrip.
* `last_seen_at` refreshes on a successful lookup.
* `POST /auth/device-trust/start` with no cookie returns 401.
* `POST /auth/device-trust/start` with a forged / unknown cookie
returns 401 + clears the cookie.
* A revoked row refuses the cookie (401) and clears it.
* An expired row refuses the cookie (401) and clears it.
* `GET /api/auth/me/devices` lists the user's active rows.
* `DELETE /api/auth/me/devices/{id}` revokes a single row.
* `DELETE /api/auth/me/devices/{id}` for another user's row reads 404.
* `DELETE /api/auth/me/devices` revokes every active row.
* Constant-time path: bcrypt.checkpw guards lookup; the raw token
is never written to logs or to the DB.
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.
# ---------------------------------------------------------------------------
COOKIE_NAME = "rfc_device_trust"
# The device-trust cookie is set with Secure=True, which httpx (the
# TestClient's underlying transport) will only return on an https
# scheme. We use a `base_url="https://testserver"` so the cookie
# roundtrips faithfully — that mirrors how production deployments
# serve the framework (per the v0.11.0 upgrade-step requiring HTTPS).
HTTPS_BASE = "https://testserver"
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, *, trust_device: bool = False) -> None:
r = client.post("/auth/otc/request", json={"email": email})
assert r.status_code == 200, r.text
code = _outbound_otc_codes(email)[-1]
body = {"email": email, "code": code, "trust_device": trust_device}
r = client.post("/auth/otc/verify", json=body)
assert r.status_code == 200, r.text
def _device_rows_for_email(email: str) -> list[dict]:
from app import db
rows = db.conn().execute(
"""
SELECT dt.*
FROM device_trust dt
JOIN users u ON u.id = dt.user_id
WHERE u.email = ? COLLATE NOCASE
ORDER BY dt.id
""",
(email,),
).fetchall()
return [dict(r) for r in rows]
# ---------------------------------------------------------------------------
# trust_device flag controls cookie issuance
# ---------------------------------------------------------------------------
def test_otc_verify_without_trust_device_does_not_issue_cookie(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
_sign_in_via_otc(client, "alice@example.com", trust_device=False)
# No cookie set on the response.
assert COOKIE_NAME not in {c.name for c in client.cookies.jar}
# No row inserted.
assert _device_rows_for_email("alice@example.com") == []
def test_otc_verify_with_trust_device_issues_cookie_and_row(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
headers={"User-Agent": "Mozilla/5.0 (TestBrowser)"},
)
assert r.status_code == 200, r.text
# Cookie present on the response.
set_cookie = r.headers.get("set-cookie", "")
assert COOKIE_NAME in set_cookie
# Cookie attribute set asserts the spec'd shape. Starlette emits
# the attribute names case-insensitively (`samesite=lax`,
# `httponly`); we normalize when asserting.
lower = set_cookie.lower()
assert "httponly" in lower
assert "secure" in lower
assert "samesite=lax" in lower
assert "max-age=" in lower
# Row inserted; hash is not the raw token.
rows = _device_rows_for_email("alice@example.com")
assert len(rows) == 1
row = rows[0]
assert row["revoked_at"] is None
assert row["user_agent"] == "Mozilla/5.0 (TestBrowser)"
cookie_token = client.cookies.get(COOKIE_NAME)
assert cookie_token
assert cookie_token != row["device_token_hash"]
# bcrypt hash shape (starts with $2)
assert row["device_token_hash"].startswith("$2")
def test_passcode_verify_with_trust_device_issues_cookie(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
_sign_in_via_otc(client, "alice@example.com")
# Set a passcode.
r = client.post("/auth/passcode/set", json={"passcode": "secret123"})
assert r.status_code == 200, r.text
# Sign out so the passcode verify path is the active sign-in.
client.cookies.clear()
# Passcode verify with trust_device=true issues a row.
r = client.post(
"/auth/passcode/verify",
json={"email": "alice@example.com", "passcode": "secret123", "trust_device": True},
headers={"User-Agent": "Test/Phone"},
)
assert r.status_code == 200, r.text
set_cookie = r.headers.get("set-cookie", "")
assert COOKIE_NAME in set_cookie
rows = _device_rows_for_email("alice@example.com")
assert len(rows) == 1
assert rows[0]["user_agent"] == "Test/Phone"
# ---------------------------------------------------------------------------
# /auth/device-trust/start
# ---------------------------------------------------------------------------
def test_device_trust_start_with_no_cookie_returns_401(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
r = client.post("/auth/device-trust/start")
assert r.status_code == 401
def test_device_trust_start_with_valid_cookie_establishes_session(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
# Trust the device.
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
)
assert r.status_code == 200, r.text
trust_cookie = client.cookies.get(COOKIE_NAME)
assert trust_cookie
# Clear the session cookie so only the device-trust cookie is in
# play. We keep `rfc_device_trust` and drop `rfc_session`.
for cookie in list(client.cookies.jar):
if cookie.name != COOKIE_NAME:
client.cookies.jar.clear(cookie.domain, cookie.path, cookie.name)
# The session cookie is gone — /api/auth/me reads anonymous.
me = client.get("/api/auth/me").json()
assert me["authenticated"] is False
# Hit the trust-start endpoint; the cookie re-establishes the session.
r = client.post("/auth/device-trust/start")
assert r.status_code == 200, r.text
assert r.json()["user"]["email"] == "alice@example.com"
# /api/auth/me now reads authenticated.
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["email"] == "alice@example.com"
def test_device_trust_start_refreshes_last_seen_at(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
)
assert r.status_code == 200, r.text
# Force the existing row's last_seen_at into the past so we can
# assert the refresh moved it forward.
db.conn().execute(
"""
UPDATE device_trust
SET last_seen_at = datetime('now', '-7 days')
"""
)
# Hit the start endpoint.
r = client.post("/auth/device-trust/start")
assert r.status_code == 200, r.text
# last_seen_at is now recent (within the last minute).
row = db.conn().execute(
"SELECT last_seen_at, datetime('now') >= datetime(last_seen_at, '-1 minute') AS fresh FROM device_trust LIMIT 1"
).fetchone()
assert row["fresh"] == 1
def test_device_trust_start_with_revoked_row_refuses_and_clears(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
)
assert r.status_code == 200, r.text
# Revoke the row out-of-band.
db.conn().execute(
"UPDATE device_trust SET revoked_at = datetime('now')"
)
# Now the start endpoint refuses + clears the cookie.
r = client.post("/auth/device-trust/start")
assert r.status_code == 401
# The cookie is cleared via a Set-Cookie header with Max-Age=0
# (Starlette's `delete_cookie` shape).
set_cookie = r.headers.get("set-cookie", "")
assert COOKIE_NAME in set_cookie
assert "Max-Age=0" in set_cookie or 'expires=Thu, 01 Jan 1970' in set_cookie.lower().replace("expires=thu", "expires=Thu")
def test_device_trust_start_with_expired_row_refuses_and_clears(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
)
assert r.status_code == 200, r.text
# Backdate the expiry into the past.
db.conn().execute(
"UPDATE device_trust SET expires_at = datetime('now', '-1 day')"
)
r = client.post("/auth/device-trust/start")
assert r.status_code == 401
set_cookie = r.headers.get("set-cookie", "")
assert COOKIE_NAME in set_cookie
def test_device_trust_start_with_forged_cookie_refuses_and_clears(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
# No real row; just paste a cookie value.
client.cookies.set(COOKIE_NAME, "definitely-not-a-real-token-value-xxx")
r = client.post("/auth/device-trust/start")
assert r.status_code == 401
set_cookie = r.headers.get("set-cookie", "")
assert COOKIE_NAME in set_cookie
# ---------------------------------------------------------------------------
# /api/auth/me/devices — list + revoke
# ---------------------------------------------------------------------------
def test_list_devices_requires_session(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
r = client.get("/api/auth/me/devices")
assert r.status_code == 401
def test_list_devices_returns_active_rows_only(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
# Add a second trusted device by re-running the verify flow.
# OTC has a per-email cooldown, so drop the cooldown rather
# than waiting it out.
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
headers={"User-Agent": "Test/Tablet"},
)
assert r.status_code == 200, r.text
# Revoke one row directly.
db.conn().execute(
"UPDATE device_trust SET revoked_at = datetime('now') WHERE id = 1"
)
# /api/auth/me/devices returns only the un-revoked one.
r = client.get("/api/auth/me/devices")
assert r.status_code == 200, r.text
items = r.json()["items"]
assert len(items) == 1
assert items[0]["user_agent"] == "Test/Tablet"
def test_revoke_single_device_kills_the_row(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
r = client.get("/api/auth/me/devices")
assert r.status_code == 200, r.text
items = r.json()["items"]
assert len(items) == 1
device_id = items[0]["id"]
# Revoke it.
r = client.delete(f"/api/auth/me/devices/{device_id}")
assert r.status_code == 200, r.text
# List is empty.
r = client.get("/api/auth/me/devices")
assert r.json()["items"] == []
# The row in the table has revoked_at populated.
row = db.conn().execute(
"SELECT revoked_at FROM device_trust WHERE id = ?", (device_id,)
).fetchone()
assert row["revoked_at"] is not None
def test_revoke_other_users_device_reads_404(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
# Alice trusts a device.
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
alice_device_id = client.get("/api/auth/me/devices").json()["items"][0]["id"]
# Bob signs in (without a trusted device of his own).
client.cookies.clear()
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
_sign_in_via_otc(client, "bob@example.com", trust_device=False)
# Bob tries to revoke Alice's row by id.
r = client.delete(f"/api/auth/me/devices/{alice_device_id}")
assert r.status_code == 404
# Alice's row is still active.
row = db.conn().execute(
"SELECT revoked_at FROM device_trust WHERE id = ?", (alice_device_id,)
).fetchone()
assert row["revoked_at"] is None
def test_revoke_all_devices_kills_every_active_row(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
# Add a second device.
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
)
assert r.status_code == 200, r.text
# Two active rows.
assert len(client.get("/api/auth/me/devices").json()["items"]) == 2
# Revoke all.
r = client.delete("/api/auth/me/devices")
assert r.status_code == 200, r.text
assert r.json()["revoked"] == 2
# List is empty.
assert client.get("/api/auth/me/devices").json()["items"] == []
+532
View File
@@ -0,0 +1,532 @@
"""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
@@ -0,0 +1,220 @@
"""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") == []
+13
View File
@@ -49,3 +49,16 @@ VITE_PRIVACY_POLICY_URL=
# 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.8.0",
"version": "0.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
"version": "0.8.0",
"version": "0.12.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.8.0",
"version": "0.12.0",
"type": "module",
"scripts": {
"dev": "vite",
+107
View File
@@ -455,6 +455,65 @@
.otc-fallback a:hover { color: #1a1a1a; text-decoration: underline; }
.otc-fallback-sep { color: #ccc; }
/* v0.11.0 "trust this device for 30 days" checkbox on the verify
step. Sits above the action row, padded so it doesn't crowd the
passcode/code input. */
.otc-trust-device {
display: flex; align-items: center; gap: 8px;
font-size: 13px; color: #444;
margin: 8px 0 4px;
cursor: pointer;
user-select: none;
}
.otc-trust-device input[type="checkbox"] {
width: auto; margin: 0; cursor: pointer;
}
/* v0.11.0 — /settings/devices revoke-device UI. */
.device-list {
list-style: none; padding: 0; margin: 12px 0 0;
}
.device-list-item {
display: flex; align-items: center; justify-content: space-between;
gap: 12px;
border: 1px solid #eee; border-radius: 6px;
padding: 10px 12px; margin: 0 0 8px;
background: #fafafa;
}
.device-list-item .device-meta {
flex: 1; min-width: 0;
}
.device-list-item .device-ua {
font-size: 13px; color: #1a1a1a;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.device-list-item .device-stamps {
font-size: 12px; color: #777;
margin-top: 2px;
}
.device-list-item button {
font-size: 12px; padding: 4px 10px;
border: 1px solid #ccc; border-radius: 4px;
background: white; cursor: pointer;
}
.device-list-item button:hover:not(:disabled) {
background: #f5f5f5;
}
.device-revoke-all {
margin-top: 8px;
font-size: 13px; padding: 6px 12px;
border: 1px solid #cb6a6a; border-radius: 4px;
background: white; color: #cb6a6a; cursor: pointer;
}
.device-revoke-all:hover:not(:disabled) {
background: #fff5f5;
}
.device-empty {
font-size: 13px; color: #777;
background: #fafafa; border: 1px solid #eee; border-radius: 6px;
padding: 12px;
}
/* --- Beta-pending page (post-OAuth-rejection) --- */
.beta-pending {
@@ -1889,6 +1948,54 @@
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; cursor: pointer;
}
/* v0.9.0 — admin user-management surface (roadmap item #7). */
.admin-filter-chips {
display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap;
}
.admin-chip {
display: inline-flex; align-items: center; gap: 6px;
background: #fff; border: 1px solid #d1d5db; border-radius: 999px;
padding: 4px 12px; font-size: 12px; color: #374151; cursor: pointer;
}
.admin-chip:hover { background: #f9fafb; }
.admin-chip.active {
background: #111; color: #fff; border-color: #111;
}
.admin-chip-count {
font-size: 11px; opacity: 0.7;
}
.admin-users-table td { vertical-align: top; padding-top: 10px; padding-bottom: 10px; }
.permission-cell { display: flex; flex-direction: column; gap: 4px; }
.permission-actions { display: flex; gap: 6px; }
.permission-badge {
display: inline-block;
font-size: 11px; font-weight: 600;
padding: 2px 8px; border-radius: 999px;
text-transform: uppercase; letter-spacing: 0.04em;
width: max-content;
}
.permission-badge-pending {
background: #fef3c7; color: #92400e;
}
.permission-badge-granted {
background: #dcfce7; color: #166534;
}
.permission-badge-revoked {
background: #fee2e2; color: #991b1b;
}
.permission-decided { font-size: 11px; }
.user-row-reason td {
background: #fffbeb; border-top: none !important;
padding: 0 16px 12px !important;
}
.user-reason-block {
border-left: 3px solid #f59e0b;
padding: 8px 12px; font-size: 13px;
background: #fffbeb;
}
.user-reason-block strong { display: block; margin-bottom: 4px; color: #92400e; }
.user-reason-block p { margin: 0; white-space: pre-wrap; color: #374151; }
.grad-queue { list-style: none; padding: 0; margin: 8px 0 24px; }
.grad-queue li { padding: 8px 0; border-bottom: 1px solid #f3f4f6; }
.grad-queue-link { color: #111; text-decoration: none; font-size: 14px; }
+13
View File
@@ -11,6 +11,7 @@ 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'
@@ -111,6 +112,9 @@ 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
@@ -153,6 +157,7 @@ export default function App() {
<Route path="/login" element={<Login />} />
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
<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>} />
@@ -221,6 +226,14 @@ function PhilosophyWithSidebar({ viewer }) {
)
}
function DocsWithSidebar({ viewer }) {
return (
<main className="chrome-pane">
<Docs authenticated={!!viewer} />
</main>
)
}
function NotificationSettingsWithSidebar({ viewer }) {
return (
<main className="chrome-pane">
+106 -4
View File
@@ -31,20 +31,33 @@ 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) {
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
const res = await fetch('/auth/otc/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
body: JSON.stringify(body),
})
return jsonOrThrow(res)
}
export async function verifyOtc(email, code) {
export async function verifyOtc(email, code, { trustDevice = false } = {}) {
// v0.11.0 — `trustDevice` is the "trust this device for 30 days"
// checkbox on the Login.jsx OTC step. When true, the server mints
// a fresh device-trust row and sets the long-lived cookie; on
// subsequent visits, the cookie skips the OTC roundtrip via
// `startDeviceTrust()`.
const res = await fetch('/auth/otc/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, code }),
body: JSON.stringify({ email, code, trust_device: !!trustDevice }),
})
return jsonOrThrow(res)
}
@@ -65,6 +78,78 @@ export async function submitBetaRequest({ first_name, last_name, beta_request_re
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, { trustDevice = false } = {}) {
// v0.11.0 — same trust-device opt-in as `verifyOtc`.
const res = await fetch('/auth/passcode/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, passcode, trust_device: !!trustDevice }),
})
return jsonOrThrow(res)
}
// ── v0.11.0: trust device for 30 days (§6.2, roadmap item #9) ─────────────
//
// On a returning visit with a valid device-trust cookie, `startDeviceTrust`
// re-establishes the session without an OTC / passcode roundtrip. The
// cookie is HttpOnly so the client cannot read it; the call is a pure POST
// that the browser attaches the cookie to automatically.
//
// `listMyDevices`, `revokeMyDevice`, and `revokeAllMyDevices` drive the
// /settings/devices revoke-device UI. The signed-in user is the implicit
// subject; the cookie carries the session.
export async function startDeviceTrust() {
const res = await fetch('/auth/device-trust/start', { method: 'POST' })
return jsonOrThrow(res)
}
export async function listMyDevices() {
return jsonOrThrow(await fetch('/api/auth/me/devices'))
}
export async function revokeMyDevice(deviceId) {
return jsonOrThrow(await fetch(`/api/auth/me/devices/${deviceId}`, { method: 'DELETE' }))
}
export async function revokeAllMyDevices() {
return jsonOrThrow(await fetch('/api/auth/me/devices', { method: 'DELETE' }))
}
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'))
}
@@ -589,6 +674,10 @@ 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).
@@ -614,6 +703,19 @@ export async function setUserMute(userId, muted) {
}))
}
// v0.9.0 — roadmap item #7. Flip a user's permission_state between
// 'pending', 'granted', and 'revoked'. The Users tab on the admin
// page wires Grant / Revoke buttons against this endpoint; the
// returned `changed` flag is false when the requested state already
// matched the row.
export async function setUserPermission(userId, state) {
return jsonOrThrow(await fetch(`/api/admin/users/${userId}/permission`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state }),
}))
}
export async function listAuditLog({ actionKind, actorUserId, rfcSlug, beforeId, limit } = {}) {
const params = new URLSearchParams()
if (actionKind) params.set('action_kind', actionKind)
+192 -52
View File
@@ -16,6 +16,7 @@ import {
listAdminUsers,
setUserRole,
setUserMute,
setUserPermission,
listAuditLog,
listPermissionEvents,
listGraduationQueue,
@@ -68,12 +69,26 @@ export default function Admin({ viewer }) {
)
}
// Users + role + write-mute (§6.1 / §6.2)
// Users + role + write-mute + permission grant/revoke (§6.1 / §6.2)
//
// v0.9.0 (roadmap item #7) lands the user-management surface. The table
// shows every user with their permission_state, sign-up reason (when
// pending), role, write-mute, and Grant / Revoke controls. State filter
// chips above the table narrow to one bucket the "Pending" chip is the
// admin's daily inbox shape.
const STATE_CHIPS = [
{ value: 'all', label: 'All' },
{ value: 'pending', label: 'Pending' },
{ value: 'granted', label: 'Granted' },
{ value: 'revoked', label: 'Revoked' },
]
function UsersTab() {
const [users, setUsers] = useState(null)
const [busy, setBusy] = useState({})
const [error, setError] = useState(null)
const [stateFilter, setStateFilter] = useState('all')
async function refresh() {
setError(null)
@@ -113,68 +128,193 @@ function UsersTab() {
}
}
async function flipPermission(userId, state) {
setBusy(b => ({ ...b, [userId]: true }))
setError(null)
try {
await setUserPermission(userId, state)
// Refresh the full row so permission_decided_{at,by_*} update too.
await refresh()
} catch (e) {
setError(e.message)
} finally {
setBusy(b => ({ ...b, [userId]: false }))
}
}
const counts = useMemo(() => {
const c = { all: 0, pending: 0, granted: 0, revoked: 0 }
if (users) {
c.all = users.length
for (const u of users) {
const s = u.permission_state || 'granted'
if (s in c) c[s] += 1
}
}
return c
}, [users])
if (users == null) return <p className="muted">Loading users</p>
const filtered = stateFilter === 'all'
? users
: users.filter(u => (u.permission_state || 'granted') === stateFilter)
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Users</h2>
<p className="muted">
Role changes write to <code>permission_events</code>. The §6.2
write-mute applies to contributors only promote to admin to
remove a user's ability to write without silencing them.
The pending bucket is the beta-access review queue (§6.1 /
v0.8.0). Grant or revoke writes to <code>permission_events</code>
and stamps <code>permission_decided_by</code> +{' '}
<code>permission_decided_at</code>. Role and write-mute controls
retain their v0.7.0 semantics promote to admin to remove a
user's ability to write without silencing them.
</p>
</header>
{error && <p className="settings-note warning">{error}</p>}
<table className="admin-table">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Write-muted</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td>
<div className="user-cell">
<span className="user-handle">@{u.gitea_login}</span>
<span className="muted">{u.display_name}</span>
</div>
</td>
<td>
<select
value={u.role}
onChange={e => changeRole(u.id, e.target.value)}
disabled={!!busy[u.id]}
>
<option value="contributor">Contributor</option>
<option value="admin">Admin</option>
<option value="owner">Owner</option>
</select>
</td>
<td>
{u.role === 'contributor' ? (
<label className="mute-toggle">
<input
type="checkbox"
checked={!!u.muted}
onChange={e => toggleMute(u.id, e.target.checked)}
disabled={!!busy[u.id]}
/>
{u.muted ? 'Muted' : 'Active'}
</label>
) : (
<span className="muted">N/A</span>
)}
</td>
<td className="muted">{u.last_seen_at}</td>
<div className="admin-filter-chips">
{STATE_CHIPS.map(chip => (
<button
key={chip.value}
type="button"
className={`admin-chip${stateFilter === chip.value ? ' active' : ''}`}
onClick={() => setStateFilter(chip.value)}
>
{chip.label} <span className="admin-chip-count">{counts[chip.value] ?? 0}</span>
</button>
))}
</div>
{filtered.length === 0 ? (
<p className="muted">No users in this bucket.</p>
) : (
<table className="admin-table admin-users-table">
<thead>
<tr>
<th>User</th>
<th>State</th>
<th>Role</th>
<th>Write-muted</th>
<th>Signed up</th>
<th>Last seen</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{filtered.map(u => (
<UserRow
key={u.id}
user={u}
busy={!!busy[u.id]}
onChangeRole={role => changeRole(u.id, role)}
onToggleMute={muted => toggleMute(u.id, muted)}
onFlipPermission={state => flipPermission(u.id, state)}
/>
))}
</tbody>
</table>
)}
</div>
)
}
function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }) {
const state = u.permission_state || 'granted'
const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
const handle = u.gitea_login ? `@${u.gitea_login}` : (u.email || u.display_name)
return (
<>
<tr>
<td>
<div className="user-cell">
<span className="user-handle">{handle}</span>
<span className="muted">
{fullName || u.display_name}
{u.email ? ` · ${u.email}` : ''}
</span>
</div>
</td>
<td>
<PermissionCell user={u} busy={busy} onFlipPermission={onFlipPermission} />
</td>
<td>
<select
value={u.role}
onChange={e => onChangeRole(e.target.value)}
disabled={busy}
>
<option value="contributor">Contributor</option>
<option value="admin">Admin</option>
<option value="owner">Owner</option>
</select>
</td>
<td>
{u.role === 'contributor' ? (
<label className="mute-toggle">
<input
type="checkbox"
checked={!!u.muted}
onChange={e => onToggleMute(e.target.checked)}
disabled={busy}
/>
{u.muted ? 'Muted' : 'Active'}
</label>
) : (
<span className="muted">N/A</span>
)}
</td>
<td className="muted">{u.created_at || '—'}</td>
<td className="muted">{u.last_seen_at || '—'}</td>
</tr>
{state === 'pending' && u.beta_request_reason ? (
<tr className="user-row-reason">
<td colSpan={6}>
<div className="user-reason-block">
<strong>Why they want access:</strong>
<p>{u.beta_request_reason}</p>
</div>
</td>
</tr>
) : null}
</>
)
}
function PermissionCell({ user: u, busy, onFlipPermission }) {
const state = u.permission_state || 'granted'
const decidedSuffix = u.permission_decided_at
? ` · by ${u.permission_decided_by_login ? '@' + u.permission_decided_by_login : '—'} at ${u.permission_decided_at}`
: ''
return (
<div className="permission-cell">
<span className={`permission-badge permission-badge-${state}`}>{state}</span>
<div className="permission-actions">
{state !== 'granted' && (
<button
type="button"
className="btn-link-quiet"
disabled={busy}
onClick={() => onFlipPermission('granted')}
>Grant</button>
)}
{state === 'granted' && (
<button
type="button"
className="btn-link-quiet"
disabled={busy}
onClick={() => {
if (confirm(`Revoke access for ${u.display_name || u.email}?`)) {
onFlipPermission('revoked')
}
}}
>Revoke</button>
)}
</div>
{decidedSuffix && (
<div className="permission-decided muted">{decidedSuffix.replace(/^ · /, '')}</div>
)}
</div>
)
}
+5 -2
View File
@@ -27,8 +27,11 @@ export default function BetaPending({ viewer }) {
{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.
Thanks for telling us a bit about yourself. The deployment's
admins are notified by email as soon as a request lands;
we don't commit to a fixed SLA turnaround depends on
operator availability and the deployment operator is
the right person to ask if a wait runs long.
</p>
<p>
While you wait, the catalog on the left lists every super-draft
+51
View File
@@ -0,0 +1,51 @@
// `/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>
)
}
+474 -55
View File
@@ -1,59 +1,163 @@
// Login.jsx v0.7.0's primary sign-in surface (§6.2), extended by
// v0.8.0 (§6.1 / §14.1, roadmap item #6) with the first-OTC profile
// capture step.
// 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.
//
// Three-step (the third is conditional):
// 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, the response body carries `needs_profile`:
// * needs_profile=false (returning user, OAuth-era grandfather,
// or already-captured pending user): redirect to "/".
// * needs_profile=true (fresh OTC sign-in, no profile fields
// yet): advance to step 3.
// Cmd/Ctrl+Enter on the code field is the keyboard shortcut.
// 3. 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.
// 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.
//
// Server-side, /auth/otc/request returns 202 uniformly so abuse paths
// (e.g. distributed allowlist-probing) don't leak the recognized-email
// set. This surface never distinguishes "we couldn't reach you" from
// "we don't know you" it just advances to step 2. If a request was
// rate-limited, the user sees a 429 hint and stays on step 1.
// Four-to-six-step flow (most users see three; the longest path is
// pending-user with no passcode, who never sees the passcode steps):
//
// The legacy Gitea OAuth callback remains at /auth/login /auth/callback
// during the 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.
// 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.
import { useEffect, useRef, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { requestOtc, verifyOtc, submitBetaRequest } from '../api'
import {
requestOtc,
verifyOtc,
submitBetaRequest,
checkPasscode,
verifyPasscode,
setPasscode as apiSetPasscode,
startDeviceTrust,
} from '../api'
import TurnstileWidget, { turnstileEnabled } from './TurnstileWidget'
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('')
// v0.8.0 step 3 capture fields.
const [passcode, setPasscode] = useState('')
const [newPasscode, setNewPasscode] = useState('')
// v0.11.0 "trust this device for 30 days" checkbox, shared by the
// OTC and passcode verify steps. The flag rides on the verify POST;
// a checked box mints a device-trust row server-side and sets the
// long-lived `rfc_device_trust` cookie. Defaults off so the user
// makes an explicit choice auth credentials shouldn't persist by
// default.
const [trustDevice, setTrustDevice] = useState(false)
// 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 === 'profile') firstNameRef.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()
}, [step])
// v0.11.0 on mount, try the device-trust cookie path. If the
// browser still carries a valid `rfc_device_trust` cookie from a
// prior "trust this device" gesture, the server re-establishes the
// session without any user input and we redirect home. The cookie
// is HttpOnly so we can't peek at it; we just call the endpoint and
// see whether it returns 200. 401 (no cookie / invalid / revoked)
// is the structural-silent case the user proceeds to the email
// step normally. We do not surface any UI about the attempt; a
// failure should be invisible.
useEffect(() => {
let cancelled = false
;(async () => {
try {
await startDeviceTrust()
if (!cancelled) window.location.assign('/')
} catch (_) {
// No trusted device fall through to the email step.
}
})()
return () => { cancelled = true }
}, [])
async function submitEmail(e) {
e.preventDefault()
if (!email.trim() || !email.includes('@')) {
@@ -63,20 +167,93 @@ export default function Login() {
setBusy(true)
setStatus('')
try {
await requestOtc(email.trim())
setStep('code')
setStatus('Check your inbox — a six-digit code is on the way.')
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.')
}
} 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 request a code. Try again.')
setStatus(err.message || 'Could not start sign-in. 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(), { trustDevice })
// 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) {
@@ -86,22 +263,39 @@ export default function Login() {
setBusy(true)
setStatus('')
try {
const result = await verifyOtc(email.trim(), code.trim())
// v0.8.0 a fresh OTC user lands in `permission_state='pending'`
// with no profile fields. The verify response now carries a
// `needs_profile` flag the server stamped from the row state;
// surface the capture form here instead of jumping straight to
// "/". The fallback path (no flag, e.g. an older backend
// before the migration ran) jumps to "/" as before.
if (result?.needs_profile) {
setStep('profile')
await verifyOtc(email.trim(), code.trim(), { trustDevice })
// 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
}
// 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.
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.
window.location.assign('/')
} catch (err) {
setStatus('That code is invalid or expired. Try again, or request a new code.')
@@ -126,10 +320,10 @@ export default function Login() {
last_name: ln,
beta_request_reason: why,
})
// The user is still `permission_state='pending'`; bounce them
// to /beta-pending so the next thing they see is the
// "your request is in review" page. Hard-load so App.jsx
// re-fetches /api/auth/me and picks up the captured fields.
// 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.')
@@ -137,6 +331,26 @@ export default function Login() {
}
}
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') {
@@ -144,12 +358,69 @@ 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">
@@ -157,7 +428,8 @@ export default function Login() {
{step === 'email' && (
<form onSubmit={submitEmail}>
<p className="otc-hint">
Enter your email. We'll send you a one-time code.
Enter your email. If you've set a passcode, you'll enter that
next; otherwise we'll send a one-time code.
</p>
<input
ref={emailRef}
@@ -169,11 +441,85 @@ export default function Login() {
required
disabled={busy}
/>
<button type="submit" disabled={busy || !email.trim()}>
{busy ? 'Sending…' : 'Send code'}
{/*
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>
</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.11.0 trust device for 30 days. The checkbox lives
on the verify step so the user makes the trust gesture
in the same breath as signing in. Off by default; the
user opts in deliberately. */}
<label className="otc-trust-device">
<input
type="checkbox"
checked={trustDevice}
onChange={e => setTrustDevice(e.target.checked)}
disabled={busy}
/>
<span>Trust this device for 30 days</span>
</label>
{/*
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">
@@ -193,6 +539,17 @@ export default function Login() {
required
disabled={busy}
/>
{/* v0.11.0 trust device for 30 days. Same shape as the
passcode step; the user opts in deliberately. */}
<label className="otc-trust-device">
<input
type="checkbox"
checked={trustDevice}
onChange={e => setTrustDevice(e.target.checked)}
disabled={busy}
/>
<span>Trust this device for 30 days</span>
</label>
<div className="otc-actions">
<button type="submit" disabled={busy || code.length !== 6}>
{busy ? 'Signing in…' : 'Sign in'}
@@ -211,7 +568,7 @@ export default function Login() {
</p>
</form>
)}
{step === 'profile' && (
{step === 'capture-profile' && (
<form onSubmit={submitProfile}>
<p className="otc-hint">
You're signed in. {import.meta.env.VITE_APP_NAME} is in private
@@ -245,6 +602,7 @@ export default function Login() {
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
onKeyDown={onReasonKey}
required
disabled={busy}
rows={5}
@@ -259,10 +617,71 @@ export default function Login() {
{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 !== 'profile' && (
{step !== 'capture-profile' && (
<p className="otc-fallback">
<Link to="/philosophy">Read the philosophy </Link>
<span className="otc-fallback-sep">·</span>
@@ -29,6 +29,12 @@ import {
muteUser,
searchUsers,
getCookieConsent,
getMe,
setPasscode,
clearPasscode,
listMyDevices,
revokeMyDevice,
revokeAllMyDevices,
} from '../api.js'
import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js'
@@ -50,11 +56,282 @@ export default function NotificationSettings({ viewer }) {
<QuietHoursSection />
<WatchesSection />
<MutesSection viewer={viewer} />
<SignInSection />
<DevicesSection />
<PrivacyCookiesSection />
</div>
)
}
// v0.11.0: trusted devices (§6.2, roadmap item #9)
//
// Lists the user's active device-trust rows and lets them revoke any
// or all. A revoke marks the row dead server-side; the matching
// device's next visit will be refused and the cookie cleared. The
// surface intentionally does not single out the row whose cookie the
// current request carries every row reads identically, so the user
// can revoke "this device" alongside any other from a single page.
function DevicesSection() {
const [devices, setDevices] = useState(null)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
refresh()
}, [])
async function refresh() {
try {
const { items } = await listMyDevices()
setDevices(items || [])
setError(null)
} catch (e) {
setError(e.message || 'Could not load trusted devices.')
}
}
async function onRevoke(deviceId) {
setBusy(true)
try {
await revokeMyDevice(deviceId)
await refresh()
} catch (e) {
setError(e.message || 'Could not revoke device.')
} finally {
setBusy(false)
}
}
async function onRevokeAll() {
if (!confirm('Revoke trust on every device, including this one? You will be asked to sign in via email next time.')) {
return
}
setBusy(true)
try {
await revokeAllMyDevices()
await refresh()
} catch (e) {
setError(e.message || 'Could not revoke devices.')
} finally {
setBusy(false)
}
}
return (
<SectionShell
title="Trusted devices"
subtitle="Devices where you've checked “Trust this device for 30 days.” Sign-in is automatic on these devices until the trust expires or you revoke it."
>
{devices === null && <p className="settings-note">Loading</p>}
{devices !== null && devices.length === 0 && (
<p className="device-empty">
No trusted devices. Sign in and check Trust this device for 30 days
to add the device you're on now.
</p>
)}
{devices !== null && devices.length > 0 && (
<>
<ul className="device-list">
{devices.map(d => (
<li key={d.id} className="device-list-item">
<div className="device-meta">
<div className="device-ua">{d.user_agent || 'Unknown device'}</div>
<div className="device-stamps">
Trusted {formatStamp(d.created_at)} · last seen {formatStamp(d.last_seen_at)} · expires {formatStamp(d.expires_at)}
</div>
</div>
<button
type="button"
onClick={() => onRevoke(d.id)}
disabled={busy}
title="Revoke trust on this device"
>
Revoke
</button>
</li>
))}
</ul>
<button
type="button"
className="device-revoke-all"
onClick={onRevokeAll}
disabled={busy}
>
Revoke all devices
</button>
</>
)}
{error && <p className="settings-note warning">{error}</p>}
</SectionShell>
)
}
function formatStamp(stamp) {
// The server emits SQLite `datetime('now')` strings (UTC, no
// timezone marker). Parse defensively; fall back to the raw stamp
// if Date can't make sense of it.
if (!stamp) return '—'
const d = new Date(stamp.replace(' ', 'T') + 'Z')
if (Number.isNaN(d.getTime())) return stamp
return d.toLocaleString()
}
// §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() {
+120
View File
@@ -0,0 +1,120 @@
// 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" />
}