Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2590c244d6 |
+4
-744
@@ -23,650 +23,6 @@ skip versions are the composition of each intervening adjacent
|
||||
release's steps in order — no A-to-B path is pre-computed beyond
|
||||
that.
|
||||
|
||||
## 0.14.0 — 2026-05-28
|
||||
|
||||
**Minor — no operator action required; new optional env var.** This
|
||||
release ships `DOCS.md` and the `/docs` route — a public-facing user
|
||||
guide that translates `SPEC.md` into plain prose for readers,
|
||||
proposers, and contributors. The originating need was the
|
||||
admin-vs-owner distinction on the `/admin/users` surface (the §6.1
|
||||
role separation was load-bearing but only documented in spec voice);
|
||||
the response was a single guide that covers the framework's user-
|
||||
facing surfaces end-to-end. Mirrors `/philosophy` end-to-end: a
|
||||
markdown file checked into the repo root, served by a sibling backend
|
||||
loader, rendered with `MarkdownPreview`. No schema migration. No
|
||||
required env-var changes. The new "Docs" header link sits alongside
|
||||
the persistent "About" link from §14.3 and is reachable by anonymous
|
||||
viewers per the same v0.3.0 anonymous-read contract.
|
||||
|
||||
### Added
|
||||
|
||||
- **`DOCS.md`** at the repo root — the user-facing guide. Covers
|
||||
reading anonymously, signing in, proposing an RFC, super-drafts vs
|
||||
active RFCs, the discussion-vs-contribution distinction (§10.10),
|
||||
working on a branch (contribute mode, AI proposals, manual edits,
|
||||
flags, branch visibility, contribute grants, hygiene), opening and
|
||||
reviewing PRs, graduation (§13), withdrawal and reopening, the AI
|
||||
participant (§6.6 / §6.7 / §18), notifications and watch states
|
||||
(§15), and the full roles-and-permissions story (§6 in plain
|
||||
prose: anonymous / contributor / admin / owner, per-RFC
|
||||
owners + arbiters, per-branch contribute grants, the write-mute,
|
||||
and the three structurally distinct "mutes"). Framework-neutral —
|
||||
no deployment-specific names or corpus references; consistent with
|
||||
`CLAUDE.md`'s separation-of-concerns rule.
|
||||
- **`backend/app/docs.py`** — sibling loader for `philosophy.py`.
|
||||
Reads `DOCS.md` from the repo root with the same disk-first,
|
||||
in-process-cached, `refresh()`-on-demand shape. Optional
|
||||
`DOCS_PATH` env var points at an alternative source (e.g. a
|
||||
meta-repo working-tree clone) for deployments that prefer that.
|
||||
- **`§17` endpoint** — `GET /api/docs` returns
|
||||
`{ "body": "<DOCS.md verbatim>" }`. Anonymous-reachable, same
|
||||
contract as `GET /api/philosophy`.
|
||||
- **`frontend/src/components/Docs.jsx`** — the `/docs` reading
|
||||
surface. Mirrors `Philosophy.jsx`: chrome with Back / "USER GUIDE" /
|
||||
Home affordances, body rendered through `MarkdownPreview`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`backend/app/api.py`** — imports `docs as docs_mod` alongside
|
||||
`philosophy` in the relative-import block; registers the new
|
||||
`GET /api/docs` handler immediately after `GET /api/philosophy`.
|
||||
- **`frontend/src/api.js`** — exports `getDocs()` alongside
|
||||
`getPhilosophy()`. Same fetch shape, different endpoint path.
|
||||
- **`frontend/src/App.jsx`** — imports `Docs` alongside `Philosophy`,
|
||||
registers the `/docs` route alongside `/philosophy`, adds the
|
||||
persistent "Docs" header link alongside "About", and adds the
|
||||
`DocsWithSidebar` chrome wrapper alongside `PhilosophyWithSidebar`.
|
||||
|
||||
### Upgrade steps (from 0.13.0)
|
||||
|
||||
- You **MUST** rebuild the frontend and restart the backend after
|
||||
upgrading so the new `/docs` route, the new endpoint, and the new
|
||||
loader are picked up. `frontend/package.json#version` and `VERSION`
|
||||
both move to `0.14.0`. No schema migration; the new endpoint
|
||||
serves a checked-in file.
|
||||
- You **MAY** set `DOCS_PATH` to an absolute path if your deployment
|
||||
hosts `DOCS.md` outside the framework's repo (e.g. as a sync target
|
||||
from a content repo). Unset is supported — the framework's
|
||||
`DOCS.md` at the repo root is the default, mirroring how
|
||||
`PHILOSOPHY_PATH` works for `/api/philosophy`.
|
||||
- You **MAY** customize `DOCS.md` for your deployment if you want
|
||||
deployment-specific phrasing layered on top of the framework's
|
||||
guide. The file is a regular markdown source; standard `vim`/`git`
|
||||
edits suffice. Framework upgrades that ship a new `DOCS.md` will
|
||||
show as a normal merge in your deployment-overlay layer.
|
||||
|
||||
## 0.13.0 — 2026-05-28
|
||||
|
||||
**Minor — schema migration required; new optional env vars.** This
|
||||
release ships the cookie / privacy consent surface (roadmap item #11,
|
||||
SPEC §14.5 / §14.6). Every viewer — authenticated and anonymous alike —
|
||||
now sees a non-modal bottom-of-page banner on first visit asking which
|
||||
categories of cookies they allow (essential / essential + analytics /
|
||||
essential + analytics + other). The choice persists in `localStorage`
|
||||
for anonymous viewers and in a new `cookie_consent` table for
|
||||
authenticated viewers, with server-side overriding local on sign-in.
|
||||
The framework also ships default `/privacy` and `/cookies` policy pages
|
||||
that deployments can layer their own policy URL on top of via two new
|
||||
optional env vars. No analytics SDK ships in this release — the
|
||||
consent infrastructure is wired so item #13 (v0.15.0) can read from
|
||||
`frontend/src/lib/consent.js` when the SDK lands.
|
||||
|
||||
### Added
|
||||
|
||||
- **Cookie consent banner** (`frontend/src/components/CookieConsentBanner.jsx`).
|
||||
Non-modal, bottom of viewport. Three single-select choices with
|
||||
inline descriptions. Visible until the user makes a choice; hides
|
||||
thereafter. Reachable for revision via the settings surface.
|
||||
- **Consent helper** (`frontend/src/lib/consent.js`). Exports
|
||||
`getConsent()`, `hasChosen()`, `onConsentChange(cb)`, `setConsent()`,
|
||||
`hydrateFromServer()`, `clearLocal()`. Cross-tab sync via the
|
||||
`storage` event. Item #13's analytics SDK reads consent here before
|
||||
importing.
|
||||
- **Privacy and cookies policy pages**
|
||||
(`frontend/src/pages/Privacy.jsx`, `frontend/src/pages/Cookies.jsx`).
|
||||
Default minimal policies that describe the framework's stance and
|
||||
list the cookies the framework sets. Deployments override via the
|
||||
two new env vars below; the framework's stub always renders above
|
||||
the link so the framework-level contract stays visible.
|
||||
- **"Privacy & cookies" tab** in `/settings/notifications` showing
|
||||
the current consent choice, the recorded-at stamp, and a "Change"
|
||||
button that re-opens the banner via a custom DOM event.
|
||||
- **`§17` endpoints** —
|
||||
- `GET /api/users/me/cookie-consent` — read the current consent
|
||||
record.
|
||||
- `PUT /api/users/me/cookie-consent` — write a new consent record.
|
||||
Upserts a single row per user, stamps `recorded_at` to now,
|
||||
accepts `essential` for symmetry but always persists it as true.
|
||||
- **Schema migration** `013_cookie_consent.sql` — new
|
||||
`cookie_consent` table keyed by `user_id`, three flags
|
||||
(`essential`, `analytics`, `other_cookies`), and `recorded_at`.
|
||||
(Renumbered from `012_*` during driver integration because v0.7.0
|
||||
also added a `012_otc.sql` migration that landed in the integration
|
||||
order before this one.)
|
||||
- **SPEC `§14.5` Cookie / privacy consent** — settles the banner
|
||||
shape, the three-category single-select, the storage shape (local
|
||||
for anon, server row for authenticated), the precedence rule on
|
||||
sign-in, and the `consent.js` helper surface for downstream
|
||||
callers including item #13.
|
||||
- **SPEC `§14.6` Privacy and cookies policy pages** — settles the
|
||||
`/privacy` and `/cookies` routes, the framework's stub content, and
|
||||
the `VITE_PRIVACY_POLICY_URL` / `VITE_COOKIES_POLICY_URL` override
|
||||
shape.
|
||||
- **SPEC `§5`** — names the `cookie_consent` table in the canonical
|
||||
app-tables list.
|
||||
- **SPEC `§17`** — lists the two new cookie-consent endpoints.
|
||||
- **SPEC `§19.2`** — surfaces four candidates: policy content via
|
||||
content-repo file vs env var, GPC / DNT headers, multi-language
|
||||
consent text, and the item #13 analytics-SDK gating dependency.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`frontend/.env.example`** — documents the two new optional env
|
||||
vars `VITE_PRIVACY_POLICY_URL` and `VITE_COOKIES_POLICY_URL`. Unset
|
||||
is supported; defaults render the framework's stub.
|
||||
- **`backend/app/api_notifications.py`** — module docstring grew two
|
||||
endpoint lines; the new endpoints sit alongside the existing
|
||||
`/api/users/me/*` neighbors.
|
||||
- **`frontend/src/App.jsx`** — registers `/privacy` and `/cookies`
|
||||
routes (anonymous-reachable), wires `<CookieConsentBanner>` into
|
||||
the global chrome, and listens for a `rfc-app:cookie-consent-reopen`
|
||||
custom event to re-open the banner from the settings surface.
|
||||
|
||||
### Upgrade steps (from 0.7.0)
|
||||
|
||||
- You **MUST** rebuild the frontend and restart the backend after
|
||||
upgrading. `frontend/package.json#version` and `VERSION` both move
|
||||
to `0.13.0` and the build embeds the new env-var contract.
|
||||
- You **MUST** apply schema migration `013_cookie_consent.sql`. The
|
||||
migration creates a single new table keyed by `user_id` with three
|
||||
flag columns and a `recorded_at` stamp. The framework runs
|
||||
migrations automatically at process start; no manual step is
|
||||
required beyond restarting the backend so the migration runner
|
||||
picks the file up.
|
||||
- You **MAY** set `VITE_PRIVACY_POLICY_URL` to an http(s) URL that
|
||||
points at your deployment's full privacy policy. The framework's
|
||||
`/privacy` page renders its built-in stub above a link to the
|
||||
configured URL. Unset is supported — the stub is sufficient for a
|
||||
default-config deployment.
|
||||
- You **MAY** set `VITE_COOKIES_POLICY_URL` to an http(s) URL that
|
||||
points at your deployment's full cookies policy. Same shape as the
|
||||
privacy URL.
|
||||
- You **MAY** announce the new consent banner to your users. Existing
|
||||
authenticated users will see the banner on their next visit
|
||||
(because their `cookie_consent` row does not yet exist); their
|
||||
current sessions remain valid.
|
||||
|
||||
## 0.12.0 — 2026-05-28
|
||||
|
||||
**Minor — operator action required (new secret + new overlay).**
|
||||
CloudFlare Turnstile gates the email-entry step of the OTC sign-in
|
||||
flow against automated abuse (roadmap item #10, SPEC §6.2 / §19.2-
|
||||
settled). Since v0.7.0 made `/auth/otc/request` the primary human-
|
||||
auth path and v0.8.0 opened the request endpoint to any valid email,
|
||||
the OTC dispatch became the natural target for distributed scrapers
|
||||
fanning out to harvest "this email is admitted vs. this email is
|
||||
not" timing/bounce signals. The per-email cooldown stops the trivial
|
||||
back-to-back loop; the Turnstile challenge stops the distributed one
|
||||
by costing the attacker a browser-side proof-of-humanness on every
|
||||
request. The challenge runs before the bcrypt hash + SMTP send so a
|
||||
failed verify spends no rate budget and produces no envelope.
|
||||
|
||||
Scope: the widget renders on the email-entry step of `/login` only.
|
||||
The OTC verify step (where the user pastes the six-digit code) is
|
||||
already bottlenecked on email delivery and protected by the
|
||||
five-minute TTL + single-use consume on the row; a second challenge
|
||||
there would double the rate budget against the same abuse path
|
||||
without measurably more protection. If bots adapt to defeat the
|
||||
email-entry challenge specifically — pushing the abuse vector onto
|
||||
the verify step — a future release adds the second widget. The
|
||||
widget also renders on the passcode step's "Use a code instead"
|
||||
fallback dispatch since that route also calls `/auth/otc/request`.
|
||||
|
||||
Default policy: `TURNSTILE_REQUIRED=false`. The gate stays open when
|
||||
the secret is absent — the dev / test path, and the pre-rollout
|
||||
path while the operator is wiring the secret. Once the secret is in
|
||||
GCP Secret Manager and the site key is in the overlay, the operator
|
||||
**MAY** flip `TURNSTILE_REQUIRED=true` so a future config drift on
|
||||
the secret fails loudly (HTTP 500 "auth misconfigured") instead of
|
||||
silently disabling abuse defense.
|
||||
|
||||
No schema migration — Turnstile siteverify is stateless.
|
||||
|
||||
### Added
|
||||
|
||||
- **`backend/app/turnstile.py`** — the siteverify caller. POSTs
|
||||
`secret` + `response` (+ optional `remoteip`) to
|
||||
`https://challenges.cloudflare.com/turnstile/v0/siteverify` and
|
||||
returns a `VerifyOutcome` (`ok` boolean + `reason` enum:
|
||||
`ok` / `skipped` / `misconfigured` / `missing-token` / `failed` /
|
||||
`network`). Tunables read from env at call time so tests
|
||||
monkeypatch cleanly: `CLOUDFLARE_TURNSTILE_SECRET`,
|
||||
`TURNSTILE_REQUIRED`, and (test-only) `TURNSTILE_SITEVERIFY_URL`.
|
||||
- **`frontend/src/components/TurnstileWidget.jsx`** — the React
|
||||
wrapper around the official CloudFlare Turnstile JS API. Reads the
|
||||
site key from `import.meta.env.VITE_TURNSTILE_SITE_KEY`; renders
|
||||
nothing when the var is unset (the form still submits and the
|
||||
backend's `TURNSTILE_REQUIRED` policy decides admission). Loads
|
||||
the CloudFlare script once per page on first widget mount. Cleans
|
||||
up the widget instance on unmount via `turnstile.remove()` so a
|
||||
remount produces a fresh challenge rather than reusing a stale,
|
||||
already-consumed token.
|
||||
- **Backend tests** (`backend/tests/test_turnstile_vertical.py`) —
|
||||
five vertical scenarios: happy path (secret + valid token →
|
||||
admit), siteverify rejects → 400 + no envelope, missing-token →
|
||||
400 + no envelope, missing-secret-soft (default) → admit, and
|
||||
missing-secret-hard (`TURNSTILE_REQUIRED=true`) → 500
|
||||
"misconfigured". All five mock the siteverify HTTP call via
|
||||
`monkeypatch.setattr(turnstile.httpx, "post", …)`; no real
|
||||
CloudFlare keys are ever embedded.
|
||||
- **SPEC `§6.2`** — names the Turnstile gate on the OTC dispatch as
|
||||
the v0.12.0 settled shape; the §19.2 candidate from v0.7.0 closes.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`backend/app/main.py`** — `OtcRequestBody` grows an optional
|
||||
`turnstile_token` field. The `/auth/otc/request` handler calls
|
||||
`turnstile.verify_token` first, before `otc.request_code`, so a
|
||||
failed challenge spends no rate budget and produces no envelope.
|
||||
The handler maps `misconfigured` → HTTP 500, all other failures
|
||||
(`missing-token`, `failed`, `network`) → uniform HTTP 400 so the
|
||||
response does not enumerate which leg of the challenge broke.
|
||||
- **`frontend/src/api.js`** — `requestOtc` accepts a second arg
|
||||
`{ turnstileToken }` and threads it into the request body. The
|
||||
positional signature stays backwards-compatible so calls that pass
|
||||
only an email still type-check.
|
||||
- **`frontend/src/components/Login.jsx`** — the email step and the
|
||||
passcode step both render `<TurnstileWidget>`. The submit button
|
||||
on the email step is disabled until the widget produces a token
|
||||
(when the widget is enabled at build time); the "Use a code
|
||||
instead" link on the passcode step has the same gate. A 400 from
|
||||
`/auth/otc/request` clears the token and surfaces a "couldn't
|
||||
verify you're human, please retry" status. The fallback-from-
|
||||
passcode path bounces back to the email step on 400 so the user
|
||||
gets a fresh challenge in the natural place.
|
||||
- **`backend/.env.example`** — documents `CLOUDFLARE_TURNSTILE_SECRET`
|
||||
and `TURNSTILE_REQUIRED` alongside the existing OTC tunables.
|
||||
- **`frontend/.env.example`** — documents `VITE_TURNSTILE_SITE_KEY`
|
||||
with the operator wire-up procedure (dash.cloudflare.com →
|
||||
Turnstile → Add site).
|
||||
|
||||
### Upgrade steps (from 0.10.0)
|
||||
|
||||
The operator **MUST** create a CloudFlare Turnstile site
|
||||
(dash.cloudflare.com → Turnstile → Add site, choose "Managed" widget
|
||||
mode), obtain the site key (public) and secret key (private), and:
|
||||
|
||||
- You **MUST** `flotilla secret set ohm-rfc-app CLOUDFLARE_TURNSTILE_SECRET`
|
||||
(paste the secret key when prompted) before the v0.12.0 deploy.
|
||||
The framework reads the secret at request time; deploying v0.12.0
|
||||
without the secret leaves the gate in its default soft-fail state
|
||||
(every request admitted regardless of token), which means abuse
|
||||
defense is silently off.
|
||||
- You **MUST** `flotilla overlay set ohm-rfc-app VITE_TURNSTILE_SITE_KEY <site-key>`
|
||||
so the frontend build embeds the site key and the widget renders
|
||||
on `/login`. The site key is public — it travels in the bundle and
|
||||
appears in every browser — so this is the overlay (non-secret)
|
||||
layer per the §3 invariant 1 split. Skipping this step leaves
|
||||
`/login` with no widget; even after the operator sets the secret,
|
||||
the backend would refuse every request as `missing-token` once
|
||||
`TURNSTILE_REQUIRED=true` flips.
|
||||
- You **MUST** rebuild the frontend and restart the backend after
|
||||
upgrading. `frontend/package.json#version` and `VERSION` both move
|
||||
to `0.12.0`. No schema migration; Turnstile siteverify is
|
||||
stateless. The site-key embed is build-time, so the rebuild after
|
||||
the `flotilla overlay set` is what actually wires the widget into
|
||||
the bundle the deploy serves.
|
||||
- You **MAY** `flotilla overlay set ohm-rfc-app TURNSTILE_REQUIRED true`
|
||||
once you've confirmed a real sign-in works end-to-end with the
|
||||
widget. The default (`false`) keeps the gate in soft-fail mode so
|
||||
a missing-secret regression admits requests rather than 500ing
|
||||
every sign-in attempt; flipping to `true` makes a future config
|
||||
drift on the secret fail loudly with HTTP 500 instead of silently
|
||||
disabling abuse defense. The framework's tested path is the
|
||||
flipped-to-true production shape; the default `false` exists for
|
||||
the dev / pre-rollout window only.
|
||||
- You **MAY** customize the Turnstile widget mode (Managed /
|
||||
Non-interactive / Invisible) from the dashboard at any time
|
||||
without redeploying — the site key stays the same, and the widget
|
||||
picks up the mode change on the next page load. The framework's
|
||||
tested path is "Managed" because it gives the operator a visible
|
||||
challenge surface to debug against.
|
||||
|
||||
If either of the two **MUST** secret/overlay steps is skipped, the
|
||||
deploy still boots and `/login` still serves; the failure mode is
|
||||
that abuse defense is off (default `TURNSTILE_REQUIRED=false`) or
|
||||
every sign-in attempt 500s (`TURNSTILE_REQUIRED=true` flipped while
|
||||
the secret is unset). The driver pauses the wave at the secret/
|
||||
overlay gesture so the operator confirms both are in place before
|
||||
the framework version pin moves.
|
||||
|
||||
## 0.10.0 — 2026-05-28
|
||||
|
||||
**Minor — schema migration required; new auth path is additive.**
|
||||
This release lands user-set passcodes after OTC (roadmap item #8,
|
||||
SPEC §6.2). After a successful one-time-code sign-in, the user can
|
||||
set a passcode (4–20 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 (4–20) 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 (4–20) are hard-coded in `backend/app/passcode.py`. See
|
||||
§19.2 for the env-tunable candidate.
|
||||
|
||||
## 0.8.0 — 2026-05-28
|
||||
|
||||
**Minor — schema migration required; admission semantics shift.**
|
||||
This release replaces the v0.3.0 / v0.7.0 `allowed_emails` admission
|
||||
gate with an admin-grant flow (roadmap item #6, SPEC §6.1 / §6.2 /
|
||||
§14.1 / §17). Anyone with a valid email can sign in via the v0.7.0
|
||||
OTC flow; the OTC request endpoint no longer consults the
|
||||
allowlist. A fresh user lands in `permission_state='pending'` until
|
||||
an admin grants access. The first-OTC sign-in captures first name,
|
||||
last name, and a free-text "why I should be included in the beta"
|
||||
via a new `POST /api/auth/me/beta-request` endpoint; the captured
|
||||
fields populate the same `users` row alongside the OAuth-era
|
||||
columns.
|
||||
|
||||
A pending user has the same read access an anonymous viewer has —
|
||||
the catalog, RFC bodies, the philosophy page, and every public
|
||||
conversation are reachable. Every write-shaped endpoint
|
||||
(`auth.require_contributor` floor) refuses pending users with 403.
|
||||
The frontend renders a thin "Your beta access request is in
|
||||
review" banner on every page and re-purposes the v0.3.0
|
||||
`/beta-pending` page as the post-capture landing surface.
|
||||
|
||||
Grandfathered behavior: every `users` row at migration time
|
||||
carries `permission_state='granted'` via the column default, so
|
||||
existing contributors are unaffected. The OAuth fallback at
|
||||
`/auth/callback` still consults the v0.3.0 allowlist (legacy
|
||||
path); the OTC flow does not.
|
||||
|
||||
The `allowed_emails` table stays in the schema as a fast-path
|
||||
bypass — the v0.3.0 admin UI continues to manage it, but the OTC
|
||||
request handler no longer reads it. v0.9.0 (roadmap item #7) is
|
||||
expected to ship the admin user-management page that replaces the
|
||||
allowlist surface entirely; until then, admin grants are done by
|
||||
direct DB `UPDATE`.
|
||||
|
||||
### Upgrade steps (from 0.13.0)
|
||||
|
||||
1. **MUST** restart the backend so migration `014_beta_access.sql`
|
||||
runs. The migration adds `permission_state` (default `'granted'`,
|
||||
so existing rows pass through unaffected), `first_name`,
|
||||
`last_name`, `beta_request_reason`, `permission_decided_by`,
|
||||
and `permission_decided_at` to the `users` table, plus an index
|
||||
on `permission_state` for the pending queue. The migration is
|
||||
ALTER-TABLE-based (no table rebuild) — every foreign key and
|
||||
existing row passes through untouched.
|
||||
2. **MUST** rebuild the frontend. The `Login.jsx` surface now
|
||||
runs a conditional third step (the capture form) on fresh OTC
|
||||
sign-ins; `BetaPending.jsx` carries the new "your request is
|
||||
in review" copy; `App.jsx` renders a thin pending-access
|
||||
banner. The build embeds the new `/api/auth/me/beta-request`
|
||||
client call.
|
||||
3. **SHOULD** announce the new admission flow to existing users.
|
||||
Wording suggestion: "We've replaced our email-allowlist gate
|
||||
with an admin-review flow. Existing users are unaffected;
|
||||
new visitors sign in with their email, tell us a bit about
|
||||
themselves, and an admin reviews their request before
|
||||
discussion and contribution unlock." Existing sessions
|
||||
remain valid.
|
||||
4. **SHOULD** plan the admin grant mechanism. v0.8.0 does not
|
||||
ship a UI for the grant — v0.9.0 (roadmap item #7) will. For
|
||||
the v0.8.0 window, an admin grants access via direct DB
|
||||
gesture:
|
||||
```sql
|
||||
UPDATE users
|
||||
SET permission_state = 'granted',
|
||||
permission_decided_by = <admin_user_id>,
|
||||
permission_decided_at = datetime('now')
|
||||
WHERE email = '<approved>';
|
||||
```
|
||||
The pending queue lives in `SELECT * FROM users WHERE
|
||||
permission_state = 'pending' ORDER BY created_at`.
|
||||
5. **MUST** decide whether to drain the `allowed_emails` table.
|
||||
The OTC request handler no longer consults it; populated
|
||||
rows are inert at the request surface. Three operator
|
||||
choices, all valid:
|
||||
* **Leave as-is** (the framework's default behavior — the
|
||||
v0.3.0 admin UI continues to work, the rows stay as a
|
||||
fast-path bypass record). Recommended if you anticipate
|
||||
v0.9.0's user-management page folding the allowlist UI
|
||||
into its surface.
|
||||
* **Drain via the existing admin UI** (`/admin/allowlist`)
|
||||
— one row at a time, no data loss elsewhere.
|
||||
* **Bulk-drain via DB** — `DELETE FROM allowed_emails;`
|
||||
drops every row; the table stays.
|
||||
6. **MAY** announce write access individually to grandfathered
|
||||
users you want to keep at `'granted'`. The default-`'granted'`
|
||||
migration means no action is required for them; this step
|
||||
exists only if you want to send a "you're still in" message.
|
||||
|
||||
### Added
|
||||
|
||||
- **`backend/migrations/014_beta_access.sql`** — adds
|
||||
`permission_state` (CHECK in `('pending', 'granted', 'revoked')`,
|
||||
default `'granted'`), `first_name`, `last_name`,
|
||||
`beta_request_reason`, `permission_decided_by` (FK to users,
|
||||
ON DELETE SET NULL), `permission_decided_at` to the `users`
|
||||
table. Plus `idx_users_permission_state` for the pending
|
||||
queue.
|
||||
- **`POST /api/auth/me/beta-request`** — body
|
||||
`{first_name, last_name, beta_request_reason}` (all required;
|
||||
bounds 120 / 120 / 4000). Writes the fields to the signed-in
|
||||
user's row and leaves `permission_state='pending'`. Refuses
|
||||
HTTP 409 for already-granted / revoked users; refuses HTTP
|
||||
401 for anonymous callers.
|
||||
- **`needs_profile` flag** on the `/auth/otc/verify` response.
|
||||
`true` iff the user is `permission_state='pending'` AND
|
||||
carries no profile fields yet (a fresh OTC sign-in). The
|
||||
Login.jsx surface uses the flag to gate the capture step.
|
||||
- **`permission_state` field** on the `/api/auth/me` response,
|
||||
plus `first_name`, `last_name`, `beta_request_reason`, and
|
||||
the same `needs_profile` flag.
|
||||
- **First-OTC profile capture step** in `Login.jsx`. Third
|
||||
step in the sign-in surface, gated by the verify response's
|
||||
`needs_profile` flag.
|
||||
- **`/beta-pending` repurpose** in `BetaPending.jsx`. The
|
||||
page now reads as "your request is in review" when the
|
||||
viewer is pending; the v0.3.0 "private beta" framing
|
||||
remains as the anonymous-viewer fallback.
|
||||
- **Thin pending-access banner** at the top of every page
|
||||
for `permission_state='pending'` viewers (other than
|
||||
`/beta-pending` itself).
|
||||
- **SPEC `§6` opening / `§6.1` / `§6.2` / `§14.1` / `§17` /
|
||||
`§19.2`** corrections per §19.3 rule-2 — the admission
|
||||
shift, the orthogonality of permission_state vs role / muted
|
||||
/ notification-mutes, the new endpoints, and the
|
||||
newly-surfaced §19.2 candidates (admin user-management page,
|
||||
allowlist deprecation, admin notification on new request).
|
||||
- **`backend/tests/test_beta_access_vertical.py`** — 9 new
|
||||
tests covering: a fresh OTC user lands pending with empty
|
||||
profile; the capture endpoint populates the fields and
|
||||
keeps state pending; the capture endpoint refuses
|
||||
anonymous / granted / revoked callers; a pending user is
|
||||
refused write endpoints; an admin grant promotes pending →
|
||||
granted; a grandfathered user is unaffected by the
|
||||
migration; the OTC request endpoint accepts any email
|
||||
regardless of allowlist state; the `allowed_emails` table
|
||||
is still present in the schema.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`backend/app/auth.py#require_contributor`** widens its
|
||||
gate to refuse `permission_state != 'granted'` with HTTP
|
||||
403. The §6.1 contributor capabilities (propose, branch,
|
||||
PR, chat, claim) all funnel through this dependency, so
|
||||
the widening covers them transitively. `SessionUser` now
|
||||
carries `permission_state` (default `'granted'` for the
|
||||
dataclass-default fallback path).
|
||||
- **`backend/app/otc.py#request_code`** drops the allowlist
|
||||
check from the OTC request flow. The `RequestOutcome`
|
||||
shape loses the `'allowlist'` reason (replaced by
|
||||
`'sent'` / `'cooldown'` / `'invalid'`).
|
||||
- **`backend/app/otc.py#provision_or_link_user`** sets
|
||||
`permission_state='pending'` explicitly on a fresh row.
|
||||
Grandfathered (link-by-email) users pass through with
|
||||
their existing column value.
|
||||
- **`backend/app/auth.py#provision_user`** (OAuth fallback)
|
||||
now sets `permission_state='granted'` explicitly on a
|
||||
fresh row. The OAuth callback still consults the
|
||||
`is_allowed_sign_in` allowlist check (the legacy fallback
|
||||
path retains its v0.3.0 admission shape during the OAuth
|
||||
migration window).
|
||||
- **`backend/tests/test_otc_vertical.py`** — the
|
||||
`test_otc_request_silently_drops_when_email_not_on_allowlist`
|
||||
test (asserted the v0.7.0 allowlist gate) is replaced by
|
||||
`test_otc_request_admits_emails_regardless_of_allowlist_population`
|
||||
which asserts the v0.8.0 open-request contract. The
|
||||
on-list test stays as a regression net for the
|
||||
rate-limit / outbound-buffer plumbing.
|
||||
- **`SPEC.md`** §6 opening, §6.1, §6.2, §14.1, §17, §19.2
|
||||
per §19.3 rule-2.
|
||||
- **`VERSION`** → `0.8.0`. `frontend/package.json#version` and
|
||||
the lockfile mirror.
|
||||
|
||||
### Deferred to later releases
|
||||
|
||||
- **Admin user-management page** at `/admin/users` (item #7,
|
||||
v0.9.0) — replaces the manual DB `UPDATE` gesture.
|
||||
- **Allowlist UI deprecation** (also v0.9.0) — once the
|
||||
admin user-management page lands, the `/admin/allowlist`
|
||||
surface and the `allowed_emails` table both retire.
|
||||
- **Admin email notification on new beta request** (item #7
|
||||
again, v0.9.0).
|
||||
- **Revoke gesture in the UI** — the `permission_state='revoked'`
|
||||
state is wired in the schema and the auth gate; v0.9.0 ships
|
||||
the admin UI that flips the column.
|
||||
|
||||
## 0.7.0 — 2026-05-28
|
||||
|
||||
**Minor — schema migration required; new auth path is additive.**
|
||||
@@ -802,106 +158,6 @@ release and surface as §19.2 candidates:
|
||||
- **Removing the Gitea OAuth `/auth/callback` route entirely** — a
|
||||
later release after every active user has signed in via OTC.
|
||||
|
||||
## 0.6.0 — 2026-05-28
|
||||
|
||||
**Minor — no operator action required.** A sweep-the-edges hardening
|
||||
release (roadmap item #4, "anon discuss + contribute off-limits") that
|
||||
audits every write-shaped backend endpoint and asserts each one
|
||||
enforces an explicit `auth.require_contributor` (or stricter) gate
|
||||
before doing any state-changing work. v0.3.0 hid write affordances
|
||||
behind a sign-in CTA on the frontend; v0.5.0 added the PR-less
|
||||
discussion surface with its own write gate; v0.6.0 sweeps the rest
|
||||
and adds a regression test net so future endpoints can't quietly
|
||||
ship without a gate. No schema migration, no env-var changes, no
|
||||
new dependencies. The only behavioural change is one tightening: the
|
||||
`GET /api/rfcs/<slug>/graduate/progress` SSE now requires
|
||||
`auth.require_user` since it surfaces operator-visible step detail
|
||||
(repo name, PR number, rollback steps) not part of the v0.3.0
|
||||
anonymous-read contract for catalog/RFC bodies.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`backend/app/api_graduation.py`** — `GET /graduate/progress`
|
||||
now calls `auth.require_user(request)` as its first line.
|
||||
Anonymous callers receive 401 instead of being able to subscribe
|
||||
to a graduation's SSE. The floor is `require_user` (not
|
||||
`require_contributor`) so a write-muted operator can still observe
|
||||
the progress of a graduation they kicked off before being muted.
|
||||
- **SPEC `§6.1`** (`SPEC.md`) — the Anonymous role's bullet now
|
||||
documents the v0.6.0 audit: every write-shaped endpoint in §17
|
||||
enforces an explicit gate; anonymous writes refuse 401. The list
|
||||
of audited write families is recorded in-line.
|
||||
- **SPEC `§10.10`** — the discussion-vs-contribution section now
|
||||
records that the v0.5.0 write gates have a regression test net
|
||||
(`test_anon_offlimits_vertical.py`) added in v0.6.0.
|
||||
- **SPEC `§17`** — the `GET /api/rfcs/<slug>/graduate/progress`
|
||||
bullet now documents the `require_user` gate added in v0.6.0,
|
||||
with the rationale.
|
||||
|
||||
### Added (tests)
|
||||
|
||||
- **`backend/tests/test_anon_offlimits_vertical.py`** — twelve new
|
||||
tests asserting that every write-shaped endpoint surveyed in the
|
||||
v0.6.0 audit refuses anonymous callers with 401, and that the five
|
||||
anonymous-read surfaces (health, philosophy, auth/me, catalog,
|
||||
RFC view, discussion threads, proposals) stay reachable. Sixty-six
|
||||
assertions in total, covering: propose; proposal merge / decline /
|
||||
withdraw; branch promote-to-branch / start-edit-branch / metadata /
|
||||
manual-flush / visibility / grants (POST + DELETE) / threads (POST)
|
||||
/ messages (POST) / resolve / chat-seen / changes (accept / decline
|
||||
/ reask) / chat-stream; super-draft start-edit-branch + metadata;
|
||||
PR pr-draft / open / seen / review / merge / withdraw /
|
||||
description / resolution-branch; discussion thread create + message
|
||||
post + resolve; admin role / mute / allowlist (POST + DELETE) plus
|
||||
the admin reads; notification preferences / quiet-hours / watch /
|
||||
mark-read / user-mute (POST + DELETE); funder credentials (POST +
|
||||
DELETE) + consent (POST + DELETE); graduation kickoff + claim +
|
||||
progress SSE; PR review page anonymous-readable.
|
||||
|
||||
### Anonymous-writeable allowlist
|
||||
|
||||
Two endpoints are intentionally anonymous-by-design. They are not
|
||||
audit findings; they are documented here so the contract is
|
||||
explicit:
|
||||
|
||||
- `GET /auth/login` and `GET /auth/callback` — the OAuth
|
||||
round-trip. Anonymous-by-design because they ARE the sign-in
|
||||
entrypoint.
|
||||
- `POST /api/webhooks/gitea` and `POST /api/webhooks/email-bounce`
|
||||
— anonymous in the session sense but authenticated by HMAC shared
|
||||
secret (`GITEA_WEBHOOK_SECRET` and `WEBHOOK_EMAIL_BOUNCE_SECRET`
|
||||
respectively). The webhook receiver is the wrong place for an
|
||||
authenticated session; the shared-secret shape is correct.
|
||||
|
||||
### §19.2 candidates surfaced
|
||||
|
||||
- None unique to this release. The two pre-existing candidates the
|
||||
audit touched — anonymous-read polish for the discussion surface
|
||||
(carried from v0.5.0) and the operator-visibility floor on
|
||||
graduation progress — were settled here as `require_user` on
|
||||
`/graduate/progress` rather than deferred.
|
||||
|
||||
### Upgrade steps (from 0.5.0)
|
||||
|
||||
1. The deployment **MUST** rebuild the frontend (`npm install &&
|
||||
npm run build`) so the frontend bundle's reported version matches
|
||||
the backend's. No new env vars; existing `frontend/.env` is
|
||||
sufficient.
|
||||
2. The deployment **MUST** restart the backend so the new
|
||||
`require_user` gate on `/graduate/progress` is enforced. No
|
||||
schema migration runs.
|
||||
3. The deployment **MAY** announce the audit completion to
|
||||
operators: every write-shaped backend endpoint now enforces an
|
||||
explicit gate, and the `test_anon_offlimits_vertical.py` test
|
||||
net asserts the contract on every CI run. The audited surfaces
|
||||
are listed in the `Added (tests)` section above and in `SPEC.md`
|
||||
§6.1.
|
||||
4. The deployment **MUST NOT** assume any new envelope behaviour:
|
||||
v0.6.0 is purely a hardening release. No schema, no env, no
|
||||
dependency changes; the operator's role is reduced to rebuild +
|
||||
restart.
|
||||
|
||||
|
||||
## 0.5.0 — 2026-05-27
|
||||
|
||||
**Minor — no operator action required.** This release wires the
|
||||
@@ -1254,3 +510,7 @@ names itself.
|
||||
- `CLAUDE.md` at the repo root capturing the separation-of-concerns
|
||||
rule for working sessions.
|
||||
|
||||
## 0.1.0 — v1 build
|
||||
|
||||
Initial release. See `docs/DEV.md` for the slicing plan and build
|
||||
history.
|
||||
|
||||
@@ -1,605 +0,0 @@
|
||||
# Using the RFC app
|
||||
|
||||
This is the user-facing guide to the Wiggleverse RFC framework — how to
|
||||
read what's here, propose a new RFC, contribute to one that already
|
||||
exists, and understand who is allowed to do what.
|
||||
|
||||
This guide describes the framework. Individual deployments brand and
|
||||
configure themselves independently — the name in the header and the
|
||||
corpus the RFCs are about belong to the deployment, not to this
|
||||
document.
|
||||
|
||||
For the *why* of the framework, read the [philosophy](/philosophy).
|
||||
For the binding technical contract, see `SPEC.md` in the repository.
|
||||
|
||||
---
|
||||
|
||||
## Reading without signing in
|
||||
|
||||
You can read the catalog and every public RFC without an account.
|
||||
Anonymous visitors can:
|
||||
|
||||
- Browse the catalog of super-drafts and active RFCs.
|
||||
- Open any RFC and read its canonical body.
|
||||
- Read any public branch — its diff and its chat thread.
|
||||
- Read any pull request — its diff, its conversation, its review
|
||||
comments.
|
||||
- Read the discussion that has accumulated on an RFC's main view.
|
||||
|
||||
Reading is open by design. The framework's claim is that the *argument
|
||||
behind a definition* is the evidence that the definition was earned,
|
||||
and an argument that disappears behind a sign-in wall stops carrying
|
||||
that evidence.
|
||||
|
||||
What you cannot do without an account: chat, propose a new RFC,
|
||||
create a branch, open a PR, drop a flag, or post on a discussion
|
||||
thread. Every write affordance is replaced with a sign-in prompt.
|
||||
|
||||
---
|
||||
|
||||
## Signing in
|
||||
|
||||
While the framework is in private beta, only invited email addresses
|
||||
can complete sign-in. If your email is on the allowlist, the
|
||||
"Sign in" button in the header completes the flow and lands you on
|
||||
the catalog with full read and write access. If your email is not on
|
||||
the allowlist, you'll be sent to a short "pending" page explaining
|
||||
the gate.
|
||||
|
||||
Once you have an account, you're a **contributor** by default — the
|
||||
role that grants every write affordance the app exposes, scoped by
|
||||
the per-RFC and per-branch rules described below.
|
||||
|
||||
---
|
||||
|
||||
## Proposing a new RFC
|
||||
|
||||
A new RFC begins as a proposal. The "+ Propose new RFC" button at
|
||||
the bottom of the catalog opens a small modal that collects four
|
||||
things:
|
||||
|
||||
- **Title.** The word, concept, or topic this RFC would define.
|
||||
- **Slug.** A kebab-cased identifier derived from the title. It is
|
||||
the entry's stable handle from this moment until it graduates;
|
||||
collisions with existing entries or open proposals are caught
|
||||
inline.
|
||||
- **Pitch.** One or two paragraphs answering *why this RFC is
|
||||
needed*. This becomes the body of the entry.
|
||||
- **Tags.** Optional. The AI suggests tags from the pitch; you can
|
||||
accept, dismiss, or type your own.
|
||||
|
||||
Submitting the modal does one concrete thing: it opens a pull
|
||||
request against the framework's meta repository, adding one new
|
||||
file under `rfcs/`. There is no other Git artifact and no other
|
||||
side-effect. You are returned to the **pending-idea view** for the
|
||||
new proposal.
|
||||
|
||||
A pending idea is publicly readable but not yet a super-draft. The
|
||||
catalog surfaces it in a "Pending ideas" disclosure at the bottom
|
||||
of the list. A conversation can accumulate on the pending-idea view
|
||||
before it is admitted — contributors can argue, in public, about
|
||||
whether the entry belongs in the catalog at all.
|
||||
|
||||
Three outcomes are possible:
|
||||
|
||||
- **Merge.** An admin or owner merges the proposal PR. The entry
|
||||
becomes a super-draft and graduates from the "Pending ideas"
|
||||
section into the main catalog. Any conversation that accumulated
|
||||
on the pending-idea view migrates with it.
|
||||
- **Decline.** An admin or owner declines, attaching a written
|
||||
comment. You see the comment on your next visit, along with a
|
||||
one-click affordance to revise and re-propose.
|
||||
- **Withdraw.** You can withdraw your own proposal at any time. The
|
||||
entry will not appear in any default view; the conversation that
|
||||
accumulated stays attached to the closed PR as historical record.
|
||||
|
||||
You are automatically the first owner of any RFC you propose. The
|
||||
claim flow described under [Roles & permissions](#roles--permissions)
|
||||
is for *other* contributors to add themselves as owners later, not
|
||||
for the proposer.
|
||||
|
||||
---
|
||||
|
||||
## What a super-draft is
|
||||
|
||||
A super-draft is an entry that has been admitted to the catalog but
|
||||
does not yet have its own dedicated repository. Most of the
|
||||
argument that shapes a definition happens here. The framework
|
||||
assumes — and the philosophy explicitly invites — that many
|
||||
super-drafts will not survive the argument, and that is fine. The
|
||||
entries that do survive earn their place in the catalog by being
|
||||
defensible in public.
|
||||
|
||||
Opening a super-draft from the catalog gives you the same surface
|
||||
an active RFC uses:
|
||||
|
||||
- The canonical body in the centre, read-only by default.
|
||||
- A chat thread on the right where the public conversation lives.
|
||||
- A breadcrumb dropdown listing any in-flight edit branches and
|
||||
any open body-edit PRs against this entry.
|
||||
- A "Start Contributing" affordance that cuts a fresh edit branch
|
||||
and lands you in contribute mode.
|
||||
|
||||
Edits to a super-draft body propagate through pull requests against
|
||||
the meta repository — there is no dedicated RFC repository yet.
|
||||
|
||||
---
|
||||
|
||||
## What an active RFC is
|
||||
|
||||
An active RFC is an entry that has been **graduated**. It has its
|
||||
own dedicated repository, an integer `RFC-NNNN` identifier, and a
|
||||
canonical body file (`RFC.md`) inside that repository. The catalog
|
||||
distinguishes super-drafts and active RFCs at a glance.
|
||||
|
||||
Opening an active RFC gives you:
|
||||
|
||||
- `main` — the canonical body, always read-only. Changes to `main`
|
||||
arrive exclusively through pull requests.
|
||||
- A breadcrumb listing every open branch and pull request on this
|
||||
RFC.
|
||||
- A per-branch chat thread on the right. Each branch has its own
|
||||
conversation, including `main` itself.
|
||||
- A "Start Contributing" affordance: on `main` it cuts a new branch
|
||||
and lands you on it in contribute mode; on any other branch you
|
||||
already have push access to, it flips that branch into
|
||||
contribute mode.
|
||||
|
||||
---
|
||||
|
||||
## Discussion vs contribution
|
||||
|
||||
The framework draws an explicit distinction between two surfaces
|
||||
that other tools tend to conflate:
|
||||
|
||||
- **Discussion** is what the RFC is *for*. The chat thread on an
|
||||
RFC's main view is the place for "what about this part?" or
|
||||
"have we considered…?" questions that don't yet warrant proposing
|
||||
a specific edit. Posting on a discussion thread does not create
|
||||
any Git artifact; the conversation lives in the app database.
|
||||
- **Contribution** is how an RFC *changes*. Editing the canonical
|
||||
body requires opening a branch and, eventually, a pull request.
|
||||
The pull request is the place a specific proposed change is
|
||||
reviewed and merged.
|
||||
|
||||
Reading both surfaces is open to anonymous visitors. Posting on
|
||||
either requires a contributor account.
|
||||
|
||||
---
|
||||
|
||||
## Working on a branch
|
||||
|
||||
Contribute mode flips one branch into edit-enabled. The centre
|
||||
column splits: a markdown source pane on the left, a live-rendered
|
||||
preview on the right. Fenced `mermaid` blocks render as diagrams in
|
||||
the preview.
|
||||
|
||||
Two kinds of edits accumulate on a branch:
|
||||
|
||||
- **AI-proposed changes.** You ask the AI a question or request a
|
||||
revision in the branch's chat. When the AI proposes a concrete
|
||||
edit, that edit appears as a *change card* in a panel below the
|
||||
chat — not yet applied to the document. You can **accept**,
|
||||
**decline**, or **edit before accepting**. Accepting produces
|
||||
one commit on the branch with the original text, the proposed
|
||||
text, and the AI's reason recorded in the commit body.
|
||||
- **Manual edits.** Typing directly into the source pane buffers
|
||||
locally and flushes as a single commit on an idle window, a
|
||||
branch switch, or an explicit "Save now" button. Manual edits
|
||||
also appear as change cards in the same panel — same evidence
|
||||
shape, different author.
|
||||
|
||||
Every accepted change is one commit. The framework does not
|
||||
support squash-merges or fixup-style cleanups: the per-change
|
||||
commit granularity is the framework's evidence unit, and
|
||||
collapsing it would erase what was earned.
|
||||
|
||||
### Discuss mode vs contribute mode
|
||||
|
||||
A branch defaults to discuss mode — read-only, with chat enabled.
|
||||
AI proposals still appear in chat, but they are *buffered* rather
|
||||
than applied; a single CTA invites you to flip the branch into
|
||||
contribute mode if you want to act on them. The toggle is an
|
||||
*intent* affordance, not a permission one. If you don't have push
|
||||
access to the branch, the toggle is disabled with a sign-in or
|
||||
request-access path.
|
||||
|
||||
`main` is special: contribute mode is never available there. The
|
||||
"Start Contributing" button on `main` always cuts a new branch.
|
||||
|
||||
### Flags
|
||||
|
||||
Anywhere you can read, you can drop a flag. A flag is the
|
||||
lightweight "I'm pointing at this, it's a problem" gesture — a
|
||||
single short declarative statement anchored to a passage. Creating
|
||||
a flag requires a contributor account but does not require push
|
||||
access to the branch: any signed-in contributor who can read a
|
||||
passage can point at it and say it's wrong.
|
||||
|
||||
Flags don't block PR merges by design — making them a merge gate
|
||||
would re-create the failure mode where contributors hastily "resolve"
|
||||
threads to unblock a button. Flags are prominent on PR headers but
|
||||
non-blocking.
|
||||
|
||||
### Branch visibility
|
||||
|
||||
A new branch is publicly readable by default. The branch creator
|
||||
can flip a branch to private, in which case only the creator, any
|
||||
explicit grantees, and the RFC's per-RFC owners and arbiters can
|
||||
read it. Owners and arbiters can flip it back.
|
||||
|
||||
**Opening a PR makes the branch fully public.** If your branch is
|
||||
currently private, the "Open PR" affordance asks you to confirm
|
||||
this before submitting. There is no concept of a private PR — the
|
||||
framework's evidence claim depends on the argument being readable.
|
||||
|
||||
### Who can push to a branch
|
||||
|
||||
Every branch has one of three contribute modes:
|
||||
|
||||
- **`just-me`** (default) — only the branch creator can push.
|
||||
- **`specific`** — only the branch creator and explicitly granted
|
||||
contributors can push.
|
||||
- **`any-contributor`** — any signed-in contributor can push.
|
||||
|
||||
The branch creator and the RFC's per-RFC owners and arbiters can
|
||||
change this setting at any time.
|
||||
|
||||
### Branch hygiene
|
||||
|
||||
A branch with no associated PR auto-closes after 30 days of
|
||||
inactivity. A closed branch is deleted from the Git host 60 days
|
||||
later. Closed branches remain in the catalog under a "show closed"
|
||||
filter — closing is a state, not a censorship event. The chat
|
||||
attached to a closed or deleted branch is preserved as historical
|
||||
record.
|
||||
|
||||
Owners and arbiters can *pin* a branch to disable the auto-close
|
||||
timer if the work is paused but legitimately ongoing.
|
||||
|
||||
---
|
||||
|
||||
## Opening and reviewing a pull request
|
||||
|
||||
A pull request is the deliberate "ready for review" gesture for
|
||||
work that has accumulated on a branch. The "Open PR" affordance is
|
||||
available on any branch with at least one commit ahead of `main`.
|
||||
|
||||
The PR creation modal collects two AI-drafted fields, both editable
|
||||
before submit:
|
||||
|
||||
- **Title.** A one-line description of the change, in spec voice.
|
||||
- **Description.** Two to four sentences pulling from the branch
|
||||
chat, written for an arbiter.
|
||||
|
||||
There is no reviewer picker. The RFC's arbiters are the implicit
|
||||
reviewer set.
|
||||
|
||||
### The PR review page
|
||||
|
||||
The review page shows the diff, the branch's compressed chat
|
||||
(messages that produced accepted changes are expanded, the rest is
|
||||
behind a "Show full conversation" toggle), and the review-comment
|
||||
surface inline below the chat.
|
||||
|
||||
Review comments are not a separate concept from chat — they live in
|
||||
the same thread, anchored to a range in the diff. The framework's
|
||||
claim is that the disagreement an arbiter raises about a proposed
|
||||
change is the same *kind* of thing as the disagreement that
|
||||
produced the proposed change in the first place, and the two should
|
||||
share a surface.
|
||||
|
||||
Each PR records a per-user seen-cursor. New diff hunks and new
|
||||
conversation messages since your last visit render with a subtle
|
||||
accent. The cursor advances on view; you do not have to mark
|
||||
anything as read.
|
||||
|
||||
### Merging a PR
|
||||
|
||||
Per-RFC owners and arbiters can merge; app-wide admins and owners
|
||||
also retain this capability. The merge produces a no-fast-forward
|
||||
commit on `main`, preserving every per-acceptance commit as an
|
||||
individually reachable node in `main`'s history.
|
||||
|
||||
Merge is hard-blocked **only** by Git-level conflicts with `main`.
|
||||
Open review threads, pending change-cards, unresolved chat threads,
|
||||
and open flags do not block merge by design.
|
||||
|
||||
### Conflicts with main
|
||||
|
||||
A conflict surfaces on the PR page as a read-only banner. A "Start
|
||||
resolution branch" affordance cuts a fresh branch off `main`'s
|
||||
current tip, replays the work into it (asking the AI to resolve
|
||||
unambiguous conflicts, surfacing the rest for you), and opens a new
|
||||
PR. The original PR auto-closes when the resolution PR merges.
|
||||
|
||||
Fixup commits on the existing branch are not supported. Per-change
|
||||
commit granularity is the framework's evidence unit; admitting
|
||||
"fix merge conflict with main" commits would dilute it.
|
||||
|
||||
---
|
||||
|
||||
## Graduation: super-draft → active RFC
|
||||
|
||||
Graduation is the moment a super-draft becomes a canonical entry
|
||||
in the catalog. It is initiated by an app-wide admin, an app-wide
|
||||
owner, or one of the RFC's per-RFC owners or arbiters from the
|
||||
super-draft's page.
|
||||
|
||||
Two preconditions block the action:
|
||||
|
||||
- **The super-draft must have at least one owner.** The proposer
|
||||
is automatically the first owner; if they have stepped away, any
|
||||
contributor can use the "Claim ownership" affordance to add
|
||||
themselves.
|
||||
- **No open body-edit PRs against the super-draft's entry.** An
|
||||
open body-edit PR would attempt to re-introduce a body to a
|
||||
frontmatter-only entry after graduation runs. Merge or withdraw
|
||||
them first.
|
||||
|
||||
When the dialog confirms, the framework runs a transactional
|
||||
sequence: create a fresh Git repository for the RFC, seed it with
|
||||
the super-draft's body as `RFC.md`, update the meta-repo entry to
|
||||
`state: active` with the integer ID and the new repository's URL,
|
||||
auto-merge that update. If any step fails partway, the sequence
|
||||
rolls back — the half-created repository is deleted and the
|
||||
unmerged update is abandoned. The dialog shows each step in flight
|
||||
and tells you exactly what happened.
|
||||
|
||||
The chat thread on the super-draft moves to the new repository's
|
||||
`main` chat at graduation. Edit-branch chats from the super-draft
|
||||
phase stay attached to their original branches on the meta repo
|
||||
and surface from the new RFC view under a "Pre-graduation history"
|
||||
section.
|
||||
|
||||
Graduation is not reversible. The path forward from an active RFC
|
||||
is withdrawal, not back to super-draft.
|
||||
|
||||
---
|
||||
|
||||
## Withdrawing and reopening
|
||||
|
||||
An active RFC or a super-draft can be withdrawn by the proposer
|
||||
(for a super-draft they proposed) or by an admin or owner. A
|
||||
withdrawn entry stays in the catalog as a historical record but is
|
||||
hidden from default views. The entry is filterable back in.
|
||||
|
||||
An admin or owner can reopen a withdrawn entry back into the
|
||||
super-draft state. The history is preserved across the transition.
|
||||
|
||||
---
|
||||
|
||||
## AI in the chat
|
||||
|
||||
The chat on every RFC, super-draft, branch, and PR has an AI
|
||||
participant by default. The framework treats the AI as one voice
|
||||
among many in a public argument — not an oracle, and not a
|
||||
co-author whose name lands on commits.
|
||||
|
||||
You invoke the AI by writing into the chat composer and submitting.
|
||||
Each message can pick a model from the picker (the option list is
|
||||
configurable per RFC). The AI responds in the chat; when its
|
||||
response includes a concrete change to the document, that change
|
||||
appears as a card you can accept, decline, or edit.
|
||||
|
||||
When you accept an AI's proposed change, the commit's
|
||||
`On-behalf-of:` trailer names *you*, not the AI. The AI's authorship
|
||||
survives only as evidence — the original proposal in the commit body
|
||||
and the message that produced it in the chat record. The framework
|
||||
is explicit about this: AI participation produces evidence; it does
|
||||
not produce authorship.
|
||||
|
||||
Two configuration knobs scope AI participation per RFC:
|
||||
|
||||
- **Which models are available.** The meta-repo entry's frontmatter
|
||||
carries an optional `models:` list. Absent means the RFC inherits
|
||||
whatever models the deployment is provisioned to run. An empty
|
||||
list (`models: []`) opts the RFC out of AI entirely — every AI
|
||||
surface is absent rather than disabled-but-present.
|
||||
- **Whose credentials pay.** By default the deployment operator's
|
||||
API credentials cover AI calls on every RFC. A `funder:`
|
||||
frontmatter field can name a single contributor whose registered
|
||||
credentials pay for AI calls on this RFC instead. The named
|
||||
contributor must explicitly consent from their settings page;
|
||||
either side can revoke at any time.
|
||||
|
||||
Per-RFC AI configuration is edited through the meta-repo PR flow
|
||||
that governs the rest of the entry's frontmatter — by the RFC's
|
||||
per-RFC owners and arbiters, or by app-wide admins or owners.
|
||||
|
||||
---
|
||||
|
||||
## Notifications
|
||||
|
||||
The framework's public-async work model produces signals that
|
||||
shouldn't all reach you the same way. Five surfaces compose:
|
||||
|
||||
- **In-app inbox.** The durable triage surface. One mental space
|
||||
across every RFC you have any relationship to, with per-RFC and
|
||||
per-category filters. Reachable from the inbox icon in the
|
||||
header.
|
||||
- **Badges.** Ambient pull-ins. A single integer beside the inbox
|
||||
icon (count of unread notifications). A small binary dot on
|
||||
individual catalog rows for watched RFCs with unseen activity.
|
||||
No per-row counts and no per-section counts.
|
||||
- **Toasts.** Transient mid-session signals. Used only for your own
|
||||
actions completing, and for events arriving on the view you're
|
||||
currently looking at.
|
||||
- **Email.** The single channel that escapes the app. Opt-in per
|
||||
category, conservative defaults. One-click unsubscribe per
|
||||
category.
|
||||
- **Digest.** Aggregation for activity on watched RFCs you haven't
|
||||
triaged through any other channel.
|
||||
|
||||
### Watch states
|
||||
|
||||
Every RFC has one of three implicit relationship states for you:
|
||||
|
||||
- **Watching.** You receive structural signals for the RFC.
|
||||
- **Following.** You receive only churn-grade signals (new
|
||||
commits, new chat messages on threads you didn't participate
|
||||
in). This is a lighter relationship than watching.
|
||||
- **Muted.** You receive no signals for the RFC. The mute is
|
||||
per-RFC and self-imposed; it does not affect what others see
|
||||
or what reaches you on *other* RFCs.
|
||||
|
||||
Watch states transition automatically based on your participation,
|
||||
with explicit overrides available from each RFC's header and from
|
||||
the notification settings page.
|
||||
|
||||
### Email categories
|
||||
|
||||
Four categories with distinct defaults:
|
||||
|
||||
- **Personal-direct events** — default on. Signals where you are
|
||||
the named subject. The contract is that when your name is on the
|
||||
action, the framework reaches out of band.
|
||||
- **Watched-RFC structural events** — default off. PR opened on a
|
||||
watched RFC, PR merged, graduation, withdrawal. Inbox and badges
|
||||
carry these by default; the email toggle is opt-in.
|
||||
- **Watched-RFC churn** — permanently off, by design. Per-commit
|
||||
and per-message email is intentionally not offered. The digest
|
||||
aggregates this activity weekly.
|
||||
- **Admin-actionable events** — default on for admins and owners,
|
||||
unused for contributors.
|
||||
|
||||
### Quiet hours
|
||||
|
||||
You can set a daily window during which email notifications are
|
||||
held. Messages held during the window are released at window end —
|
||||
bundled into a single "Activity while you were away" email if a
|
||||
threshold accumulated, otherwise sent individually.
|
||||
|
||||
---
|
||||
|
||||
## Roles & permissions
|
||||
|
||||
Authorization in this framework is owned by the app itself, not by
|
||||
the Git host. The Git host sees only a single bot account — every
|
||||
commit, every PR, every merge passes through it on a user's behalf
|
||||
— and the *app* decides which users are authorized to ask the bot
|
||||
to do which things.
|
||||
|
||||
### The four app-wide roles
|
||||
|
||||
Each role is a strict superset of the one below it.
|
||||
|
||||
1. **Anonymous.** Anyone who has not signed in. Can read public
|
||||
RFCs, public branches, and public PRs; cannot chat, propose,
|
||||
create branches, or open PRs.
|
||||
|
||||
2. **Contributor.** The default role for any authenticated
|
||||
account. Adds everything anonymous can do, plus: propose new
|
||||
RFCs, create branches on any RFC repository, open PRs from
|
||||
branches they have push access to, post on chat anywhere they
|
||||
can read, claim ownership of unclaimed super-drafts.
|
||||
|
||||
3. **Admin.** Adds the ability to act on any RFC, anywhere in the
|
||||
framework. Concretely: merge any PR on any RFC, graduate any
|
||||
super-draft, set branch visibility on anyone's behalf, withdraw
|
||||
or reopen any entry, write-mute or restore any contributor,
|
||||
grant or revoke the **admin** role.
|
||||
|
||||
4. **Owner.** Adds two capabilities admin does not have: grant or
|
||||
revoke the **owner** role itself, and disable an account
|
||||
entirely. The framework names a single "owner zero" at
|
||||
bootstrap.
|
||||
|
||||
The practical difference between admin and owner is narrow but
|
||||
load-bearing: admin is the operational tier — it does the day-to-
|
||||
day moderation and stewardship work; owner is the tier that
|
||||
controls the admin tier. Disabling an account and creating other
|
||||
owners are owner-only because they affect the framework's chain of
|
||||
authority itself.
|
||||
|
||||
The app refuses to let the last owner demote themselves silently —
|
||||
losing the last owner would leave nobody able to grant the role
|
||||
back. Role changes are recorded in an append-only `permission_events`
|
||||
log; an admin's own admin/users page shows the log of who promoted,
|
||||
demoted, or muted whom.
|
||||
|
||||
### Per-RFC delegated authority
|
||||
|
||||
The four roles above are framework-wide. Within an individual RFC,
|
||||
the meta-repo entry's frontmatter names two additional groups:
|
||||
|
||||
- **`owners:`** — contributors elevated for this RFC. They can
|
||||
grant push access on any branch in the RFC, merge any PR on the
|
||||
RFC, change branch visibility, and withdraw the RFC.
|
||||
- **`arbiters:`** — contributors with merge authority for this RFC.
|
||||
Functionally similar to per-RFC owners for merge decisions; the
|
||||
distinction matters in some configuration paths.
|
||||
|
||||
Per-RFC owners and arbiters are **not** app-wide admins. Their
|
||||
elevated powers are scoped strictly to the RFC named in the
|
||||
frontmatter. This is what lets the framework distribute work
|
||||
without putting one person on the hook for every action.
|
||||
|
||||
The proposer of an RFC is automatically the first per-RFC owner.
|
||||
Additional per-RFC owners are added through a "Claim ownership"
|
||||
PR against the meta repository; app-wide admins or owners merge
|
||||
it.
|
||||
|
||||
### Per-branch contribute grants
|
||||
|
||||
Within an RFC, the branch creator and the RFC's per-RFC owners
|
||||
and arbiters can grant push access to specific contributors on a
|
||||
specific branch — `specific` contribute mode, described under
|
||||
"Working on a branch."
|
||||
|
||||
### The write-mute
|
||||
|
||||
An app-wide admin or owner can **mute** a contributor. A muted
|
||||
account retains read access and keeps its existing branches, but
|
||||
cannot create new branches, open new PRs, propose new RFCs, or
|
||||
post chat. This is a moderation tool, distinct from removing the
|
||||
account; restoring is the reverse gesture.
|
||||
|
||||
The write-mute applies only to contributors. Promoting a user to
|
||||
admin or owner is the way to remove a user's write-restriction in
|
||||
the structural sense; the write-mute is for *retaining* an account
|
||||
while removing its ability to act.
|
||||
|
||||
Every mute and every restore is recorded in `permission_events`.
|
||||
|
||||
### Three different "mutes"
|
||||
|
||||
The word "mute" appears in three structurally distinct places.
|
||||
They share a word and nothing else.
|
||||
|
||||
- **Write-mute.** Admin-imposed. Removes a contributor's ability
|
||||
to post or push. Described above.
|
||||
- **Per-RFC notification mute.** Self-imposed. Sets your watch
|
||||
state on a specific RFC to *muted* — you stop receiving signals
|
||||
for that RFC, in inbox, badges, and email. Does not affect what
|
||||
others see.
|
||||
- **Per-user notification mute.** Self-imposed. Suppresses
|
||||
notifications produced by a specific other user, anywhere in
|
||||
the framework. Notification-volume only — it does not affect
|
||||
what you can read.
|
||||
|
||||
A write-muted contributor continues to receive notifications
|
||||
normally, so they can triage what they can't act on, and so a
|
||||
restore lands cleanly.
|
||||
|
||||
### Audit trail
|
||||
|
||||
Every gesture that changes app state — role changes, mutes,
|
||||
graduations, withdrawals, grant changes — is recorded in
|
||||
append-only logs the app maintains. Git commit history is for
|
||||
code archaeology; the app's audit log is the accountability
|
||||
record. An admin's page surfaces both `permission_events` (the
|
||||
role/mute log) and `actions` (the state-transition log) for
|
||||
review.
|
||||
|
||||
---
|
||||
|
||||
## Where to learn more
|
||||
|
||||
- The framework's *why* lives in [the philosophy
|
||||
document](/philosophy).
|
||||
- The binding technical contract — section numbers (`§n.n`)
|
||||
referenced throughout this guide — is in `SPEC.md` in the
|
||||
framework's source repository.
|
||||
- Deployment operators have their own recipe in
|
||||
`docs/DEPLOYMENTS.md`.
|
||||
@@ -332,13 +332,6 @@ and exact columns are illustrative; the implementing session can adjust.
|
||||
- `actions` — append-only audit log for every state transition, every
|
||||
graduation, every grant change. Includes the acting user, the bot
|
||||
commit hash if any, and the on-behalf-of trailer applied.
|
||||
- `cookie_consent` — per-user record of the §14.5 cookie consent
|
||||
choice. One row per user. Columns: `user_id` (PK, FK users), three
|
||||
flags (`essential`, `analytics`, `other_cookies`), and
|
||||
`recorded_at`. `essential` is permanently 1; `recorded_at` is set
|
||||
on first write and updated on every change. Absence of a row means
|
||||
"no choice yet" — the banner shows. Anonymous viewers persist their
|
||||
choice in `localStorage` only, with no corresponding row here.
|
||||
|
||||
**Super-draft scoping.** For rows in `threads` and `changes` where the
|
||||
entry referenced by `rfc_slug` is in state `super-draft`, `branch_name`
|
||||
@@ -356,76 +349,31 @@ merge with no data movement.
|
||||
|
||||
Authorization is owned by the app. Gitea sees only the bot account.
|
||||
|
||||
Authentication has three paths, in the order a visitor encounters
|
||||
them:
|
||||
|
||||
1. **Email + one-time code (OTC).** The v0.7.0 primary path: a
|
||||
visitor enters their email address, receives a six-digit code via
|
||||
SMTP, and exchanges the code for a session. Used by every visitor
|
||||
on first sign-in, and as the fallback for the other two paths.
|
||||
2. **Email + passcode (with OTC fallback).** Added in v0.10.0
|
||||
(roadmap item #8). After a successful OTC sign-in, the visitor
|
||||
may set a user-chosen passcode (4–20 characters, bcrypt-hashed at
|
||||
rest) and use email + passcode on subsequent sign-ins. Five
|
||||
consecutive failed verifies lock the passcode path for 15 minutes
|
||||
(HTTP 423); during the lockout the user falls back to OTC. The
|
||||
OTC path is unaffected by the passcode lockout, so a forgotten
|
||||
passcode is recovered by requesting a fresh OTC — there is no
|
||||
separate "forgot passcode" flow. The user can remove the passcode
|
||||
at any time from the §6.2 sign-in settings tab, returning to
|
||||
OTC-only.
|
||||
3. **Gitea OAuth fallback (migration only).** The v0.1 OAuth
|
||||
callback remains functional during the v0.7.0 window, with a
|
||||
small "Sign in with Gitea (fallback)" link on `/login` so users
|
||||
with active OAuth sessions or older invite paths still have a
|
||||
way in. Scheduled for removal in a future release per §19.2.
|
||||
|
||||
`users.gitea_id` is preserved on existing rows so a grandfathered
|
||||
user signing in via any of the three paths resolves to the same row.
|
||||
`users.email` is the identity key for everything provisioned after
|
||||
v0.7.0; `users.gitea_id` is the grandfathering linker (nullable,
|
||||
partial-unique). The Gitea bot user + token are still required for
|
||||
server-side git operations (repo reads, PR creation); only the
|
||||
operator-facing sign-in surface moved.
|
||||
|
||||
Admission, as of v0.8.0, is by admin grant. v0.7.0 carried the
|
||||
v0.3.0 `allowed_emails` table forward as the admission gate at the
|
||||
OTC request surface — emails not on the list got a silent drop.
|
||||
v0.8.0 (roadmap item #6) reverses that: any valid email receives an
|
||||
OTC, the fresh `users` row lands in `permission_state='pending'`,
|
||||
and an admin grant flips the column to `'granted'` before write
|
||||
endpoints accept the user. The capture-fields step (first name,
|
||||
last name, free-text "why I should be included in the beta") feeds
|
||||
the admin's triage queue. The `allowed_emails` table stays in the
|
||||
schema as a fast-path bypass — the v0.3.0 admin UI still manages
|
||||
it — but the OTC request path no longer consults it. v0.9.0's
|
||||
admin user-management page replaces the allowlist UI and ships the
|
||||
pending-queue triage surface.
|
||||
Authentication, as of v0.7.0, is by email + one-time-code: a visitor
|
||||
enters their email address, receives a six-digit code via SMTP, and
|
||||
exchanges the code for a session. The Gitea OAuth callback that
|
||||
v0.1 used as the human sign-in path remains as a migration fallback
|
||||
during the v0.7.0 window — `users.gitea_id` is preserved on existing
|
||||
rows so a grandfathered user signing in via either path resolves to
|
||||
the same row — but the primary surface points at OTC. `users.email`
|
||||
is the identity key for everything provisioned after v0.7.0;
|
||||
`users.gitea_id` is the grandfathering linker (nullable, partial-
|
||||
unique). The Gitea bot user + token are still required for server-
|
||||
side git operations (repo reads, PR creation); only the operator-
|
||||
facing sign-in surface moved.
|
||||
|
||||
### 6.1 Four roles, each a strict superset of the one below
|
||||
|
||||
1. **Anonymous.** Can read public RFCs (the meta repo's main branch,
|
||||
every RFC repo's main branch), read any branch whose `read_public`
|
||||
is true, read any PR. Cannot chat, propose, create branches, or
|
||||
open PRs. v0.6.0 (roadmap item #4) closed the audit: every
|
||||
write-shaped endpoint surveyed in §17 enforces an explicit
|
||||
`auth.require_contributor` (or stricter) gate before doing any
|
||||
state-changing work; anonymous writes refuse 401. The explicit
|
||||
audit covers propose, branch create, branch threads, PR-less
|
||||
discussion threads + messages, PR open / merge / withdraw,
|
||||
funder credentials + consent, admin allowlist add, graduation
|
||||
kickoff + claim. Anonymous reads on every catalog and RFC-body
|
||||
surface remain open per the v0.3.0 contract.
|
||||
open PRs.
|
||||
2. **Contributor.** Default role for any authenticated account. A
|
||||
first OTC sign-in by a previously unknown email provisions a row
|
||||
at this role; v0.8.0 replaced the v0.3.0 / v0.7.0 allowlist gate
|
||||
with an admin-grant flow (roadmap item #6, see opening of §6).
|
||||
The contributor capabilities below — propose, branch, PR, chat,
|
||||
claim — are gated by `users.permission_state='granted'` as well
|
||||
as by the role. A pending contributor (the post-OTC waiting
|
||||
state) has the same read access as anonymous and zero write
|
||||
capability until an admin grants. Everything anonymous can do,
|
||||
plus: propose new RFCs (open a PR against the meta repo), create
|
||||
at this role; v0.7.0 keeps the v0.3.0 allowlist gate (`allowed_emails`)
|
||||
as the admission control, deferring the open beta-access request
|
||||
flow to a later release. Everything anonymous can do, plus:
|
||||
propose new RFCs (open a PR against the meta repo), create
|
||||
branches on any RFC repo, open PRs from branches they have
|
||||
contribute access to, chat on anything they can read, claim
|
||||
ownership of unclaimed super-drafts.
|
||||
@@ -467,38 +415,6 @@ triage what they can't act on, and the restore lands cleanly); a
|
||||
self-DND'd contributor's own gestures continue to fire signals to
|
||||
others normally.
|
||||
|
||||
v0.8.0 adds a fourth structurally-distinct field on the same row:
|
||||
`users.permission_state` (the admission gate the v0.8.0 release
|
||||
ships, see opening of §6 and §6.1). The four — role, muted,
|
||||
permission_state, the notification mutes — are orthogonal and the
|
||||
gate semantics compose:
|
||||
|
||||
* `role` answers "what scope of action is this user authorized to
|
||||
perform if they're admitted at all?" (anonymous / contributor /
|
||||
admin / owner).
|
||||
* `muted` answers "is this contributor write-restricted by an
|
||||
admin gesture against their existing grant?" (a sanctions
|
||||
primitive — owner/admin imposed).
|
||||
* `permission_state` answers "is this user admitted to the beta
|
||||
at all?" (the v0.8.0 admin-grant gate — 'pending' / 'granted' /
|
||||
'revoked'). The default for grandfathered rows at migration time
|
||||
is `'granted'`; OTC freshly provisions `'pending'`.
|
||||
* The notification mutes answer "does this user want to receive
|
||||
signals about a particular RFC or from a particular other
|
||||
user?" (self-imposed preference).
|
||||
|
||||
The four never gate each other. A pending user with `role=owner`
|
||||
(impossible by construction in v0.8.0 — fresh OTC always provisions
|
||||
role=contributor — but the orthogonality holds at the column level)
|
||||
would still refuse write endpoints because the admission gate
|
||||
runs first; a granted contributor whose row is also muted refuses
|
||||
writes via the mute gate; a granted contributor with notification
|
||||
mutes set still passes the contributor gate and writes normally.
|
||||
v0.6.0's anon-write audit (item #4) is the structural floor for all
|
||||
four — every write-shaped endpoint funnels through
|
||||
`auth.require_contributor`, which checks all three of {authenticated,
|
||||
not muted, permission_state='granted'} in order.
|
||||
|
||||
### 6.3 Per-RFC delegated authority
|
||||
|
||||
An RFC's `owners:` and `arbiters:` (from the meta-repo entry's
|
||||
@@ -1778,12 +1694,8 @@ them was the failure mode of generic-PR-comments-as-only-conversation.
|
||||
|
||||
Reads on the discussion surface follow §14 / the v0.3.0 anonymous-read
|
||||
contract: anyone can see the conversation. Writes require contributor
|
||||
role per §6.1: v0.5.0 implemented the gate on the three discussion
|
||||
write paths (POST threads, POST messages, POST resolve); v0.6.0 (item
|
||||
#4) audited the adjacent surfaces and added the matching test net
|
||||
(`test_anon_offlimits_vertical.py`) so a regression on any write
|
||||
endpoint is caught immediately. The gates use `auth.require_contributor`
|
||||
as the canonical helper. The notification routing reuses the
|
||||
role per §6.1 (v0.5.0 implements the gate; v0.6.0 — item #4 — hardens
|
||||
adjacent surfaces to match). The notification routing reuses the
|
||||
existing `chat_message_in_participated_thread` /
|
||||
`chat_reply_to_my_message` event kinds with `branch_name=null` on the
|
||||
fan-out row; the §15.7 reconciler and §15 inbox prose render
|
||||
@@ -2054,48 +1966,11 @@ with Gitea"; v0.7.0's email/OTC surface replaced that as the primary
|
||||
gesture, with a small "Sign in with Gitea (fallback)" link surviving
|
||||
on `/login` itself for the migration window.
|
||||
|
||||
`/login` itself is a stepped surface, driven by which auth path the
|
||||
viewer is currently on (§6):
|
||||
|
||||
1. **Email step.** The viewer enters their email. The frontend
|
||||
consults `GET /auth/passcode/check?email=…` to learn whether
|
||||
this email has a passcode set. The check endpoint is
|
||||
account-enumeration-safe — it returns `has_passcode: false` for
|
||||
both "unknown email" and "known email without passcode", so a
|
||||
probing client cannot distinguish the two from the response.
|
||||
2. **Either the passcode step or the OTC code step.** If the email
|
||||
has a passcode set, the viewer is asked for it (v0.10.0).
|
||||
Otherwise an OTC is dispatched and the viewer is asked for the
|
||||
six-digit code from their email (v0.7.0).
|
||||
3. **Optional post-OTC passcode-offer step.** After a successful
|
||||
OTC verify on an account with no passcode set, the surface
|
||||
asks "Set a passcode for faster sign-in next time?" — the user
|
||||
can dismiss the offer or set one inline. The skip-for-now path
|
||||
redirects straight to `/`.
|
||||
|
||||
The passcode step carries a "Use a code instead" link that
|
||||
re-dispatches an OTC and switches to the code step — the same path
|
||||
the lockout response (HTTP 423) takes automatically after five
|
||||
consecutive failed passcode verifies.
|
||||
|
||||
This is the front door. It sets expectation before the user encounters
|
||||
the mechanics, so the mechanics (super-drafts, graduation, public
|
||||
arguments, AI participation in chat) read as load-bearing rather than
|
||||
novel.
|
||||
|
||||
v0.8.0 (roadmap item #6) added a third sign-in step the surface
|
||||
runs conditionally — on the first OTC sign-in by a previously
|
||||
unknown email, the verify response carries `needs_profile=true`
|
||||
and the surface prompts for first name, last name, and a free-text
|
||||
"why I should be included in the beta" before bouncing the user to
|
||||
`/beta-pending`. The page displays a "your request is in review"
|
||||
message keyed on `users.permission_state='pending'` (repurposed
|
||||
from the v0.3.0 post-OAuth-rejection surface). Anonymous viewers
|
||||
and pending viewers see the same read surfaces; only the write
|
||||
affordances differ. A persistent thin "Your beta access is in
|
||||
review" banner shows on every page (other than `/beta-pending`
|
||||
itself) until an admin grants access.
|
||||
|
||||
### 14.2 The `/philosophy` route
|
||||
|
||||
Authenticated and anonymous visitors alike can reach `/philosophy`,
|
||||
@@ -2127,92 +2002,6 @@ The visual design of the landing page and the `/philosophy` route —
|
||||
typography, layout, illustrations if any — is deferred. The structural
|
||||
decisions above are the binding part.
|
||||
|
||||
### 14.5 Cookie / privacy consent (v0.13.0)
|
||||
|
||||
The framework ships a non-modal cookie consent banner reachable by
|
||||
every viewer — authenticated and anonymous alike. The banner appears
|
||||
at the bottom of the viewport on first load and stays visible until
|
||||
the user makes a choice, after which it hides and the choice is
|
||||
persisted. The `/settings/notifications` page carries a "Privacy &
|
||||
cookies" tab that surfaces the current choice and re-opens the banner
|
||||
on demand.
|
||||
|
||||
The choice has three categories, presented as a single-select:
|
||||
|
||||
- **Essential only** — the framework's strictly-necessary cookies
|
||||
(sign-in session, signed payloads, the consent-choice record
|
||||
itself). Always on; the user cannot switch this off because the
|
||||
app cannot function without it.
|
||||
- **Essential + analytics** — adds the optional analytics layer
|
||||
gated by this choice. As of v0.13.0 no analytics SDK ships in the
|
||||
framework; roadmap item #13 (v0.15.0) lands one behind this gate.
|
||||
Off by default — the user has to opt in.
|
||||
- **Essential + analytics + other** — adds third-party embeds or
|
||||
social widgets a deployment may configure. The framework ships no
|
||||
such cookies by default; this category exists so deployments that
|
||||
add them have a categorized opt-in to wire them behind.
|
||||
|
||||
Storage shape:
|
||||
|
||||
- **Anonymous viewer** — choice persists in `localStorage` only
|
||||
(`rfc-app.cookie-consent.v1`). The same browser carries the choice
|
||||
forward; a different browser, or cleared storage, re-prompts.
|
||||
- **Authenticated viewer** — choice persists in the `cookie_consent`
|
||||
row keyed by `user_id`. On sign-in, the server row (if present)
|
||||
overrides the local snapshot; if the server has no row, the local
|
||||
choice is uploaded.
|
||||
|
||||
The `essential` flag is permanently true at the API surface. The
|
||||
endpoint accepts it for symmetry but never persists a false value.
|
||||
A deployment that wants strictly-necessary cookies to be optional
|
||||
must change the framework contract, not flip a flag.
|
||||
|
||||
The framework exports a small JavaScript helper (`frontend/src/lib/
|
||||
consent.js`) for downstream surfaces:
|
||||
|
||||
- `getConsent()` — current snapshot.
|
||||
- `hasChosen()` — true once the user has made a choice.
|
||||
- `onConsentChange(cb)` — subscribe to updates.
|
||||
- `setConsent({analytics, other})` — record a new choice locally
|
||||
(the banner / settings surface handles server persistence on top).
|
||||
|
||||
Roadmap item #13's analytics SDK (v0.15.0) will read from this helper:
|
||||
read consent, then conditionally `import()` the SDK module. The gate
|
||||
is wired before the SDK lands so the contract is already in place.
|
||||
|
||||
### 14.6 Privacy and cookies policy pages
|
||||
|
||||
The framework ships two policy routes:
|
||||
|
||||
- `/privacy` — a minimal default privacy policy that describes the
|
||||
framework's stance (what is stored, why, how to revoke consent,
|
||||
how to reach the deployment operator). The page is reachable by
|
||||
anonymous and authenticated viewers alike.
|
||||
- `/cookies` — the framework's cookies policy, listing exactly which
|
||||
cookies the framework sets, by category. Self-documenting: a future
|
||||
framework release that adds or removes a cookie updates this page
|
||||
as part of the change.
|
||||
|
||||
Each page links to the other and to the §14.5 banner. The consent
|
||||
banner links to both.
|
||||
|
||||
Deployments override the policy content via two optional build-time
|
||||
env vars documented in `frontend/.env.example`:
|
||||
|
||||
- `VITE_PRIVACY_POLICY_URL` — an http(s) URL the `/privacy` page
|
||||
links to as the "full deployment policy". The framework's stub
|
||||
always renders above the link so the framework-level contract is
|
||||
always visible; the link layers deployment-specific content on
|
||||
top.
|
||||
- `VITE_COOKIES_POLICY_URL` — same shape for `/cookies`.
|
||||
|
||||
Both are optional. Unset is the supported default; the stub pages are
|
||||
sufficient for a default-config deployment that has nothing
|
||||
deployment-specific to add. The framework chose the env-var path over
|
||||
a content-repo file because it composes with the existing build-time
|
||||
config layer; the content-repo-file alternative is the §19.2
|
||||
candidate.
|
||||
|
||||
---
|
||||
|
||||
## 15. Notifications
|
||||
@@ -2732,87 +2521,25 @@ The follow-up session will refine this. A minimal starting set:
|
||||
- `POST /auth/otc/request` — unauthenticated. Body carries `email`.
|
||||
Generates a six-digit code, stores its bcrypt hash with an expiry
|
||||
(`OTC_TTL_MINUTES`, default 10), and dispatches a plain-text email
|
||||
via the SMTP layer. Returns HTTP 200 (`{ok:true}`) uniformly.
|
||||
Returns HTTP 429 when the per-email cooldown
|
||||
(`OTC_REQUEST_COOLDOWN_SECONDS`, default 60) blocks back-to-back
|
||||
requests — the loud-failure shape for the abuse path. A re-request
|
||||
invalidates the prior unused code for the same email so only one
|
||||
code is outstanding at a time. v0.7.0 also dropped requests
|
||||
silently if the email wasn't on the `allowed_emails` list (the
|
||||
v0.3.0 admission gate); v0.8.0 (item #6) removed that check —
|
||||
admission moved to `permission_state` on the freshly-provisioned
|
||||
`users` row, asserted at the contributor gate. v0.12.0 (item #10)
|
||||
gates this endpoint behind a CloudFlare Turnstile siteverify call:
|
||||
the body carries an optional `turnstile_token` field, the server
|
||||
POSTs `secret` + `response` to `challenges.cloudflare.com/turnstile/
|
||||
v0/siteverify` before the bcrypt hash + SMTP send, and a failed
|
||||
challenge refuses with HTTP 400 spending no rate budget. Two env
|
||||
vars drive the policy: `CLOUDFLARE_TURNSTILE_SECRET` (Secret
|
||||
Manager) and `TURNSTILE_REQUIRED` (overlay, default `false`). When
|
||||
the secret is unset and `TURNSTILE_REQUIRED=false`, the gate is
|
||||
open (the dev / pre-rollout path); when the secret is unset and
|
||||
`TURNSTILE_REQUIRED=true`, the endpoint refuses with HTTP 500
|
||||
"auth misconfigured" so a future config drift fails loudly
|
||||
instead of silently disabling abuse defense.
|
||||
via the SMTP layer. Returns HTTP 200 (`{ok:true}`) uniformly so
|
||||
allowlist state (§6.1 / §6.2) is not leaked to callers. Returns
|
||||
HTTP 429 when the per-email cooldown (`OTC_REQUEST_COOLDOWN_SECONDS`,
|
||||
default 60) blocks back-to-back requests — the loud-failure shape
|
||||
for the abuse path. A re-request invalidates the prior unused
|
||||
code for the same email so only one code is outstanding at a time.
|
||||
Per §19.2's expected next session, this endpoint is the lead-up
|
||||
to the Cloudflare-Turnstile abuse-mitigation overlay.
|
||||
- `POST /auth/otc/verify` — unauthenticated. Body carries `email` and
|
||||
`code`. Validates the bcrypt hash against the most-recent unconsumed
|
||||
non-expired row for the email, marks the row consumed, provisions
|
||||
or links the `users` row by email (per §6.2's migration path —
|
||||
match by `users.email` case-insensitive, otherwise insert a fresh
|
||||
contributor row with `gitea_id = NULL` and
|
||||
`permission_state='pending'`), and stores the session cookie.
|
||||
Returns HTTP 200 on success; the response body carries
|
||||
`{ok, user, needs_profile}` where `needs_profile=true` iff the
|
||||
user is `permission_state='pending'` AND the row has no
|
||||
first_name / last_name / beta_request_reason yet (a fresh OTC
|
||||
sign-in). The `needs_profile` flag drives the Login.jsx surface's
|
||||
step-3 capture form. HTTP 400 on any failure (expired, consumed,
|
||||
wrong, unknown). The failure modes collapse to a single generic
|
||||
message so a probing client cannot distinguish "you got the
|
||||
wrong code" from "we don't know this email" — the operator logs
|
||||
carry the distinction.
|
||||
- `POST /api/auth/me/beta-request` — authenticated. Body carries
|
||||
`first_name`, `last_name`, `beta_request_reason` (all required;
|
||||
bounded at 120 / 120 / 4000 chars). Writes the fields to the
|
||||
signed-in user's row and leaves `permission_state='pending'`.
|
||||
Idempotent for the same already-pending user (a re-submit
|
||||
updates the row so the admin sees the latest text). Refuses
|
||||
with HTTP 409 if the user is already `'granted'` or `'revoked'`.
|
||||
v0.8.0 — the first-OTC profile-capture endpoint (roadmap item
|
||||
#6). v0.9.0's admin user-management page consumes this column
|
||||
set to render the request queue.
|
||||
- `GET /auth/passcode/check` — unauthenticated. Query param `email`.
|
||||
Returns `{has_passcode: boolean}`. The frontend's `/login` surface
|
||||
calls this after the email step to decide whether to render a
|
||||
passcode input or fall back to OTC. The response carries only the
|
||||
boolean; lockout state, the bcrypt hash, and the `passcode_set_at`
|
||||
stamp are not leaked. An unknown email and a known-without-passcode
|
||||
email both return `false`, so the endpoint is enumeration-safe.
|
||||
- `POST /auth/passcode/set` — authenticated (any role). Body carries
|
||||
`passcode` (4–20 characters). bcrypt-hashes the passcode, writes
|
||||
`users.passcode_hash` + `users.passcode_set_at`, clears the failure
|
||||
counter and any active lockout. Refuses obvious patterns (a small
|
||||
denylist: `0000`, `1234`, `aaaa`, `password`, etc.) and length
|
||||
violations with HTTP 422. Replaces any prior passcode. v0.10.0.
|
||||
- `DELETE /auth/passcode` — authenticated. Clears
|
||||
`users.passcode_hash` and `users.passcode_set_at`, returning the
|
||||
user to OTC-only on next sign-in. v0.10.0.
|
||||
- `POST /auth/passcode/verify` — unauthenticated. Body carries
|
||||
`email` and `passcode`. Locates the user, checks the lockout
|
||||
window, and compares via bcrypt. On success: clears the failure
|
||||
counter, refreshes `last_seen_at`, stores the session cookie,
|
||||
returns HTTP 200 with the minimal user payload. On failure:
|
||||
increments `passcode_failed_attempts`. After five consecutive
|
||||
failures, stamps `passcode_locked_until = now + 15 minutes` and
|
||||
returns HTTP 423 with a `locked_until` field; subsequent attempts
|
||||
inside the window are refused with the same shape. After the
|
||||
window expires, the next attempt clears the counter and proceeds
|
||||
normally. The OTC path (§17 above) is unaffected by the passcode
|
||||
lockout — a locked-out user can still request and verify a fresh
|
||||
OTC. The wrong-passcode and unknown-email failure modes both
|
||||
return HTTP 400 with a generic message; the no-passcode-set
|
||||
failure also collapses to 400 so the response does not enumerate
|
||||
account state. v0.10.0.
|
||||
contributor row with `gitea_id = NULL`), and stores the session
|
||||
cookie. Returns HTTP 200 on success with a minimal user payload;
|
||||
HTTP 400 on any failure (expired, consumed, wrong, unknown). The
|
||||
failure modes collapse to a single generic message so a probing
|
||||
client cannot distinguish "you got the wrong code" from "we don't
|
||||
know this email" — the operator logs carry the distinction.
|
||||
- `GET /api/rfcs` — list entries with state, id, title, slug, repo,
|
||||
owners, last_active_at, has_open_prs, starred-by-me. Supports
|
||||
search, sort, filter chips, and the `unclaimed` predicate.
|
||||
@@ -2868,13 +2595,7 @@ The follow-up session will refine this. A minimal starting set:
|
||||
trailing `rollback` step's events if any earlier step fails. The
|
||||
Graduate dialog opens this stream on confirm and renders the step
|
||||
stack from the events. The stream closes on success or on
|
||||
rollback completion. Requires `auth.require_user` per v0.6.0
|
||||
(item #4): the step detail (repo name, PR number, rollback steps)
|
||||
is operator-visible state and isn't part of the v0.3.0
|
||||
anonymous-read contract for catalog and RFC bodies. The floor is
|
||||
`require_user` (not `require_contributor`) so a write-muted
|
||||
operator can still observe a graduation they kicked off before
|
||||
being muted.
|
||||
rollback completion.
|
||||
- `GET /api/rfcs/<slug>/blocking-prs` — list open meta-repo PRs
|
||||
against `rfcs/<slug>.md` per §13.2's precondition popover. Returns
|
||||
PR number, title, author, last-activity timestamp, and the
|
||||
@@ -3046,16 +2767,6 @@ The follow-up session will refine this. A minimal starting set:
|
||||
short confirmation page.
|
||||
- `POST /api/webhooks/email-bounce` — bounce and complaint receiver
|
||||
per §15.4; sets the recipient's global email opt-out.
|
||||
- `GET /api/users/me/cookie-consent` — read the signed-in user's
|
||||
cookie consent record per §14.5. Returns `{essential, analytics,
|
||||
other, recorded_at}`. `recorded_at: null` means "no choice yet"
|
||||
and the banner should be shown; the framework treats absence of a
|
||||
row as equivalent to that. `essential` is permanently true.
|
||||
- `PUT /api/users/me/cookie-consent` — write the signed-in user's
|
||||
cookie consent record per §14.5. Body: `{essential, analytics,
|
||||
other}`. The `essential` flag is accepted for symmetry but always
|
||||
persisted as true. Upserts (a single row per user) and stamps
|
||||
`recorded_at` to now.
|
||||
|
||||
Plus all the chat / streaming / model-picker endpoints, scoped to
|
||||
per-RFC and per-branch threads.
|
||||
@@ -3788,72 +3499,23 @@ the new §15 (Notifications, in full), and §17 (the notification
|
||||
endpoints — list, mark-read, stream, watch mutation, preferences,
|
||||
quiet-hours, per-user mute, unsubscribe, bounce webhook).
|
||||
|
||||
First-OTC profile capture (formerly a v0.7.0 candidate) is settled
|
||||
and folded into §6.1 (the contributor role now requires
|
||||
`permission_state='granted'`), §6.2 (the orthogonality of
|
||||
permission_state vs role / muted / notification-mutes), §14.1
|
||||
(the landing page's v0.8.0 first-OTC capture step), and §17
|
||||
(the `POST /api/auth/me/beta-request` endpoint and the verify
|
||||
endpoint's new `needs_profile` flag). The structural decision
|
||||
landed as: capture is a third step on the `/login` surface
|
||||
gated by `verify_response.needs_profile=true`; pending users
|
||||
land on `/beta-pending` after submitting and see a thin banner
|
||||
on every other page until an admin grants. v0.8.0 (roadmap item
|
||||
#6) shipped the work.
|
||||
|
||||
Candidates surfaced during v0.8.0 (open beta-access request flow,
|
||||
§6.1 / §14.1, item #6):
|
||||
|
||||
- **Admin user-management page** (`/admin/users`). *Surfaced by
|
||||
v0.8.0 — the release ships the pending-state column but no
|
||||
admin UI to triage it.* For v0.8.0, the admin gesture is an
|
||||
out-of-band `UPDATE users SET permission_state='granted' WHERE
|
||||
email=?`. v0.9.0 (roadmap item #7) is expected to ship the
|
||||
triage queue: a list of `permission_state='pending'` rows
|
||||
sorted by `created_at`, each showing the captured first /
|
||||
last / why fields, with Grant and Revoke buttons that stamp
|
||||
`permission_decided_by` and `permission_decided_at` (schema
|
||||
slots already in place per `migrations/014_beta_access.sql`).
|
||||
The page composes naturally with the existing `/admin/allowlist`
|
||||
surface — both are admission-control gestures — so v0.9.0 may
|
||||
fold the allowlist UI into this page (see next candidate).
|
||||
Decision points: do grants / revokes also fire email
|
||||
notifications to the user (probably yes — the notifications
|
||||
layer from v0.6.0 has the personal-direct channel for it); is
|
||||
there a "decline with reason" gesture that surfaces in the
|
||||
user's view (probably yes — symmetric with §9.3's
|
||||
proposal-decline shape); does the page support bulk grants
|
||||
(probably no for v0.9.0 — the queue volume is operator-scale,
|
||||
not user-scale). Earns its session as the v0.9.0 design pass.
|
||||
- **Allowlist deprecation.** *Surfaced by v0.8.0 — the
|
||||
`allowed_emails` table stays in the schema but the OTC
|
||||
request path no longer consults it.* v0.8.0 left the table
|
||||
and the `/admin/allowlist` UI in place as a fast-path bypass
|
||||
for deployments that want to pre-mark known-good emails (the
|
||||
v0.8.0 contract is that those emails still go through the
|
||||
pending-grant flow; the table itself is no longer a gate). A
|
||||
future release retires both — probably v0.9.0 alongside the
|
||||
admin user-management page, since the two surfaces are
|
||||
functionally redundant once the pending queue lands.
|
||||
Decision points: drop the table outright (a schema migration)
|
||||
or leave it as a non-functional surface and remove only the
|
||||
UI (a frontend-only change); how to handle existing
|
||||
`allowed_emails` rows at the cutover (probably: walk them
|
||||
into the pending queue with `permission_state='granted'` for
|
||||
any matching `users` row, leave unmatched rows as a no-op
|
||||
since v0.8.0 doesn't consult them anymore). Earns its
|
||||
session as a sub-topic of the v0.9.0 admin user-management
|
||||
pass.
|
||||
- **Admin notification on new beta request.** *Surfaced by
|
||||
v0.8.0 — the capture endpoint writes to the row but does
|
||||
not signal admins.* v0.9.0 candidate (item #7 again): when a
|
||||
`POST /api/auth/me/beta-request` lands, fire an `admin-actionable`
|
||||
notification (per §15.4's category set) to every owner / admin
|
||||
so the queue doesn't go stale. The §15 infrastructure already
|
||||
supports the category; the open question is whether the
|
||||
notification is per-request (one email per submission) or
|
||||
digested (a daily summary). Earns its session alongside the
|
||||
admin user-management page.
|
||||
- **First-OTC profile capture.** *Surfaced by v0.7.0's email/OTC
|
||||
migration.* When a fresh email lands at `/auth/otc/verify` with
|
||||
no matching `users.email` row, v0.7.0 provisions the row with
|
||||
`display_name = <local part of email>` and no other identity
|
||||
fields. A subsequent release (the roadmap item-#6 candidate)
|
||||
is expected to add a one-shot profile-capture step on the
|
||||
first-OTC sign-in: first name, last name, and a free-text
|
||||
"why I want access" field that flows into the open beta-access
|
||||
request queue (also item #6) that replaces the v0.3.0
|
||||
`allowed_emails` gate. The schema slot exists implicitly already
|
||||
(`users.display_name` is updateable, the audit-log + permission-
|
||||
events tables carry the freeform notes); the structural decision
|
||||
is what gates the capture (modal on `/login` after verify? a
|
||||
one-time redirect to `/welcome/profile`? a deferred banner on
|
||||
the main view?) and how it interacts with the open-access
|
||||
request flow that replaces the allowlist. Earns its session as
|
||||
the v0.8.0 design pass.
|
||||
- **Removing the Gitea OAuth fallback.** *Surfaced by v0.7.0.*
|
||||
v0.7.0 keeps `/auth/callback` functional and links to it as a
|
||||
"Sign in with Gitea (fallback)" affordance on the new `/login`
|
||||
@@ -3870,117 +3532,34 @@ Candidates surfaced during v0.8.0 (open beta-access request flow,
|
||||
its session once the OTC adoption curve flattens.
|
||||
- **Device trust (30-day skip).** *Surfaced by v0.7.0 — the
|
||||
signed-in cookie already lasts 30 days via SessionMiddleware,
|
||||
but every sign-in still requires a fresh OTC or passcode.* The
|
||||
roadmap item-#9 candidate adds a "trust this device" affordance
|
||||
on the verify step that issues a longer-lived rotating token,
|
||||
so returning visitors on the same device skip both the OTC and
|
||||
the passcode step. The shape question is whether the trust is a
|
||||
signed cookie distinct from the session, a row in a `device_trust`
|
||||
table keyed by a random device-id, or a property of the session
|
||||
itself; and whether the trust survives password-equivalent events
|
||||
— v0.10.0's passcode-change and passcode-clear gestures are the
|
||||
v1 instances — or only survives explicit logout. Earns its
|
||||
session as the v0.11.0 design pass.
|
||||
- **Cloudflare Turnstile on `/auth/otc/request`.** *Settled in
|
||||
v0.12.0 (roadmap item #10).* The OTC request endpoint is now
|
||||
gated behind a Turnstile siteverify call: the frontend renders
|
||||
the official widget on the `/login` email-entry step (and on the
|
||||
passcode step for the "Use a code instead" fallback dispatch),
|
||||
the captured token rides in the request body as
|
||||
`turnstile_token`, and the backend POSTs `secret` + `response`
|
||||
to `challenges.cloudflare.com/turnstile/v0/siteverify` before
|
||||
the bcrypt hash + SMTP send. The widget renders only on the
|
||||
email-entry / passcode-fallback dispatch points — the OTC
|
||||
verify step is bottlenecked on email delivery and protected by
|
||||
the five-minute TTL + single-use row consume, so a second
|
||||
challenge there would double the rate budget against the same
|
||||
abuse path without measurably more protection (revisit if bots
|
||||
adapt to the email-entry challenge specifically). Configured
|
||||
via `CLOUDFLARE_TURNSTILE_SECRET` (Secret Manager) and
|
||||
`VITE_TURNSTILE_SITE_KEY` (frontend build-time overlay); a
|
||||
third var `TURNSTILE_REQUIRED` (default `false`) lets the
|
||||
operator flip from "soft-fail when secret unset" (the dev /
|
||||
pre-rollout shape) to "fail-closed when secret unset" (HTTP 500
|
||||
"auth misconfigured", the production-locked shape). Tests mock
|
||||
the siteverify HTTP call at the `httpx.post` boundary in
|
||||
`app.turnstile`. The hCaptcha / reCAPTCHA alternatives noted in
|
||||
the v0.7.0 surfacing are still viable substitutes for a future
|
||||
deployment that wants them but the framework's tested path is
|
||||
Turnstile.
|
||||
|
||||
Candidates surfaced during v0.10.0 (user-set passcodes, §6.2 /
|
||||
roadmap item #8):
|
||||
|
||||
- **Passcode policy tunables via env.** v0.10.0 hard-codes the
|
||||
lockout shape (5 consecutive failures → 15-minute lockout) and
|
||||
the min/max passcode length (4 / 20) in
|
||||
`backend/app/passcode.py`. The denylist of obvious patterns is
|
||||
also hard-coded. A deployment that wants tighter or looser rules
|
||||
has to fork the constants. Two env vars
|
||||
(`PASSCODE_LOCKOUT_AFTER_ATTEMPTS`,
|
||||
`PASSCODE_LOCKOUT_DURATION_MINUTES`) would let operators
|
||||
reshape the lockout without forking; a third
|
||||
(`PASSCODE_MIN_LENGTH`) would cover the length floor. Earns its
|
||||
session if a deployment surfaces evidence that the v1 defaults
|
||||
bite.
|
||||
- **Per-IP rate-limiting on `/auth/passcode/verify`.** v0.10.0's
|
||||
lockout is per-account: 5 failures against the same email lock
|
||||
that account for 15 minutes. A distributed attacker that knows
|
||||
many emails can fan out across them without ever tripping any
|
||||
one account's lockout. Adding a per-IP throttle (e.g., 30
|
||||
passcode-verify attempts / minute / IP, returning HTTP 429) is
|
||||
the natural pairing. Defer-able — the per-account lockout is
|
||||
the v1 shape that closes the loud-loop case; the per-IP
|
||||
distributed case waits on evidence. Touches §6.2 and §17.
|
||||
- **Passcode-change "still know your old passcode" challenge.**
|
||||
v0.10.0 lets a signed-in user replace their passcode from
|
||||
`/settings/notifications` without re-entering the old one — the
|
||||
session is sufficient. A future hardening pass may require the
|
||||
old passcode (or a fresh OTC verify) before accepting the
|
||||
change, to mitigate session-hijack scenarios where the attacker
|
||||
rotates the passcode to lock the legitimate owner out. The same
|
||||
question applies to the clear gesture. Earns its session if
|
||||
session-hijack becomes a real threat surface.
|
||||
- **Passkey / WebAuthn.** A much heavier next step than passcodes:
|
||||
hardware-backed device credentials that resist phishing. The
|
||||
v0.10.0 passcode shape is a stopgap for the "I'd rather not
|
||||
type a code every time" ergonomic problem; passkeys are the
|
||||
long-term answer. Out of scope for the current roadmap —
|
||||
earns a dedicated session if/when the deployment grows enough
|
||||
that the phishing surface justifies the integration cost.
|
||||
|
||||
Candidates surfaced during v0.13.0 (cookie / privacy consent, §14.5
|
||||
and §14.6):
|
||||
|
||||
- **Policy content via content-repo file vs env var.** v0.13.0
|
||||
shipped the deployment-policy-override path as two env vars
|
||||
(`VITE_PRIVACY_POLICY_URL`, `VITE_COOKIES_POLICY_URL`) that the
|
||||
framework's stub pages link out to. The alternative — accepting
|
||||
a markdown file path the framework renders inline, parallel to
|
||||
`PHILOSOPHY_PATH` per §14.2 — was deferred. The two compose:
|
||||
a deployment could carry both an inline file (rendered above
|
||||
the fold) and an external link (rendered below). Earns its own
|
||||
topic when a real deployment ships a policy long enough that
|
||||
the link-out shape bites and renders the link unread.
|
||||
- **Global Privacy Control / Do-Not-Track headers.** v0.13.0
|
||||
scoped the consent surface to the in-app banner and did not
|
||||
honor browser-side GPC or DNT signals. The framework's stance
|
||||
is that the in-app banner is the authoritative gesture — a
|
||||
user who clears their consent in the banner has expressed
|
||||
intent, and the GPC header is a coarser signal layered on top.
|
||||
Earns its own topic if a regulatory regime emerges that treats
|
||||
GPC as the legally-binding gesture, in which case the framework
|
||||
would honor GPC as an automatic "essential only" choice unless
|
||||
the user explicitly broadened it in-app.
|
||||
- **Multi-language consent text.** The banner ships English-only.
|
||||
i18n of the framework's user-facing strings is a broader topic
|
||||
than the consent banner; carrying the work in that future topic
|
||||
rather than as a per-surface translation pass.
|
||||
- **Analytics SDK gating against `consent.js`.** Roadmap item #13
|
||||
(target v0.15.0) lands the analytics SDK behind
|
||||
`lib/consent.js`'s `getConsent().analytics` gate. The framework
|
||||
contract is already in place; the SDK integration is the work
|
||||
the item ships. Listed here so the dependency is documented.
|
||||
but every sign-in still requires a fresh OTC.* The roadmap
|
||||
item-#9 candidate adds a "trust this device" affordance on the
|
||||
verify step that issues a longer-lived rotating token, so
|
||||
returning visitors on the same device skip the OTC step. The
|
||||
shape question is whether the trust is a signed cookie distinct
|
||||
from the session, a row in a `device_trust` table keyed by a
|
||||
random device-id, or a property of the session itself; and
|
||||
whether the trust survives password-equivalent events (none
|
||||
exist yet — passcodes are item #8 / v0.10.0) or only survives
|
||||
explicit logout. Earns its session as the v0.11.0 design pass.
|
||||
- **Cloudflare Turnstile (or equivalent) on `/auth/otc/request`.**
|
||||
*Surfaced by v0.7.0 — the endpoint is now the new abuse hot
|
||||
path.* Per-email cooldown stops the trivial loop; what it
|
||||
doesn't stop is a distributed scrape that fans out across a
|
||||
large invitee list to harvest the "this email is admitted vs.
|
||||
this email is not" signal indirectly (timing differences, SMTP
|
||||
bounce-rate observation). The roadmap item-#10 candidate gates
|
||||
the request endpoint behind a one-step browser-side challenge
|
||||
before the bcrypt hash + SMTP send. Open questions: which
|
||||
provider (Turnstile is the default since it's free and
|
||||
privacy-respecting; hCaptcha and reCAPTCHA are also viable);
|
||||
how the deployment configures it (`TURNSTILE_SITE_KEY` +
|
||||
`TURNSTILE_SECRET_KEY` env vars, gated by `if
|
||||
config.turnstile_site_key:` at the handler so existing
|
||||
deployments don't break); whether the verify endpoint also
|
||||
gets a challenge (probably yes for parity); and how the test
|
||||
harness mocks the challenge. Earns its session as the v0.12.0
|
||||
design pass.
|
||||
|
||||
### 19.3 Working agreement for the queue
|
||||
|
||||
|
||||
@@ -92,21 +92,3 @@ OTC_TTL_MINUTES=10
|
||||
# loud-failure shape so the abuse path is visible). Set to 0 to
|
||||
# disable the cooldown — useful for tests but never in production.
|
||||
OTC_REQUEST_COOLDOWN_SECONDS=60
|
||||
|
||||
# --- v0.12.0: CloudFlare Turnstile gate on OTC dispatch (§6.2, item #10) ---
|
||||
# Provision a Turnstile site at dash.cloudflare.com → Turnstile → Add
|
||||
# site. The site key (public) goes in `frontend/.env` as
|
||||
# VITE_TURNSTILE_SITE_KEY. The secret key (private) goes here and is
|
||||
# what the backend POSTs to /siteverify alongside the user's response
|
||||
# token. Leave both unset for dev/test paths; the gate stays open when
|
||||
# the secret is absent AND TURNSTILE_REQUIRED=false (the default).
|
||||
CLOUDFLARE_TURNSTILE_SECRET=
|
||||
|
||||
# When `true`, /auth/otc/request fails closed (HTTP 500 "auth
|
||||
# misconfigured") if CLOUDFLARE_TURNSTILE_SECRET is unset. When `false`
|
||||
# (the default), a missing secret skips verification — useful in dev
|
||||
# and during the pre-rollout window when the operator hasn't wired
|
||||
# the secret yet. Flip to `true` once the secret is wired so a future
|
||||
# config drift surfaces as a loud 500 rather than a silent abuse-
|
||||
# defense disablement.
|
||||
TURNSTILE_REQUIRED=false
|
||||
|
||||
@@ -26,7 +26,6 @@ from . import (
|
||||
api_prs,
|
||||
auth,
|
||||
db,
|
||||
docs as docs_mod,
|
||||
entry as entry_mod,
|
||||
cache,
|
||||
funder,
|
||||
@@ -56,17 +55,6 @@ class FunderCredentialBody(BaseModel):
|
||||
api_key: str = Field(min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class BetaRequestBody(BaseModel):
|
||||
# v0.8.0 — captured on the first OTC sign-in. All three fields are
|
||||
# required so the admin queue has a coherent triage shape.
|
||||
# The bounds match the v0.7.0 OTC body (320 chars for email-ish
|
||||
# headers; 4000 for the free-text reason — the same upper bound
|
||||
# DeclineBody uses elsewhere in this file).
|
||||
first_name: str = Field(min_length=1, max_length=120)
|
||||
last_name: str = Field(min_length=1, max_length=120)
|
||||
beta_request_reason: str = Field(min_length=1, max_length=4000)
|
||||
|
||||
|
||||
def make_router(
|
||||
config: Config,
|
||||
gitea: Gitea,
|
||||
@@ -123,17 +111,6 @@ def make_router(
|
||||
payload = philosophy.load()
|
||||
return {"body": payload["body"]}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# /api/docs — DOCS.md served verbatim. Sibling of /api/philosophy:
|
||||
# no auth gate, same disk-first load + cache shape, same intent —
|
||||
# public read surface for a markdown file checked into the repo.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/docs")
|
||||
async def get_docs() -> dict[str, Any]:
|
||||
payload = docs_mod.load()
|
||||
return {"body": payload["body"]}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Auth surface — reads role from our users table per §6.
|
||||
# ---------------------------------------------------------------
|
||||
@@ -143,29 +120,6 @@ def make_router(
|
||||
user = auth.current_user(request)
|
||||
if user is None:
|
||||
return {"authenticated": False, "user": None}
|
||||
# v0.8.0 + v0.10.0: single round-trip for everything the
|
||||
# frontend gates UI off of — beta-access state + passcode state.
|
||||
row = db.conn().execute(
|
||||
"SELECT first_name, last_name, beta_request_reason, "
|
||||
"passcode_hash, passcode_set_at "
|
||||
"FROM users WHERE id = ?",
|
||||
(user.user_id,),
|
||||
).fetchone()
|
||||
first_name = (row["first_name"] if row else None) or ""
|
||||
last_name = (row["last_name"] if row else None) or ""
|
||||
beta_request_reason = (row["beta_request_reason"] if row else None) or ""
|
||||
# "Needs profile" iff the user is pending AND hasn't yet
|
||||
# filed their beta-request capture. Granted users never see
|
||||
# the capture prompt; pending users who already filed see
|
||||
# the /beta-pending page without the capture form.
|
||||
needs_profile = (
|
||||
user.permission_state == "pending"
|
||||
and not first_name
|
||||
and not last_name
|
||||
and not beta_request_reason
|
||||
)
|
||||
has_passcode = bool(row and row["passcode_hash"])
|
||||
passcode_set_at = row["passcode_set_at"] if (row and has_passcode) else None
|
||||
return {
|
||||
"authenticated": True,
|
||||
"user": {
|
||||
@@ -175,70 +129,9 @@ def make_router(
|
||||
"email": user.email,
|
||||
"avatar_url": user.avatar_url,
|
||||
"role": user.role,
|
||||
"permission_state": user.permission_state,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"beta_request_reason": beta_request_reason,
|
||||
"needs_profile": needs_profile,
|
||||
"has_passcode": has_passcode,
|
||||
"passcode_set_at": passcode_set_at,
|
||||
},
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.8.0: /api/auth/me/beta-request — first-OTC profile capture
|
||||
# (roadmap item #6). Lands first name, last name, and the free-
|
||||
# text "why I should be included in the beta" on the signed-in
|
||||
# user's row. Idempotent for the same already-pending user;
|
||||
# refuses to overwrite a row that's already granted (so a
|
||||
# bored already-granted user can't accidentally re-submit the
|
||||
# form and clobber the admin's audit trail). Uses
|
||||
# `require_user` rather than `require_contributor` because
|
||||
# `require_contributor` already enforces `permission_state =
|
||||
# 'granted'` and would refuse a pending user; the whole point
|
||||
# of this endpoint is to register the request _from_ a pending
|
||||
# user.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/auth/me/beta-request")
|
||||
async def submit_beta_request(body: BetaRequestBody, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_user(request)
|
||||
row = db.conn().execute(
|
||||
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE id = ?",
|
||||
(user.user_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
# Defensive — the session pointed at a deleted row.
|
||||
raise HTTPException(404, "User not found")
|
||||
# Granted users have no business filing a beta request.
|
||||
# 'revoked' likewise — the request flow is for fresh users
|
||||
# only. Both shapes refuse with 409 (conflict) so the client
|
||||
# can distinguish "you already have access" from
|
||||
# "your access was revoked".
|
||||
if row["permission_state"] == "granted":
|
||||
raise HTTPException(409, "Your account is already granted access")
|
||||
if row["permission_state"] == "revoked":
|
||||
raise HTTPException(409, "Your account's access has been revoked")
|
||||
# Re-submission from a pending user updates the row — the
|
||||
# admin sees the latest text rather than a stale draft.
|
||||
# The state stays 'pending'; only an admin can flip it.
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET first_name = ?,
|
||||
last_name = ?,
|
||||
beta_request_reason = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
body.first_name.strip(),
|
||||
body.last_name.strip(),
|
||||
body.beta_request_reason.strip(),
|
||||
user.user_id,
|
||||
),
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §7: the catalog
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@@ -520,16 +520,7 @@ def make_router(
|
||||
|
||||
@router.get("/api/rfcs/{slug}/graduate/progress")
|
||||
async def graduate_progress(slug: str, request: Request):
|
||||
# v0.6.0 (item #4): the progress SSE surfaces admin-internal step
|
||||
# detail (repo name, PR number, rollback steps) that isn't part of
|
||||
# the v0.3.0 anonymous-read contract for catalog/RFC bodies. The
|
||||
# corresponding POST /graduate is gated to RFC owners/arbiters and
|
||||
# app admins/owners via `_can_graduate`; the read SSE shares that
|
||||
# operator-visible surface, so it requires at least an
|
||||
# authenticated viewer. We keep the floor at require_user (not
|
||||
# require_contributor) so a write-muted operator can still observe
|
||||
# the progress of a graduation they kicked off before being muted.
|
||||
auth.require_user(request)
|
||||
del request
|
||||
state = _get_active(slug)
|
||||
if state is None:
|
||||
raise HTTPException(404, "No graduation in flight for this slug")
|
||||
|
||||
@@ -14,8 +14,6 @@ The endpoints in this module are:
|
||||
- `POST /api/users/me/quiet-hours` — set / clear
|
||||
- `POST /api/users/<id>/notification-mute` — §15.8
|
||||
- `DELETE /api/users/<id>/notification-mute` — §15.8
|
||||
- `GET /api/users/me/cookie-consent` — §14.5
|
||||
- `PUT /api/users/me/cookie-consent` — §14.5
|
||||
- `GET /api/email/unsubscribe` — §15.4 one-click
|
||||
- `POST /api/webhooks/email-bounce` — §15.4 receiver
|
||||
|
||||
@@ -75,15 +73,6 @@ class BounceBody(BaseModel):
|
||||
kind: str = Field(default="hard") # 'hard' or 'complaint'
|
||||
|
||||
|
||||
class CookieConsentBody(BaseModel):
|
||||
# `essential` is always true at the surface; we accept it for symmetry
|
||||
# but never persist a false value (the framework's strictly-necessary
|
||||
# cookies are not user-optional per SPEC §14.5).
|
||||
essential: bool = True
|
||||
analytics: bool = False
|
||||
other: bool = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -373,74 +362,6 @@ def make_router(config: Config) -> APIRouter:
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
# ----- Cookie consent (v0.13.0 / roadmap item #11; SPEC §14.5) -----
|
||||
#
|
||||
# The shape is intentionally small: three flags + a recorded-at stamp.
|
||||
# The banner's local-vs-server precedence rule lives in the frontend
|
||||
# (`consent.js`): on sign-in, the server row (if any) overrides local;
|
||||
# otherwise local is uploaded.
|
||||
|
||||
@router.get("/api/users/me/cookie-consent")
|
||||
async def get_cookie_consent(request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT essential, analytics, other_cookies, recorded_at
|
||||
FROM cookie_consent WHERE user_id = ?
|
||||
""",
|
||||
(viewer.user_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return {
|
||||
"essential": True,
|
||||
"analytics": False,
|
||||
"other": False,
|
||||
"recorded_at": None,
|
||||
}
|
||||
return {
|
||||
"essential": bool(row["essential"]),
|
||||
"analytics": bool(row["analytics"]),
|
||||
"other": bool(row["other_cookies"]),
|
||||
"recorded_at": row["recorded_at"],
|
||||
}
|
||||
|
||||
@router.put("/api/users/me/cookie-consent")
|
||||
async def set_cookie_consent(body: CookieConsentBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
# `essential` is the framework's strictly-necessary set; the
|
||||
# surface accepts the flag for symmetry but never persists a
|
||||
# false value. SPEC §14.5: a deployment that wants to make
|
||||
# session-cookie storage optional must change the framework
|
||||
# contract, not flip a flag here.
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO cookie_consent
|
||||
(user_id, essential, analytics, other_cookies, recorded_at)
|
||||
VALUES (?, 1, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
essential = 1,
|
||||
analytics = excluded.analytics,
|
||||
other_cookies = excluded.other_cookies,
|
||||
recorded_at = excluded.recorded_at
|
||||
""",
|
||||
(
|
||||
viewer.user_id,
|
||||
1 if body.analytics else 0,
|
||||
1 if body.other else 0,
|
||||
),
|
||||
)
|
||||
row = db.conn().execute(
|
||||
"SELECT recorded_at FROM cookie_consent WHERE user_id = ?",
|
||||
(viewer.user_id,),
|
||||
).fetchone()
|
||||
return {
|
||||
"ok": True,
|
||||
"essential": True,
|
||||
"analytics": bool(body.analytics),
|
||||
"other": bool(body.other),
|
||||
"recorded_at": row["recorded_at"] if row else None,
|
||||
}
|
||||
|
||||
# ----- Email: one-click unsubscribe + bounce webhook -----
|
||||
|
||||
@router.get("/api/email/unsubscribe")
|
||||
|
||||
+4
-60
@@ -30,12 +30,6 @@ class SessionUser:
|
||||
email: str
|
||||
avatar_url: str
|
||||
role: str
|
||||
# v0.8.0 / §6.1 — admission gate. Three states: 'pending' (waiting
|
||||
# for an admin grant), 'granted' (active contributor), 'revoked'
|
||||
# (was granted, later removed). Existing rows at migration time
|
||||
# default to 'granted' so grandfathered users are unaffected; OTC
|
||||
# provisions fresh users with 'pending' (see `app/otc.py`).
|
||||
permission_state: str = "granted"
|
||||
|
||||
def as_actor(self) -> Actor:
|
||||
return Actor(
|
||||
@@ -96,13 +90,6 @@ def allowlist_is_active() -> bool:
|
||||
def is_allowed_sign_in(profile: dict[str, Any]) -> bool:
|
||||
"""Decide whether a freshly-completed OAuth profile may sign in.
|
||||
|
||||
v0.8.0 (item #6) replaces the allowlist gate with an admin-grant
|
||||
flow at the OTC `/request` surface, but the Gitea OAuth callback
|
||||
in `main.py` still consults this helper so the fallback path
|
||||
keeps the v0.3.0 admission shape during the OAuth migration
|
||||
window. The eventual removal of the OAuth callback (§19.2)
|
||||
retires this function alongside it.
|
||||
|
||||
Three accept paths:
|
||||
1. The allowlist is empty (gate off).
|
||||
2. The Gitea profile's email is in `allowed_emails` (case-insensitive).
|
||||
@@ -145,27 +132,17 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
|
||||
existing = c.execute("SELECT * FROM users WHERE gitea_id = ?", (gitea_id,)).fetchone()
|
||||
if existing is None:
|
||||
role = "owner" if config.owner_gitea_login and login == config.owner_gitea_login else "contributor"
|
||||
# v0.8.0: a fresh OAuth-provisioned user is also subject to
|
||||
# the admin-grant flow. The OAuth fallback only fires for
|
||||
# users who pass `is_allowed_sign_in` (so they're already on
|
||||
# the legacy allowlist or are grandfathered by gitea_id);
|
||||
# 'granted' is the right default here since the allowlist
|
||||
# check is itself the admin gesture. A future release that
|
||||
# retires the OAuth callback (§19.2) collapses both paths
|
||||
# under the same gate.
|
||||
cur = c.execute(
|
||||
"""
|
||||
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'granted')
|
||||
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(gitea_id, login, email, display, avatar, role),
|
||||
)
|
||||
user_id = cur.lastrowid
|
||||
permission_state = "granted"
|
||||
else:
|
||||
user_id = existing["id"]
|
||||
role = existing["role"]
|
||||
permission_state = existing["permission_state"] or "granted"
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
@@ -183,7 +160,6 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
|
||||
email=email,
|
||||
avatar_url=avatar,
|
||||
role=role,
|
||||
permission_state=permission_state,
|
||||
)
|
||||
|
||||
|
||||
@@ -202,12 +178,6 @@ def store_session(request: Request, user: SessionUser) -> None:
|
||||
"email": user.email,
|
||||
"avatar_url": user.avatar_url,
|
||||
"role": user.role,
|
||||
# v0.8.0: persist the admission state on the cookie payload so
|
||||
# the post-cookie audit doesn't second-guess the row. The DB
|
||||
# is re-read on every `current_user` call regardless (so an
|
||||
# admin grant takes effect on the next request); this field
|
||||
# is purely structural redundancy for the cookie shape.
|
||||
"permission_state": user.permission_state,
|
||||
}
|
||||
|
||||
|
||||
@@ -218,7 +188,7 @@ def current_user(request: Request) -> SessionUser | None:
|
||||
# Re-read the role from the database every request so role changes
|
||||
# take effect on the next API call without forcing a logout.
|
||||
row = db.conn().execute(
|
||||
"SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state FROM users WHERE id = ?",
|
||||
"SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role FROM users WHERE id = ?",
|
||||
(raw["user_id"],),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
@@ -229,11 +199,6 @@ def current_user(request: Request) -> SessionUser | None:
|
||||
# of which sign-in path the row came from. The DB remains the
|
||||
# source of truth for "is this an OAuth-linked user" (gitea_id IS
|
||||
# NOT NULL); the in-memory SessionUser is the per-request handle.
|
||||
# v0.8.0: permission_state comes off the row directly. A NULL
|
||||
# column value (shouldn't happen under the migration's
|
||||
# NOT NULL DEFAULT, but be defensive) reads as 'granted' so the
|
||||
# gate fails open for grandfathered surfaces rather than locking
|
||||
# everyone out on a malformed row.
|
||||
return SessionUser(
|
||||
user_id=row["id"],
|
||||
gitea_id=row["gitea_id"] or 0,
|
||||
@@ -242,7 +207,6 @@ def current_user(request: Request) -> SessionUser | None:
|
||||
email=row["email"] or "",
|
||||
avatar_url=row["avatar_url"] or "",
|
||||
role=row["role"],
|
||||
permission_state=row["permission_state"] or "granted",
|
||||
)
|
||||
|
||||
|
||||
@@ -254,31 +218,11 @@ def require_user(request: Request) -> SessionUser:
|
||||
|
||||
|
||||
def require_contributor(request: Request) -> SessionUser:
|
||||
"""§6.1: authenticated, not write-muted, and granted by an admin.
|
||||
|
||||
v0.8.0 (item #6) widens this gate. A fresh OTC sign-in lands in
|
||||
`permission_state='pending'`; the user can read everything an
|
||||
anonymous viewer can read, but every write-shaped endpoint that
|
||||
funnels through this dependency now refuses with 403 until an
|
||||
admin grants them. The `pending` blast radius is the same as
|
||||
anonymous (item #4 / v0.6.0 already audited the anon-write
|
||||
refusal at every write site), so this widening is structurally
|
||||
a relabel — the same surfaces that already refused 401 to
|
||||
anonymous now also refuse 403 to pending.
|
||||
"""
|
||||
"""§6.1: authenticated, not write-muted."""
|
||||
user = require_user(request)
|
||||
row = db.conn().execute("SELECT muted FROM users WHERE id = ?", (user.user_id,)).fetchone()
|
||||
if row and row["muted"]:
|
||||
raise HTTPException(status_code=403, detail="Your account is muted")
|
||||
if user.permission_state != "granted":
|
||||
# 'pending' is the post-OTC waiting state; 'revoked' is the
|
||||
# admin-undid-the-grant state. Both refuse with the same 403
|
||||
# shape; the client distinguishes via `/api/auth/me` which
|
||||
# carries `permission_state` in the response.
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Your beta access request is in review",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
"""User-facing docs source.
|
||||
|
||||
Mirrors `philosophy.py` shape. Serves `DOCS.md` from the repo root —
|
||||
the framework's plain-prose user guide to roles, contribution flow,
|
||||
and notification surfaces, distinct from the binding `SPEC.md`. Read
|
||||
from disk on first call and cached in-process; the periodic
|
||||
reconciler can call `refresh()` to pick up out-of-band edits.
|
||||
|
||||
`DOCS_PATH` overrides the default location if a deployment hosts the
|
||||
file elsewhere (a meta-repo working-tree clone, a sync target, etc.).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_DEFAULT_PATH = Path(__file__).resolve().parents[2] / "DOCS.md"
|
||||
|
||||
_lock = threading.Lock()
|
||||
_cache: dict | None = None
|
||||
|
||||
|
||||
def _resolved_path() -> Path:
|
||||
override = os.environ.get("DOCS_PATH", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser().resolve()
|
||||
return _DEFAULT_PATH
|
||||
|
||||
|
||||
def load(force: bool = False) -> dict:
|
||||
"""Return the cached `{body, path, mtime}` payload, reading from disk
|
||||
on first call or when `force=True`.
|
||||
"""
|
||||
global _cache
|
||||
with _lock:
|
||||
if _cache is not None and not force:
|
||||
return _cache
|
||||
path = _resolved_path()
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
mtime = path.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
log.warning("DOCS.md not found at %s — serving placeholder", path)
|
||||
text = (
|
||||
"# DOCS.md not found\n\n"
|
||||
"The deployment is missing its user guide. Set "
|
||||
"DOCS_PATH or place DOCS.md at the project root."
|
||||
)
|
||||
mtime = 0.0
|
||||
_cache = {"body": text, "path": str(path), "mtime": mtime}
|
||||
return _cache
|
||||
|
||||
|
||||
def refresh() -> dict:
|
||||
"""Force-reread from disk. Returns the new payload."""
|
||||
return load(force=True)
|
||||
+1
-130
@@ -24,9 +24,7 @@ from . import (
|
||||
email_otc,
|
||||
hygiene,
|
||||
otc,
|
||||
passcode as passcode_mod,
|
||||
providers as providers_mod,
|
||||
turnstile,
|
||||
webhooks,
|
||||
)
|
||||
from .bot import Bot
|
||||
@@ -39,14 +37,6 @@ log = logging.getLogger("rfc_app")
|
||||
|
||||
class OtcRequestBody(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
# v0.12.0 / roadmap item #10: CloudFlare Turnstile token from the
|
||||
# frontend widget. Optional in the body so a deployment that has
|
||||
# not yet wired the Turnstile site key (or a dev environment with
|
||||
# the widget intentionally skipped) still routes through the same
|
||||
# endpoint; the backend turnstile.verify_token call decides whether
|
||||
# to admit the request based on `TURNSTILE_REQUIRED` + presence of
|
||||
# the secret.
|
||||
turnstile_token: str | None = Field(default=None, max_length=4096)
|
||||
|
||||
|
||||
class OtcVerifyBody(BaseModel):
|
||||
@@ -54,15 +44,6 @@ class OtcVerifyBody(BaseModel):
|
||||
code: str = Field(min_length=1, max_length=16)
|
||||
|
||||
|
||||
class PasscodeSetBody(BaseModel):
|
||||
passcode: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
class PasscodeVerifyBody(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
passcode: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
config = load_config()
|
||||
@@ -173,27 +154,7 @@ def _oauth_router(config) -> APIRouter:
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/auth/otc/request")
|
||||
async def otc_request(body: OtcRequestBody, request: Request):
|
||||
# v0.12.0 / roadmap item #10: gate the request on a successful
|
||||
# Turnstile siteverify before the bcrypt hash + SMTP send. The
|
||||
# check runs first so a failed challenge spends no rate budget
|
||||
# and produces no envelope. When the operator has not wired the
|
||||
# secret AND TURNSTILE_REQUIRED=false (the default), the gate
|
||||
# opens — see `backend/app/turnstile.py` for the full matrix.
|
||||
client_ip = request.client.host if request.client else None
|
||||
ts = turnstile.verify_token(body.turnstile_token, client_ip=client_ip)
|
||||
if not ts.ok:
|
||||
if ts.reason == "misconfigured":
|
||||
# TURNSTILE_REQUIRED=true but the secret is unset. This
|
||||
# is an operator/config problem, not a client problem;
|
||||
# surface as 500 so the operator notices in their logs
|
||||
# rather than blaming the user's browser.
|
||||
raise HTTPException(500, "auth misconfigured")
|
||||
# missing-token / failed / network → uniform 400 so the
|
||||
# response does not enumerate which leg of the challenge
|
||||
# broke. The reason is in the server logs.
|
||||
raise HTTPException(400, "verification failed")
|
||||
|
||||
async def otc_request(body: OtcRequestBody):
|
||||
outcome = otc.request_code(body.email)
|
||||
if outcome.reason == "cooldown":
|
||||
# Loud failure per the rate-limit primitive — the abuse
|
||||
@@ -211,96 +172,6 @@ def _oauth_router(config) -> APIRouter:
|
||||
if not result.ok or result.user is None:
|
||||
raise HTTPException(400, "Invalid or expired code")
|
||||
auth.store_session(request, result.user)
|
||||
# v0.8.0: surface `needs_profile` so the Login.jsx surface can
|
||||
# decide whether to advance to the first/last/why capture step
|
||||
# or jump straight to "/". `needs_profile=true` iff the user
|
||||
# is `permission_state='pending'` AND the row has no profile
|
||||
# fields yet — a fresh OTC user. Grandfathered users
|
||||
# (`permission_state='granted'`) and pending users who already
|
||||
# captured their fields both read as false.
|
||||
row = db.conn().execute(
|
||||
"SELECT first_name, last_name, beta_request_reason FROM users WHERE id = ?",
|
||||
(result.user.user_id,),
|
||||
).fetchone()
|
||||
first_name = (row["first_name"] if row else None) or ""
|
||||
last_name = (row["last_name"] if row else None) or ""
|
||||
beta_request_reason = (row["beta_request_reason"] if row else None) or ""
|
||||
needs_profile = (
|
||||
result.user.permission_state == "pending"
|
||||
and not first_name
|
||||
and not last_name
|
||||
and not beta_request_reason
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"user": {
|
||||
"id": result.user.user_id,
|
||||
"display_name": result.user.display_name,
|
||||
"email": result.user.email,
|
||||
"role": result.user.role,
|
||||
"permission_state": result.user.permission_state,
|
||||
},
|
||||
"needs_profile": needs_profile,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8).
|
||||
#
|
||||
# After a successful OTC sign-in, a contributor may set a passcode
|
||||
# and use email + passcode for subsequent sign-ins. OTC remains the
|
||||
# forgot-passcode fallback — a verify failure beyond 5 consecutive
|
||||
# attempts locks the passcode path for 15 minutes; the OTC path is
|
||||
# unaffected by the lockout.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/auth/passcode/check")
|
||||
async def passcode_check(email: str = ""):
|
||||
"""Does this email have a passcode set? Anonymous endpoint —
|
||||
the Login.jsx flow calls this after the user types their email
|
||||
to decide whether to render a passcode input or fall back to
|
||||
OTC. We surface only the boolean; lockout state, the hash, and
|
||||
the set-at stamp are not leaked here."""
|
||||
status = passcode_mod.passcode_status(email)
|
||||
return {"has_passcode": status.has_passcode}
|
||||
|
||||
@router.post("/auth/passcode/set")
|
||||
async def passcode_set(body: PasscodeSetBody, request: Request):
|
||||
"""Set or replace the signed-in user's passcode. Requires an
|
||||
active session (OTC- or passcode-authenticated)."""
|
||||
user = auth.require_user(request)
|
||||
try:
|
||||
passcode_mod.set_passcode(user.user_id, body.passcode)
|
||||
except passcode_mod.PasscodeValidationError as e:
|
||||
raise HTTPException(422, str(e))
|
||||
return {"ok": True}
|
||||
|
||||
@router.delete("/auth/passcode")
|
||||
async def passcode_delete(request: Request):
|
||||
"""Remove the signed-in user's passcode. The user is back to
|
||||
OTC-only on next sign-in."""
|
||||
user = auth.require_user(request)
|
||||
passcode_mod.clear_passcode(user.user_id)
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/auth/passcode/verify")
|
||||
async def passcode_verify(body: PasscodeVerifyBody, request: Request):
|
||||
"""Sign in with email + passcode. Returns the standard session
|
||||
payload on success; HTTP 423 with `locked_until` when the
|
||||
account is in the lockout window; HTTP 400 for every other
|
||||
failure (the wrong-vs-unknown distinction is intentionally
|
||||
collapsed so a probing client cannot enumerate emails)."""
|
||||
result = passcode_mod.verify_passcode(body.email, body.passcode)
|
||||
if result.reason == "locked":
|
||||
raise HTTPException(
|
||||
423,
|
||||
{
|
||||
"detail": "Too many failed attempts; sign in with a one-time code instead",
|
||||
"locked_until": result.locked_until,
|
||||
},
|
||||
)
|
||||
if not result.ok or result.user is None:
|
||||
raise HTTPException(400, "Invalid passcode")
|
||||
auth.store_session(request, result.user)
|
||||
return {
|
||||
"ok": True,
|
||||
"user": {
|
||||
|
||||
+45
-52
@@ -1,4 +1,4 @@
|
||||
"""§6.2 / v0.7.0 / v0.8.0: email + one-time-code sign-in.
|
||||
"""§6.2 / v0.7.0: email + one-time-code sign-in.
|
||||
|
||||
Replaces the Gitea OAuth gesture as the primary human-auth path. The
|
||||
Gitea bot user + token are still needed for server-side git
|
||||
@@ -25,25 +25,14 @@ The shape:
|
||||
`users` row already carries `email` (case-insensitive), it is
|
||||
reused — `gitea_id` is left alone so a grandfathered OAuth-era
|
||||
user keeps the linker intact. Otherwise a fresh contributor
|
||||
row is provisioned with `gitea_id = NULL`, `gitea_login = NULL`,
|
||||
and `permission_state = 'pending'` (v0.8.0 — see below).
|
||||
row is provisioned with `gitea_id = NULL`, `gitea_login = NULL`.
|
||||
|
||||
The endpoints in `main.py` thin-wrap this module.
|
||||
|
||||
v0.8.0 (roadmap item #6) replaces the v0.3.0 `allowed_emails` gate at
|
||||
the request surface. The request handler used to silently drop OTC
|
||||
requests for emails not on the allowlist; now any valid email
|
||||
receives a code. The admission gate moves to `permission_state` on
|
||||
the freshly-provisioned `users` row: a fresh user lands in 'pending'
|
||||
and waits for an admin grant before write endpoints accept them.
|
||||
Read surfaces stay open (the same blast radius v0.6.0 / item #4
|
||||
already audited for anonymous viewers).
|
||||
|
||||
The `allowed_emails` table itself stays in the schema as a
|
||||
fast-path bypass — the admin UI from v0.3.0 continues to manage it,
|
||||
and a future release (v0.9.0's admin user-management page) collapses
|
||||
the two admission surfaces into one. The OTC request path no
|
||||
longer consults the table.
|
||||
The endpoints in `main.py` thin-wrap this module. The allowlist gate
|
||||
from v0.3.0 is consulted at request time — if `allowed_emails` is
|
||||
populated and the requested address isn't on it, the request returns
|
||||
202 as usual but no email is sent. This intentionally does not leak
|
||||
allowlist state to the caller; the §19.2 candidate for v0.8.0
|
||||
replaces this gate with an admin-grant flow.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -55,7 +44,7 @@ from dataclasses import dataclass
|
||||
import bcrypt
|
||||
|
||||
from . import db
|
||||
from .auth import SessionUser
|
||||
from .auth import SessionUser, allowlist_is_active
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -112,15 +101,25 @@ def _check_code(code: str, code_hash: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allowlist gate — shared with the OAuth flow.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _allowlist_admits(email: str) -> bool:
|
||||
"""The same allowlist v0.3.0 introduced for OAuth, applied to OTC
|
||||
requests. If the allowlist is populated and the email is not on it,
|
||||
we still respond 202 to the caller, but no code is sent."""
|
||||
if not allowlist_is_active():
|
||||
return True
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request path
|
||||
#
|
||||
# v0.8.0: the allowlist gate from v0.7.0 / v0.3.0 is removed here. Any
|
||||
# valid email receives a code; the admission gate moved to
|
||||
# `permission_state` on the freshly-provisioned `users` row (see
|
||||
# `provision_or_link_user`). The `allowed_emails` table stays in the
|
||||
# schema (admin UI from v0.3.0 still manages it); v0.9.0's admin
|
||||
# user-management page will collapse the two surfaces.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -128,16 +127,14 @@ def _check_code(code: str, code_hash: str) -> bool:
|
||||
class RequestOutcome:
|
||||
"""The outcome of a `request_code` call.
|
||||
|
||||
`code` is None whenever no code was generated — the cooldown
|
||||
window blocked the request or the email was syntactically
|
||||
invalid. The caller (the API endpoint) does not surface the
|
||||
invalid-email shape to the user; it returns 202 either way.
|
||||
The cooldown shape surfaces as a loud 429 per the v0.7.0
|
||||
contract.
|
||||
`code` is None whenever no code was generated — either because the
|
||||
allowlist denied the email or because the cooldown window blocked
|
||||
the request. The caller (the API endpoint) does not surface this
|
||||
distinction to the user; it returns 202 either way.
|
||||
"""
|
||||
sent: bool
|
||||
code: str | None
|
||||
reason: str # 'sent' | 'cooldown' | 'invalid'
|
||||
reason: str # 'sent' | 'allowlist' | 'cooldown' | 'invalid'
|
||||
|
||||
|
||||
def request_code(email: str) -> RequestOutcome:
|
||||
@@ -163,6 +160,13 @@ def request_code(email: str) -> RequestOutcome:
|
||||
if row is not None:
|
||||
return RequestOutcome(sent=False, code=None, reason="cooldown")
|
||||
|
||||
# Allowlist: silently drop the send if the email isn't on the list.
|
||||
# The row is not written either — there's nothing for verify to
|
||||
# match against, so the user-facing experience is "I never got an
|
||||
# email", which is the intended shape for the private-beta gate.
|
||||
if not _allowlist_admits(email):
|
||||
return RequestOutcome(sent=False, code=None, reason="allowlist")
|
||||
|
||||
# Invalidate prior unused codes for this email. A re-request is
|
||||
# always for the most recent code; older codes are dead.
|
||||
db.conn().execute(
|
||||
@@ -271,16 +275,12 @@ def provision_or_link_user(email: str) -> SessionUser:
|
||||
1. An existing row whose email equals (case-insensitive) the
|
||||
requested email — the OAuth-era user is grandfathered in via
|
||||
this path. `gitea_id` is preserved so a future OAuth round
|
||||
trip still resolves the same row. `permission_state` is
|
||||
read off the row as-is — grandfathered users come through
|
||||
migration with 'granted' (the column default), so their
|
||||
contributor capabilities are unaffected.
|
||||
trip still resolves the same row.
|
||||
2. Otherwise: a fresh contributor row with `gitea_id = NULL`,
|
||||
`gitea_login = NULL`, and `permission_state = 'pending'`
|
||||
(v0.8.0). The display name defaults to the local part of
|
||||
the email (everything before the `@`); a separate
|
||||
`POST /auth/me/beta-request` call lands first name / last
|
||||
name / "why I want access" on the same row.
|
||||
`gitea_login = NULL`. The display name defaults to the local
|
||||
part of the email (everything before the `@`) — users can
|
||||
rename later via the §19.2 first-OTC profile-capture flow
|
||||
that v0.8.0 introduces.
|
||||
|
||||
The §6.1 owner-zero bootstrap still applies: if the email matches
|
||||
the configured `OWNER_GITEA_LOGIN`-derived owner identity, the row
|
||||
@@ -307,19 +307,13 @@ def provision_or_link_user(email: str) -> SessionUser:
|
||||
email=existing["email"] or email,
|
||||
avatar_url=existing["avatar_url"] or "",
|
||||
role=existing["role"],
|
||||
permission_state=existing["permission_state"] or "granted",
|
||||
)
|
||||
|
||||
display = email.split("@", 1)[0] or email
|
||||
# v0.8.0: 'pending' is the explicit insert value; the migration
|
||||
# default of 'granted' is what passes grandfathered users
|
||||
# through. A fresh OTC user lands in 'pending' regardless of
|
||||
# what the migration default says, so the gate engages reliably
|
||||
# even if a future migration changes the default.
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
|
||||
VALUES (NULL, NULL, ?, ?, '', 'contributor', 'pending')
|
||||
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role)
|
||||
VALUES (NULL, NULL, ?, ?, '', 'contributor')
|
||||
""",
|
||||
(email, display),
|
||||
)
|
||||
@@ -332,5 +326,4 @@ def provision_or_link_user(email: str) -> SessionUser:
|
||||
email=email,
|
||||
avatar_url="",
|
||||
role="contributor",
|
||||
permission_state="pending",
|
||||
)
|
||||
|
||||
@@ -1,367 +0,0 @@
|
||||
"""§6.2 / v0.10.0: user-set passcodes after OTC (roadmap item #8).
|
||||
|
||||
After a successful OTC sign-in, a contributor may set a passcode and
|
||||
use email + passcode for subsequent sign-ins. OTC remains the fallback
|
||||
— a forgotten passcode is recovered by requesting a fresh OTC.
|
||||
|
||||
This module is the state machine behind the four `/auth/passcode/*`
|
||||
endpoints (`set`, `clear`, `verify`, `check`). The endpoints in
|
||||
`main.py` thin-wrap these helpers in the same shape the OTC module
|
||||
uses (see `otc.py`).
|
||||
|
||||
Shape:
|
||||
|
||||
* `set_passcode(user_id, passcode)` — bcrypt-hash the passcode and
|
||||
write it to `users.passcode_hash` + `users.passcode_set_at`.
|
||||
Validation (length, denylist) happens here, not at the endpoint,
|
||||
so the rule lives in one place. Replaces any prior passcode.
|
||||
* `clear_passcode(user_id)` — null out `passcode_hash` and
|
||||
`passcode_set_at`. The user is back to OTC-only.
|
||||
* `verify_passcode(email, passcode)` — locate the user by email,
|
||||
check lockout, compare via bcrypt, manage the failure counter,
|
||||
and return a populated `SessionUser` on success.
|
||||
* `passcode_status(email)` — does this email have a passcode set?
|
||||
Used by the `/auth/passcode/check` endpoint that the Login.jsx
|
||||
flow consults after the user types their email.
|
||||
|
||||
Lockout is a v1 shape: 5 consecutive failures sets
|
||||
`passcode_locked_until` to `now + 15 minutes`, after which a verify
|
||||
attempt that lands inside the window returns HTTP 423. The OTC path
|
||||
is unaffected by the lockout — a user can request and verify a fresh
|
||||
OTC to sign in while their passcode is locked out, and `verify_code`
|
||||
in `otc.py` does not consult these columns.
|
||||
|
||||
The lockout window and the failure threshold are hard-coded here.
|
||||
Tuning them via env vars (or moving to per-IP rate-limiting) is a
|
||||
§19.2 candidate; see SPEC §19.2.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
import bcrypt
|
||||
|
||||
from . import db
|
||||
from .auth import SessionUser
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tunables — intentionally hard-coded in v0.10.0 (see module docstring).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LOCKOUT_AFTER_FAILED_ATTEMPTS = 5
|
||||
LOCKOUT_DURATION_MINUTES = 15
|
||||
|
||||
PASSCODE_MIN_LENGTH = 4
|
||||
PASSCODE_MAX_LENGTH = 20
|
||||
|
||||
# A small denylist of patterns we never want a passcode to be. The
|
||||
# rule is "no obvious patterns"; the list is deliberately small —
|
||||
# every entry here is a verbatim string match. A heavier check
|
||||
# (sequential digits, single-character runs of length >= N, etc.)
|
||||
# is a §19.2 candidate.
|
||||
PASSCODE_DENYLIST: frozenset[str] = frozenset(
|
||||
{
|
||||
"0000",
|
||||
"1111",
|
||||
"2222",
|
||||
"3333",
|
||||
"4444",
|
||||
"5555",
|
||||
"6666",
|
||||
"7777",
|
||||
"8888",
|
||||
"9999",
|
||||
"1234",
|
||||
"12345",
|
||||
"123456",
|
||||
"1234567",
|
||||
"12345678",
|
||||
"123456789",
|
||||
"1234567890",
|
||||
"0123",
|
||||
"01234",
|
||||
"012345",
|
||||
"0123456",
|
||||
"01234567",
|
||||
"012345678",
|
||||
"0123456789",
|
||||
"abcd",
|
||||
"abcde",
|
||||
"abcdef",
|
||||
"qwer",
|
||||
"qwerty",
|
||||
"asdf",
|
||||
"asdfg",
|
||||
"asdfgh",
|
||||
"aaaa",
|
||||
"bbbb",
|
||||
"cccc",
|
||||
"password",
|
||||
"letmein",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PasscodeValidationError(Exception):
|
||||
"""The proposed passcode failed validation. The endpoint surface
|
||||
maps this to HTTP 422 with the message intact."""
|
||||
|
||||
|
||||
def _validate(passcode: str) -> str:
|
||||
"""Return the normalized passcode (stripped) or raise.
|
||||
|
||||
Rules:
|
||||
* 4-20 characters after stripping leading/trailing whitespace.
|
||||
* Not on the small denylist of obvious patterns.
|
||||
|
||||
No character-class restriction beyond that — the spec says
|
||||
"numeric PIN or short alphanumeric"; we don't refuse other
|
||||
characters because the entropy isn't load-bearing (the per-account
|
||||
lockout is what carries the security weight, mirroring the OTC
|
||||
shape from v0.7.0).
|
||||
"""
|
||||
pc = (passcode or "").strip()
|
||||
if not pc:
|
||||
raise PasscodeValidationError("Passcode is required")
|
||||
if len(pc) < PASSCODE_MIN_LENGTH:
|
||||
raise PasscodeValidationError(
|
||||
f"Passcode must be at least {PASSCODE_MIN_LENGTH} characters"
|
||||
)
|
||||
if len(pc) > PASSCODE_MAX_LENGTH:
|
||||
raise PasscodeValidationError(
|
||||
f"Passcode must be at most {PASSCODE_MAX_LENGTH} characters"
|
||||
)
|
||||
if pc.lower() in PASSCODE_DENYLIST:
|
||||
raise PasscodeValidationError("Passcode is too common; pick something less obvious")
|
||||
return pc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _hash(passcode: str) -> str:
|
||||
return bcrypt.hashpw(passcode.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
|
||||
|
||||
|
||||
def _check(passcode: str, passcode_hash: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(passcode.encode("utf-8"), passcode_hash.encode("ascii"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Set / clear
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def set_passcode(user_id: int, passcode: str) -> None:
|
||||
"""Hash and store the passcode. Replaces any prior passcode on the
|
||||
same row; clears the failure counter and lockout (a user setting a
|
||||
fresh passcode is implicitly re-authenticating their account)."""
|
||||
pc = _validate(passcode)
|
||||
h = _hash(pc)
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET passcode_hash = ?,
|
||||
passcode_set_at = datetime('now'),
|
||||
passcode_failed_attempts = 0,
|
||||
passcode_locked_until = NULL
|
||||
WHERE id = ?
|
||||
""",
|
||||
(h, user_id),
|
||||
)
|
||||
|
||||
|
||||
def clear_passcode(user_id: int) -> None:
|
||||
"""Remove the passcode. The user is back to OTC-only on next sign-in."""
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET passcode_hash = NULL,
|
||||
passcode_set_at = NULL,
|
||||
passcode_failed_attempts = 0,
|
||||
passcode_locked_until = NULL
|
||||
WHERE id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check (status surface for the Login.jsx flow)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class PasscodeStatus:
|
||||
"""The shape `/auth/passcode/check` returns.
|
||||
|
||||
`has_passcode` is the only signal the frontend needs to decide
|
||||
whether to show a passcode input or an OTC request step. We do
|
||||
not leak the hash, the set-at timestamp, or the lockout state —
|
||||
a probing client that wants to know "is this account locked
|
||||
out" can attempt a verify and read the 423.
|
||||
"""
|
||||
has_passcode: bool
|
||||
|
||||
|
||||
def passcode_status(email: str) -> PasscodeStatus:
|
||||
email = (email or "").strip()
|
||||
if not email or "@" not in email:
|
||||
return PasscodeStatus(has_passcode=False)
|
||||
row = db.conn().execute(
|
||||
"SELECT passcode_hash FROM users WHERE email = ? COLLATE NOCASE",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return PasscodeStatus(has_passcode=False)
|
||||
return PasscodeStatus(has_passcode=bool(row["passcode_hash"]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class VerifyOutcome:
|
||||
"""Result of a `verify_passcode` call.
|
||||
|
||||
`reason` distinguishes the failure modes the endpoint surfaces as
|
||||
distinct HTTP shapes:
|
||||
* 'ok' — populated `user`, HTTP 200.
|
||||
* 'unknown' — no user with this email, HTTP 400 (generic).
|
||||
* 'no_passcode' — user exists but never set a passcode, HTTP 400
|
||||
(the frontend should fall back to OTC).
|
||||
* 'locked' — user is currently in the lockout window, HTTP
|
||||
423. `locked_until` carries the ISO-8601 stamp for the client.
|
||||
* 'wrong' — passcode didn't match. HTTP 400. If the failure
|
||||
crossed the lockout threshold the row is now locked; the
|
||||
endpoint surfaces this as a fresh `locked` response on the
|
||||
next attempt rather than collapsing the two states here.
|
||||
"""
|
||||
ok: bool
|
||||
user: SessionUser | None
|
||||
reason: str
|
||||
locked_until: str | None = None
|
||||
|
||||
|
||||
def verify_passcode(email: str, passcode: str) -> VerifyOutcome:
|
||||
email = (email or "").strip()
|
||||
passcode = (passcode or "").strip()
|
||||
if not email or not passcode:
|
||||
return VerifyOutcome(ok=False, user=None, reason="unknown")
|
||||
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role,
|
||||
passcode_hash, passcode_failed_attempts, passcode_locked_until
|
||||
FROM users
|
||||
WHERE email = ? COLLATE NOCASE
|
||||
""",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return VerifyOutcome(ok=False, user=None, reason="unknown")
|
||||
if not row["passcode_hash"]:
|
||||
return VerifyOutcome(ok=False, user=None, reason="no_passcode")
|
||||
|
||||
# Lockout check: if `passcode_locked_until` is populated and in the
|
||||
# future, the verify is refused without touching the hash. Once the
|
||||
# window has elapsed we let the verify proceed; the failed-attempts
|
||||
# counter is also reset so the user gets a fresh 5-attempt budget.
|
||||
locked_until = row["passcode_locked_until"]
|
||||
if locked_until:
|
||||
still_locked = db.conn().execute(
|
||||
"SELECT datetime(?) > datetime('now') AS still_locked",
|
||||
(locked_until,),
|
||||
).fetchone()["still_locked"]
|
||||
if still_locked:
|
||||
return VerifyOutcome(
|
||||
ok=False,
|
||||
user=None,
|
||||
reason="locked",
|
||||
locked_until=locked_until,
|
||||
)
|
||||
# Lockout expired — clear the counter so the next failure starts
|
||||
# from zero, and continue with the verify.
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET passcode_failed_attempts = 0,
|
||||
passcode_locked_until = NULL
|
||||
WHERE id = ?
|
||||
""",
|
||||
(row["id"],),
|
||||
)
|
||||
|
||||
if _check(passcode, row["passcode_hash"]):
|
||||
# Success: clear the counter (a single success wipes the
|
||||
# accumulated failures — the threshold tracks *consecutive*
|
||||
# failures).
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET passcode_failed_attempts = 0,
|
||||
passcode_locked_until = NULL,
|
||||
last_seen_at = datetime('now')
|
||||
WHERE id = ?
|
||||
""",
|
||||
(row["id"],),
|
||||
)
|
||||
return VerifyOutcome(
|
||||
ok=True,
|
||||
user=SessionUser(
|
||||
user_id=row["id"],
|
||||
gitea_id=row["gitea_id"] or 0,
|
||||
gitea_login=row["gitea_login"] or "",
|
||||
display_name=row["display_name"],
|
||||
email=row["email"] or email,
|
||||
avatar_url=row["avatar_url"] or "",
|
||||
role=row["role"],
|
||||
),
|
||||
reason="ok",
|
||||
)
|
||||
|
||||
# Failure: increment the counter. If this push crosses the
|
||||
# threshold, stamp the lockout. The next verify attempt against
|
||||
# the same row returns 423 with the `locked_until` stamp.
|
||||
next_count = (row["passcode_failed_attempts"] or 0) + 1
|
||||
if next_count >= LOCKOUT_AFTER_FAILED_ATTEMPTS:
|
||||
db.conn().execute(
|
||||
f"""
|
||||
UPDATE users
|
||||
SET passcode_failed_attempts = ?,
|
||||
passcode_locked_until = datetime('now', '+{LOCKOUT_DURATION_MINUTES} minutes')
|
||||
WHERE id = ?
|
||||
""",
|
||||
(next_count, row["id"]),
|
||||
)
|
||||
new_locked_until = db.conn().execute(
|
||||
"SELECT passcode_locked_until FROM users WHERE id = ?",
|
||||
(row["id"],),
|
||||
).fetchone()["passcode_locked_until"]
|
||||
return VerifyOutcome(
|
||||
ok=False,
|
||||
user=None,
|
||||
reason="locked",
|
||||
locked_until=new_locked_until,
|
||||
)
|
||||
db.conn().execute(
|
||||
"UPDATE users SET passcode_failed_attempts = ? WHERE id = ?",
|
||||
(next_count, row["id"]),
|
||||
)
|
||||
return VerifyOutcome(ok=False, user=None, reason="wrong")
|
||||
@@ -1,148 +0,0 @@
|
||||
"""§6.2 / v0.12.0 / roadmap item #10: CloudFlare Turnstile siteverify.
|
||||
|
||||
The OTC request endpoint (`/auth/otc/request`) is the abuse hot path
|
||||
of the auth surface since v0.7.0 — the per-email cooldown stops the
|
||||
trivial back-to-back loop, but it does not stop a distributed scraper
|
||||
that fans out across a large invitee list to harvest the "this email
|
||||
is admitted vs. this email is not" signal indirectly (timing
|
||||
differences, SMTP bounce-rate observation). v0.12.0 gates the request
|
||||
endpoint behind a one-step browser-side Turnstile challenge before the
|
||||
bcrypt hash + SMTP send.
|
||||
|
||||
Stateless: no DB writes, no schema change. The siteverify call to
|
||||
CloudFlare lives entirely in this module; the endpoint handler in
|
||||
`main.py` thin-wraps `verify_token`.
|
||||
|
||||
Tunables (read at call time so tests can monkeypatch):
|
||||
|
||||
* `CLOUDFLARE_TURNSTILE_SECRET` — the operator-provisioned secret
|
||||
key from the Turnstile dashboard. Lives in GCP Secret Manager in
|
||||
production; absent in tests (which monkeypatch the siteverify
|
||||
transport). When unset, the behavior depends on `TURNSTILE_REQUIRED`:
|
||||
- `TURNSTILE_REQUIRED=true` → fail closed (`misconfigured`).
|
||||
- `TURNSTILE_REQUIRED=false` (default) → skip verification entirely
|
||||
and admit the request. This is the dev/test path and the
|
||||
"operator hasn't wired the secret yet" path; production
|
||||
deployments **should** set `TURNSTILE_REQUIRED=true` once the
|
||||
secret is in place so a regression in the secret wiring fails
|
||||
loudly instead of silently disabling abuse defense.
|
||||
|
||||
* `TURNSTILE_REQUIRED` — `true` / `false` (default `false`).
|
||||
When `false` and the secret is absent, the gate is open. When
|
||||
`true` and the secret is absent, the endpoint refuses with a
|
||||
misconfigured-auth shape rather than silently letting requests
|
||||
through.
|
||||
|
||||
* `TURNSTILE_SITEVERIFY_URL` — points at the real CloudFlare
|
||||
endpoint by default. Override in tests to redirect at a mock
|
||||
URL when `httpx.MockTransport` isn't ergonomic for the case.
|
||||
|
||||
The siteverify contract is documented at
|
||||
https://developers.cloudflare.com/turnstile/get-started/server-side-validation/.
|
||||
We POST `secret` + `response` (and optionally `remoteip`) as form
|
||||
fields and read back `{"success": true|false, ...}`. Any network /
|
||||
parse failure on the siteverify call is treated as a verification
|
||||
failure (`network`) — the abuse path is to skip the challenge, so
|
||||
"can't reach CloudFlare" defaults to "refuse the request" when
|
||||
`TURNSTILE_REQUIRED=true`, and "admit" when `TURNSTILE_REQUIRED=false`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||
|
||||
|
||||
def _secret() -> str:
|
||||
return os.environ.get("CLOUDFLARE_TURNSTILE_SECRET", "").strip()
|
||||
|
||||
|
||||
def _required() -> bool:
|
||||
raw = os.environ.get("TURNSTILE_REQUIRED", "").strip().lower()
|
||||
return raw in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _siteverify_url() -> str:
|
||||
return os.environ.get("TURNSTILE_SITEVERIFY_URL", "").strip() or SITEVERIFY_URL
|
||||
|
||||
|
||||
@dataclass
|
||||
class VerifyOutcome:
|
||||
"""Result of a Turnstile siteverify call.
|
||||
|
||||
`ok`: the request **may proceed**. True both for "siteverify said
|
||||
success" and for "no secret configured AND not required" (the
|
||||
dev/test soft-fail path).
|
||||
|
||||
`reason`: one of
|
||||
* 'ok' — siteverify returned success.
|
||||
* 'skipped' — no secret configured, TURNSTILE_REQUIRED=false.
|
||||
The gate is open; the endpoint admits the request.
|
||||
* 'misconfigured' — TURNSTILE_REQUIRED=true but no secret in env.
|
||||
The endpoint fails closed with 500.
|
||||
* 'missing-token' — the client did not send a token at all and
|
||||
verification is required.
|
||||
* 'failed' — siteverify returned success=false. The
|
||||
endpoint refuses with 400.
|
||||
* 'network' — siteverify call raised. Treated as a failure
|
||||
under TURNSTILE_REQUIRED=true.
|
||||
"""
|
||||
ok: bool
|
||||
reason: str
|
||||
|
||||
|
||||
def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOutcome:
|
||||
"""Validate a Turnstile token against CloudFlare's siteverify endpoint.
|
||||
|
||||
Returns a VerifyOutcome describing whether the calling endpoint
|
||||
should proceed. The endpoint maps `ok=False` to an HTTP status per
|
||||
the `reason`:
|
||||
|
||||
* 'misconfigured' → 500 "auth misconfigured"
|
||||
* 'missing-token' / 'failed' / 'network' → 400 "verification failed"
|
||||
|
||||
Tests monkeypatch `httpx.post` (or set `TURNSTILE_SITEVERIFY_URL`
|
||||
+ a MockTransport client) to avoid touching the real CloudFlare
|
||||
endpoint. No real keys are ever embedded in tests.
|
||||
"""
|
||||
secret = _secret()
|
||||
required = _required()
|
||||
|
||||
if not secret:
|
||||
if required:
|
||||
log.warning("Turnstile required but CLOUDFLARE_TURNSTILE_SECRET is unset; failing closed")
|
||||
return VerifyOutcome(ok=False, reason="misconfigured")
|
||||
# Dev/test/soft-fail path: no secret, not required → gate is open.
|
||||
return VerifyOutcome(ok=True, reason="skipped")
|
||||
|
||||
if not token or not token.strip():
|
||||
# Secret is set, so verification is in force. A missing token
|
||||
# is a hard refuse — the frontend should have rendered the
|
||||
# widget and collected one.
|
||||
return VerifyOutcome(ok=False, reason="missing-token")
|
||||
|
||||
data = {"secret": secret, "response": token.strip()}
|
||||
if client_ip:
|
||||
data["remoteip"] = client_ip
|
||||
|
||||
try:
|
||||
response = httpx.post(_siteverify_url(), data=data, timeout=10.0)
|
||||
payload = response.json()
|
||||
except Exception as exc: # network, JSON parse, etc.
|
||||
log.warning("Turnstile siteverify call failed: %s", exc)
|
||||
return VerifyOutcome(ok=False, reason="network")
|
||||
|
||||
if payload.get("success") is True:
|
||||
return VerifyOutcome(ok=True, reason="ok")
|
||||
|
||||
# `error-codes` is a list of strings on failure; we log the codes
|
||||
# for the operator without surfacing them to the client.
|
||||
log.info("Turnstile siteverify rejected token: %s", payload.get("error-codes"))
|
||||
return VerifyOutcome(ok=False, reason="failed")
|
||||
@@ -1,35 +0,0 @@
|
||||
-- v0.13.0 / roadmap item #11 — cookie consent.
|
||||
--
|
||||
-- The framework now ships a non-modal cookie consent banner per the
|
||||
-- privacy-and-cookies UX (SPEC §14.5 / §14.6). Authenticated viewers
|
||||
-- get their choice persisted server-side so it survives sign-out /
|
||||
-- sign-in across devices; anonymous viewers persist their choice in
|
||||
-- localStorage only.
|
||||
--
|
||||
-- Shape: a single row per user, three flags, plus a recorded-at stamp.
|
||||
-- The flags are:
|
||||
-- - essential: the framework's strictly-necessary cookies (session,
|
||||
-- itsdangerous-signed payloads, CSRF if any). Permanently
|
||||
-- true at the API surface — included in the row for
|
||||
-- symmetry with the analytics / other flags rather than
|
||||
-- because the user can switch it off.
|
||||
-- - analytics: reserved for the §13 analytics SDK gating that lands
|
||||
-- in v0.15.0. Off by default; opt-in via the banner.
|
||||
-- - other: everything else (third-party embeds, social widgets).
|
||||
-- Off by default; opt-in via the banner.
|
||||
--
|
||||
-- A NULL recorded_at means "no choice yet" — the banner should re-prompt
|
||||
-- the next time the user signs in on a fresh device. Once recorded_at is
|
||||
-- set, the banner is hidden until the user re-opens it from the
|
||||
-- /settings/notifications "Privacy & cookies" tab.
|
||||
--
|
||||
-- The row is created lazily on first PUT. Absence of a row is equivalent
|
||||
-- to NULL recorded_at — the banner shows.
|
||||
|
||||
CREATE TABLE cookie_consent (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
essential INTEGER NOT NULL DEFAULT 1 CHECK (essential IN (0, 1)),
|
||||
analytics INTEGER NOT NULL DEFAULT 0 CHECK (analytics IN (0, 1)),
|
||||
other_cookies INTEGER NOT NULL DEFAULT 0 CHECK (other_cookies IN (0, 1)),
|
||||
recorded_at TEXT
|
||||
);
|
||||
@@ -1,76 +0,0 @@
|
||||
-- §6.1 / §6.2 / §14.1 / v0.8.0: open beta-access request flow (roadmap item #6).
|
||||
--
|
||||
-- This release replaces v0.3.0's `allowed_emails` allowlist as the
|
||||
-- admission control. Anyone with a valid email can sign in via the
|
||||
-- v0.7.0 OTC flow; a fresh user lands in `permission_state='pending'`
|
||||
-- until an admin grants access. The first-OTC flow captures three
|
||||
-- profile fields (first name, last name, free-text "why I should be
|
||||
-- included in the beta") that the admin sees when triaging the
|
||||
-- request queue. The `allowed_emails` table stays in the schema as a
|
||||
-- fast-path bypass — populated rows are still readable by the
|
||||
-- existing admin UI; the OTC `/request` handler no longer consults
|
||||
-- it. v0.9.0's admin user-management page will replace the
|
||||
-- allowlist UI entirely.
|
||||
--
|
||||
-- Schema additions:
|
||||
--
|
||||
-- * `permission_state` — three-state CHECK: 'pending' | 'granted' |
|
||||
-- 'revoked'. Default 'granted' so every row at migration time
|
||||
-- passes through unaffected; only newly provisioned OTC users
|
||||
-- land in 'pending' (the OTC verify path sets the column
|
||||
-- explicitly on a fresh row, per `app/otc.py`). 'revoked' is the
|
||||
-- admin gesture for an account that earned a grant then later
|
||||
-- lost it; v0.8.0 doesn't surface a revoke UI, but the schema
|
||||
-- slot is here so v0.9.0's admin user-management page can flip
|
||||
-- the column without another migration.
|
||||
--
|
||||
-- * `first_name`, `last_name` — nullable TEXT. Captured on the
|
||||
-- first OTC sign-in via `POST /auth/me/beta-request`. Existing
|
||||
-- rows (OAuth-era users, OTC users provisioned in v0.7.0) carry
|
||||
-- NULL through the migration; the admin queue treats an
|
||||
-- unpopulated capture as "auto-grandfathered" since the row's
|
||||
-- `permission_state` is already 'granted'.
|
||||
--
|
||||
-- * `beta_request_reason` — nullable TEXT. The free-text "why I
|
||||
-- should be included" from the capture form. Bounded to ~4000
|
||||
-- chars at the endpoint layer (no DB-level constraint —
|
||||
-- SQLite's TEXT is unbounded).
|
||||
--
|
||||
-- * `permission_decided_by` — nullable INTEGER. The `users.id` of
|
||||
-- the admin who flipped `permission_state` from 'pending' to
|
||||
-- 'granted' (or 'granted' to 'revoked'). NULL for grandfathered
|
||||
-- rows (they were never decided — they passed through at
|
||||
-- migration). ON DELETE SET NULL because losing the admin row
|
||||
-- should not cascade-delete the user whose access they granted.
|
||||
--
|
||||
-- * `permission_decided_at` — nullable TEXT timestamp (ISO 8601,
|
||||
-- same shape as the existing `created_at` / `last_seen_at`).
|
||||
-- Co-populated with `permission_decided_by` on each decision.
|
||||
--
|
||||
-- Grandfathered-row invariant:
|
||||
--
|
||||
-- Every row that exists at migration time has
|
||||
-- `permission_state='granted'` and `permission_decided_by=NULL`
|
||||
-- (the column default + NULL preservation). v0.8.0's auth gate
|
||||
-- reads `permission_state='granted'` as the admission check, so
|
||||
-- no existing user is locked out by the upgrade. v0.7.0's OTC
|
||||
-- path is patched in the same release to set
|
||||
-- `permission_state='pending'` explicitly on a fresh row, so the
|
||||
-- gate engages only for users provisioned after the upgrade.
|
||||
|
||||
ALTER TABLE users ADD COLUMN permission_state TEXT NOT NULL DEFAULT 'granted'
|
||||
CHECK (permission_state IN ('pending', 'granted', 'revoked'));
|
||||
|
||||
ALTER TABLE users ADD COLUMN first_name TEXT;
|
||||
ALTER TABLE users ADD COLUMN last_name TEXT;
|
||||
ALTER TABLE users ADD COLUMN beta_request_reason TEXT;
|
||||
|
||||
ALTER TABLE users ADD COLUMN permission_decided_by INTEGER
|
||||
REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE users ADD COLUMN permission_decided_at TEXT;
|
||||
|
||||
-- Index for the v0.9.0 admin queue: list pending requests ordered by
|
||||
-- when the user's row was created (the implicit "request received at"
|
||||
-- timestamp, since v0.8.0 sets pending at the same moment as the row
|
||||
-- itself is inserted via the OTC verify path).
|
||||
CREATE INDEX idx_users_permission_state ON users (permission_state);
|
||||
@@ -1,52 +0,0 @@
|
||||
-- §6.2 / v0.10.0: user-set passcodes after OTC (roadmap item #8).
|
||||
--
|
||||
-- After a successful OTC sign-in, a contributor may set a passcode
|
||||
-- (numeric PIN or short alphanumeric). Subsequent sign-ins on the same
|
||||
-- account can use email + passcode instead of email + OTC. OTC remains
|
||||
-- the structural fallback — a forgotten passcode is recovered by
|
||||
-- requesting a fresh OTC and signing in via that path. Per-account
|
||||
-- lockout after 5 consecutive verify failures redirects the user to
|
||||
-- the OTC path for 15 minutes; the OTC path itself is unaffected by
|
||||
-- the passcode lockout (a locked-out user can still receive a fresh
|
||||
-- code and sign in).
|
||||
--
|
||||
-- The columns are additive to the `users` table from `012_otc.sql`.
|
||||
-- v0.8.0's `permission_state` column (roadmap item #6) lands in the
|
||||
-- driver's integration order ahead of this migration; we do not touch
|
||||
-- that column here. v0.7.0's nullable-`gitea_id`/`gitea_login` shape
|
||||
-- is preserved verbatim.
|
||||
--
|
||||
-- Storage shape:
|
||||
--
|
||||
-- * `passcode_hash` (nullable) — bcrypt hash of the passcode.
|
||||
-- NULL means "no passcode set"; the user is OTC-only.
|
||||
-- * `passcode_set_at` (nullable) — timestamp of the most recent
|
||||
-- `passcode/set` call. Updated when a passcode is set or
|
||||
-- replaced; cleared when the passcode is removed.
|
||||
-- * `passcode_failed_attempts` — count of consecutive failed
|
||||
-- verify attempts since the last successful verify (or since
|
||||
-- the lockout cleared). Resets to 0 on success and on lockout
|
||||
-- expiry. Defaults to 0 so existing rows post-migration are
|
||||
-- not implicitly half-locked.
|
||||
-- * `passcode_locked_until` (nullable) — if populated and the
|
||||
-- timestamp is in the future, passcode verify is refused with
|
||||
-- HTTP 423. Cleared on successful verify after the window
|
||||
-- expires, or by the operator via direct DB intervention if
|
||||
-- ever needed (no admin endpoint surfaces this in v1).
|
||||
--
|
||||
-- v0.10.0 introduces no new env vars. The lockout window (5 attempts,
|
||||
-- 15 minutes) is hard-coded in `backend/app/passcode.py`; raising or
|
||||
-- lowering it is a future-§19.2 candidate. Passcode hashing reuses
|
||||
-- the bcrypt dependency added in v0.7.0 for OTC; no new secret is
|
||||
-- required (the existing `SECRET_KEY` continues to sign sessions).
|
||||
--
|
||||
-- Note on SQLite: ALTER TABLE ... ADD COLUMN is supported, so this
|
||||
-- migration does not need the rebuild dance that `012_otc.sql`
|
||||
-- required. The runner wraps each file in a single BEGIN/COMMIT
|
||||
-- block — see `backend/app/db.py` — so either every ADD COLUMN
|
||||
-- here lands or none do.
|
||||
|
||||
ALTER TABLE users ADD COLUMN passcode_hash TEXT;
|
||||
ALTER TABLE users ADD COLUMN passcode_set_at TEXT;
|
||||
ALTER TABLE users ADD COLUMN passcode_failed_attempts INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN passcode_locked_until TEXT;
|
||||
@@ -1,476 +0,0 @@
|
||||
"""v0.6.0 (roadmap item #4) — "anon discuss + contribute off-limits"
|
||||
vertical.
|
||||
|
||||
A sweep-the-edges hardening release. The v0.3.0 release hid the write
|
||||
affordances from anonymous viewers; v0.5.0 added the PR-less discussion
|
||||
surface with its own write gate. v0.6.0 audits both: every write-shaped
|
||||
endpoint refuses anonymous callers with 401 (or 403 when the role check
|
||||
runs after the auth check), and every anonymous-read surface stays
|
||||
reachable.
|
||||
|
||||
This test is the regression net for the audit. It walks each module's
|
||||
representative write endpoint as an anonymous client and asserts the
|
||||
401/403, then walks the same surfaces' representative read endpoints
|
||||
as anonymous and asserts the 200. The intent is breadth over depth:
|
||||
one assertion per write endpoint family is enough to catch a
|
||||
regression where someone strips the `auth.require_contributor` line.
|
||||
|
||||
Endpoints covered (one or two from each module):
|
||||
|
||||
- api.py: propose, decline (admin), withdraw,
|
||||
funder credentials POST/DELETE, funder consent
|
||||
POST/DELETE
|
||||
- api_branches.py: promote-to-branch, start-edit-branch, metadata,
|
||||
manual-flush, visibility, grants POST/DELETE,
|
||||
threads POST, thread messages POST, resolve,
|
||||
chat-seen, change accept/decline/reask
|
||||
- api_prs.py: pr-draft, open-pr, seen, review, merge, withdraw,
|
||||
description, resolution-branch
|
||||
- api_discussion.py: thread create, message post, resolve
|
||||
- api_admin.py: role POST, mute POST, allowlist POST/DELETE
|
||||
- api_notifications.py: prefs POST, watch POST, mark-read POST,
|
||||
quiet-hours POST, user-mute POST/DELETE
|
||||
- api_graduation.py: graduate POST, claim POST, progress GET
|
||||
|
||||
The §15.7 reads (`/api/notifications`, `/api/watches`,
|
||||
`/api/users/me/*`) are per-user surfaces — they require an
|
||||
authenticated viewer by definition; an anonymous 401 on those reads is
|
||||
shape-correct, not a regression. The test does not assert reads on
|
||||
those.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
# Reuse the fixture / session / fake-Gitea harness from Slice 1.
|
||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_can_read_every_public_surface(app_with_fake_gitea):
|
||||
"""Per §14 / the v0.3.0 anonymous-read contract: the catalog, the
|
||||
RFC view, the PR-less discussion surface, the philosophy page, and
|
||||
the health probe must remain reachable for unauthenticated viewers.
|
||||
This is the read side of the item #4 contract — the read surfaces
|
||||
must NOT regress to require auth as the write gates tighten.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
# No session cookie — viewer is anonymous.
|
||||
client.cookies.clear()
|
||||
|
||||
# The five read surfaces an anonymous viewer must reach.
|
||||
assert client.get("/api/health").status_code == 200
|
||||
assert client.get("/api/philosophy").status_code == 200
|
||||
assert client.get("/api/auth/me").status_code == 200
|
||||
assert client.get("/api/rfcs").status_code == 200
|
||||
assert client.get("/api/rfcs/ohm").status_code == 200
|
||||
assert client.get("/api/rfcs/ohm/main").status_code == 200
|
||||
assert client.get("/api/rfcs/ohm/discussion/threads").status_code == 200
|
||||
assert client.get("/api/proposals").status_code == 200
|
||||
|
||||
|
||||
def test_anonymous_propose_refused(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.cookies.clear()
|
||||
r = client.post(
|
||||
"/api/rfcs/propose",
|
||||
json={"title": "X", "slug": "x", "pitch": "p", "tags": []},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_anonymous_proposal_admin_paths_refused(app_with_fake_gitea):
|
||||
"""The admin-gated proposal actions — merge, decline — must refuse
|
||||
anonymous callers with 401 (the auth check runs before the role
|
||||
check; both refusals are correct, but 401 is the structural signal
|
||||
"no session at all")."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.cookies.clear()
|
||||
# PR number doesn't need to exist — the gate runs first.
|
||||
assert client.post("/api/proposals/1/merge").status_code == 401
|
||||
assert (
|
||||
client.post("/api/proposals/1/decline", json={"comment": "no"}).status_code
|
||||
== 401
|
||||
)
|
||||
assert client.post("/api/proposals/1/withdraw").status_code == 401
|
||||
|
||||
|
||||
def test_anonymous_branch_writes_refused_on_active_rfc(app_with_fake_gitea):
|
||||
"""Branch-scoped writes on an active RFC: promote-to-branch,
|
||||
manual-flush, visibility, grants, threads create, message post,
|
||||
resolve, chat-seen, change accept/decline/reask. All must 401 for
|
||||
anonymous callers."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
client.cookies.clear()
|
||||
|
||||
# Branch-scoped writes — slug + branch values are placeholders;
|
||||
# the auth gate runs before any state lookup.
|
||||
slug = "ohm"
|
||||
branch = "feature-x"
|
||||
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/main/promote-to-branch",
|
||||
json={},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/manual-flush",
|
||||
json={"new_content": "hi", "paragraph_count": 1},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/visibility",
|
||||
json={"read_public": False},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/grants",
|
||||
json={"grantee_gitea_login": "alice"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.delete(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/grants/alice",
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/threads",
|
||||
json={"thread_kind": "chat", "anchor_kind": "whole-doc"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/threads/1/messages",
|
||||
json={"text": "hi"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/threads/1/resolve",
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/chat-seen",
|
||||
json={"last_seen_message_id": 1},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/changes/1/accept",
|
||||
json={"proposed": "x"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/changes/1/decline",
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/changes/1/reask",
|
||||
).status_code == 401
|
||||
)
|
||||
# Chat stream — POST shaped, same auth gate.
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/threads/1/chat",
|
||||
json={"text": "hi"},
|
||||
).status_code == 401
|
||||
)
|
||||
|
||||
|
||||
def test_anonymous_super_draft_writes_refused(app_with_fake_gitea):
|
||||
"""Super-draft-scoped writes: start-edit-branch and metadata. The
|
||||
PR open / merge paths share the gate via api_prs.py — see the
|
||||
PR-flow test below for those."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.cookies.clear()
|
||||
assert (
|
||||
client.post(
|
||||
"/api/rfcs/anything/start-edit-branch", json={}
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/rfcs/anything/metadata", json={"title": "x"}
|
||||
).status_code == 401
|
||||
)
|
||||
|
||||
|
||||
def test_anonymous_pr_flow_writes_refused(app_with_fake_gitea):
|
||||
"""All §10 PR-flow writes — open, merge, withdraw, description,
|
||||
review, seen, pr-draft, resolution-branch — must 401 for anonymous."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
client.cookies.clear()
|
||||
slug, branch, pr = "ohm", "feature-x", 1
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/pr-draft"
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/open-pr",
|
||||
json={"title": "t", "description": "d"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/prs/{pr}/seen",
|
||||
json={"last_seen_message_id": 1},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/prs/{pr}/review",
|
||||
json={"text": "x", "anchor_payload": {}},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/prs/{pr}/merge"
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/prs/{pr}/withdraw"
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/prs/{pr}/description",
|
||||
json={"title": "t", "description": "d"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/prs/{pr}/resolution-branch"
|
||||
).status_code == 401
|
||||
)
|
||||
|
||||
|
||||
def test_anonymous_discussion_writes_refused(app_with_fake_gitea):
|
||||
"""The v0.5.0 PR-less discussion surface — write gates must hold.
|
||||
This duplicates the assertion in `test_discussion_vertical.py` and
|
||||
keeps it here too as the canonical home for the item #4 audit."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
client.cookies.clear()
|
||||
assert (
|
||||
client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"message": "drive-by"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/rfcs/ohm/discussion/threads/1/messages",
|
||||
json={"text": "drive-by"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/rfcs/ohm/discussion/threads/1/resolve"
|
||||
).status_code == 401
|
||||
)
|
||||
|
||||
|
||||
def test_anonymous_admin_writes_refused(app_with_fake_gitea):
|
||||
"""Admin surfaces — role, mute, allowlist — refuse anonymous.
|
||||
The auth check runs before the require_admin role check, so the
|
||||
response is 401."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.cookies.clear()
|
||||
assert (
|
||||
client.post(
|
||||
"/api/admin/users/1/role", json={"role": "admin"}
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/admin/users/1/mute", json={"muted": True}
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/admin/allowlist", json={"email": "x@y.z"}
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.delete("/api/admin/allowlist/x@y.z").status_code == 401
|
||||
)
|
||||
# Admin reads also gated.
|
||||
assert client.get("/api/admin/users").status_code == 401
|
||||
assert client.get("/api/admin/audit").status_code == 401
|
||||
assert client.get("/api/admin/permission-events").status_code == 401
|
||||
assert client.get("/api/admin/graduation-queue").status_code == 401
|
||||
assert client.get("/api/admin/allowlist").status_code == 401
|
||||
|
||||
|
||||
def test_anonymous_notification_writes_refused(app_with_fake_gitea):
|
||||
"""Notification preference / watch / mark-read / user-mute writes —
|
||||
all per-user surfaces, all require an authenticated viewer."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
client.cookies.clear()
|
||||
assert (
|
||||
client.post(
|
||||
"/api/users/me/notification-preferences",
|
||||
json={"email_personal_direct": False},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/users/me/quiet-hours",
|
||||
json={"start": None, "end": None, "timezone": None},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post("/api/rfcs/ohm/watch", json={"state": "watching"}).status_code
|
||||
== 401
|
||||
)
|
||||
assert client.post("/api/notifications/1/read").status_code == 401
|
||||
assert (
|
||||
client.post("/api/notifications/read", json={}).status_code == 401
|
||||
)
|
||||
assert client.post("/api/users/1/notification-mute").status_code == 401
|
||||
assert client.delete("/api/users/1/notification-mute").status_code == 401
|
||||
|
||||
|
||||
def test_anonymous_funder_writes_refused(app_with_fake_gitea):
|
||||
"""§6.7 funder credential + consent writes — registering a key,
|
||||
consenting to fund — all refuse anonymous callers."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
client.cookies.clear()
|
||||
assert (
|
||||
client.post(
|
||||
"/api/users/me/funder/credentials",
|
||||
json={"provider": "anthropic", "api_key": "sk-test"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.delete(
|
||||
"/api/users/me/funder/credentials/anthropic"
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post("/api/rfcs/ohm/funder/consent").status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.delete("/api/rfcs/ohm/funder/consent").status_code == 401
|
||||
)
|
||||
|
||||
|
||||
def test_anonymous_graduation_writes_refused(app_with_fake_gitea):
|
||||
"""§13 graduation: the POST kickoff and POST claim both refuse
|
||||
anonymous. The progress SSE was gated to require_user in v0.6.0
|
||||
(item #4) since it surfaces admin-internal step detail."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.cookies.clear()
|
||||
assert (
|
||||
client.post(
|
||||
"/api/rfcs/anything/graduate",
|
||||
json={
|
||||
"rfc_id": "RFC-0001",
|
||||
"repo_name": "rfc-0001-x",
|
||||
"owners": ["alice"],
|
||||
},
|
||||
).status_code == 401
|
||||
)
|
||||
assert client.post("/api/rfcs/anything/claim").status_code == 401
|
||||
# v0.6.0 tightening: progress SSE now requires require_user.
|
||||
# No graduation is in flight, but the auth check runs first.
|
||||
assert (
|
||||
client.get("/api/rfcs/anything/graduate/progress").status_code == 401
|
||||
)
|
||||
|
||||
|
||||
def test_anonymous_can_read_published_pr_view(app_with_fake_gitea):
|
||||
"""The PR review page is §11.3 universal-public — once a PR is
|
||||
open, anonymous viewers can read it. This guards against a
|
||||
regression where the read endpoint accidentally grows an auth
|
||||
gate."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
# Seed an open PR row directly — the cache shape is enough for
|
||||
# the read endpoint; the live Gitea fetch falls back gracefully.
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO cached_prs
|
||||
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
|
||||
opened_by, opened_at, head_branch, base_branch, head_sha)
|
||||
VALUES ('ohm', 'rfc_branch', 'wiggleverse/rfc-0001-ohm', 7, 't', 'd',
|
||||
'open', 'alice', datetime('now'), 'feature-x', 'main', 'sha7')
|
||||
"""
|
||||
)
|
||||
client.cookies.clear()
|
||||
# Anonymous read on an open PR: should be 200. The endpoint may
|
||||
# surface a partial response (the FakeGitea won't have the head
|
||||
# branch's RFC.md, so branch_body falls back to empty) but the
|
||||
# auth gate must let the read through.
|
||||
r = client.get("/api/rfcs/ohm/prs/7")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["capabilities"]["is_anonymous"] is True
|
||||
assert body["capabilities"]["can_merge"] is False
|
||||
assert body["capabilities"]["can_post_review"] is False
|
||||
@@ -1,390 +0,0 @@
|
||||
"""End-to-end integration tests for v0.8.0's open beta-access request
|
||||
flow (§6.1 / §14.1, roadmap item #6).
|
||||
|
||||
The release replaces v0.3.0's `allowed_emails` allowlist as the
|
||||
admission gate. Any valid email can sign in via the v0.7.0 OTC flow;
|
||||
a fresh user lands in `permission_state='pending'` until an admin
|
||||
grants access. The first-OTC flow captures first name, last name,
|
||||
and a free-text "why I should be included in the beta" via a new
|
||||
`POST /api/auth/me/beta-request` endpoint.
|
||||
|
||||
The tests prove:
|
||||
|
||||
* A fresh OTC user lands `permission_state='pending'` with empty
|
||||
profile fields, and the verify-response carries `needs_profile=true`.
|
||||
* `POST /api/auth/me/beta-request` populates the three fields and
|
||||
leaves the row in `pending`.
|
||||
* A pending user is refused write endpoints (representative
|
||||
samples: propose RFC, post discussion thread). The refusal is
|
||||
403 (not 401 — they're authenticated, just not granted).
|
||||
* An admin-grant flow promotes pending → granted. v0.8.0 doesn't
|
||||
ship an admin UI for this (deferred to item #7 / v0.9.0), so
|
||||
the test flips the column directly via DB and asserts that
|
||||
`require_contributor` now admits the user.
|
||||
* A grandfathered user (existing row pre-migration, default
|
||||
`permission_state='granted'`) is unaffected — write endpoints
|
||||
accept them.
|
||||
* The `/auth/otc/request` endpoint accepts any email — the
|
||||
v0.7.0 allowlist gate is gone from this path. The `allowed_emails`
|
||||
table stays in the schema; the admin UI from v0.3.0 continues to
|
||||
manage it for the fast-path bypass deployments may use.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
def _reset_outbound():
|
||||
from app import email as email_mod
|
||||
email_mod.reset_sent_envelopes()
|
||||
|
||||
|
||||
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
|
||||
"""Pluck the code line from every OTC envelope in the test buffer."""
|
||||
from app import email as email_mod
|
||||
out = []
|
||||
for env in email_mod.sent_envelopes():
|
||||
if env.get("kind") != "otc":
|
||||
continue
|
||||
if to_address is not None and env["to"] != to_address:
|
||||
continue
|
||||
for line in env["body"].splitlines():
|
||||
tok = line.strip()
|
||||
if tok.isdigit() and len(tok) == 6:
|
||||
out.append(tok)
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fresh OTC sign-in lands pending with empty fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fresh_otc_user_lands_pending_with_empty_profile(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
|
||||
# Request + verify the OTC.
|
||||
r = client.post("/auth/otc/request", json={"email": "newcomer@example.com"})
|
||||
assert r.status_code == 200, r.text
|
||||
code = _outbound_otc_codes("newcomer@example.com")[-1]
|
||||
|
||||
r = client.post("/auth/otc/verify", json={"email": "newcomer@example.com", "code": code})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
# The verify response carries the new fields v0.8.0 added.
|
||||
assert body["needs_profile"] is True
|
||||
assert body["user"]["permission_state"] == "pending"
|
||||
|
||||
# The row reflects the same: pending state, no profile yet.
|
||||
row = db.conn().execute(
|
||||
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE email = ? COLLATE NOCASE",
|
||||
("newcomer@example.com",),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row["permission_state"] == "pending"
|
||||
assert row["first_name"] is None
|
||||
assert row["last_name"] is None
|
||||
assert row["beta_request_reason"] is None
|
||||
|
||||
# /api/auth/me surfaces the same shape.
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["authenticated"] is True
|
||||
assert me["user"]["permission_state"] == "pending"
|
||||
assert me["user"]["needs_profile"] is True
|
||||
assert me["user"]["first_name"] == ""
|
||||
assert me["user"]["last_name"] == ""
|
||||
assert me["user"]["beta_request_reason"] == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# beta-request endpoint captures the fields and leaves state pending
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_beta_request_populates_fields_keeps_state_pending(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
# Sign in the fresh user via the full OTC flow.
|
||||
client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||
client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
|
||||
|
||||
# Submit the capture form.
|
||||
r = client.post(
|
||||
"/api/auth/me/beta-request",
|
||||
json={
|
||||
"first_name": "Alice",
|
||||
"last_name": "Liddell",
|
||||
"beta_request_reason": "I want to help write the RFCs.",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# The row reflects the captured fields; state stays pending.
|
||||
row = db.conn().execute(
|
||||
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE email = ? COLLATE NOCASE",
|
||||
("alice@example.com",),
|
||||
).fetchone()
|
||||
assert row["permission_state"] == "pending"
|
||||
assert row["first_name"] == "Alice"
|
||||
assert row["last_name"] == "Liddell"
|
||||
assert row["beta_request_reason"] == "I want to help write the RFCs."
|
||||
|
||||
# /api/auth/me now reports needs_profile=false (fields are set).
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["permission_state"] == "pending"
|
||||
assert me["user"]["needs_profile"] is False
|
||||
assert me["user"]["first_name"] == "Alice"
|
||||
|
||||
|
||||
def test_beta_request_refuses_anonymous(app_with_fake_gitea):
|
||||
"""The endpoint requires authentication — an anonymous caller can't
|
||||
file a request without first signing in via OTC."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.cookies.clear()
|
||||
r = client.post(
|
||||
"/api/auth/me/beta-request",
|
||||
json={"first_name": "A", "last_name": "B", "beta_request_reason": "Hi"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_beta_request_refuses_granted_user(app_with_fake_gitea):
|
||||
"""A grandfathered (already granted) user has no business filing a
|
||||
beta request. The endpoint refuses with 409 so the client can
|
||||
distinguish the failure from "we don't know you" (401)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="grandfathered", role="contributor")
|
||||
sign_in_as(
|
||||
client,
|
||||
user_id=1,
|
||||
gitea_login="grandfathered",
|
||||
display_name="Grandfathered",
|
||||
role="contributor",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/auth/me/beta-request",
|
||||
json={"first_name": "G", "last_name": "F", "beta_request_reason": "x"},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pending user is refused write endpoints; admin grant promotes them
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pending_user_is_refused_write_endpoints(app_with_fake_gitea):
|
||||
"""A pending user can read everything anonymous can read, but every
|
||||
write-shaped endpoint refuses with 403. The refusal shape mirrors
|
||||
the v0.6.0 / item #4 audit's anon-401 — both are "no contributor
|
||||
capability"; pending is the authenticated-but-ungranted variant.
|
||||
|
||||
Representative samples: propose RFC, post discussion thread.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
# Sign in via fresh OTC — lands pending.
|
||||
client.post("/auth/otc/request", json={"email": "pending@example.com"})
|
||||
code = _outbound_otc_codes("pending@example.com")[-1]
|
||||
client.post("/auth/otc/verify", json={"email": "pending@example.com", "code": code})
|
||||
|
||||
# Reads work — every anonymous surface stays reachable.
|
||||
assert client.get("/api/health").status_code == 200
|
||||
assert client.get("/api/rfcs").status_code == 200
|
||||
assert client.get("/api/philosophy").status_code == 200
|
||||
|
||||
# Propose — write-shaped, refused with 403.
|
||||
r = client.post(
|
||||
"/api/rfcs/propose",
|
||||
json={"title": "T", "slug": "t", "pitch": "p", "tags": []},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
# The error body mentions the review state so a UI surface can
|
||||
# render the right message — but the test asserts only on the
|
||||
# status code (the body shape is the FastAPI default detail).
|
||||
|
||||
|
||||
def test_admin_grant_promotes_pending_to_granted(app_with_fake_gitea):
|
||||
"""v0.8.0 doesn't ship an admin UI for this — it's deferred to
|
||||
item #7 / v0.9.0. For this release, an admin gesture is an
|
||||
`UPDATE users SET permission_state='granted' WHERE email=?`. The
|
||||
test flips the column directly via DB and asserts the
|
||||
`require_contributor` gate now admits the user.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
# Sign in a fresh OTC user — lands pending.
|
||||
client.post("/auth/otc/request", json={"email": "promoted@example.com"})
|
||||
code = _outbound_otc_codes("promoted@example.com")[-1]
|
||||
client.post("/auth/otc/verify", json={"email": "promoted@example.com", "code": code})
|
||||
|
||||
# Before the grant: propose refused with 403.
|
||||
r = client.post(
|
||||
"/api/rfcs/propose",
|
||||
json={"title": "T", "slug": "t-pre", "pitch": "p", "tags": []},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
# The admin gesture (v0.8.0 shape — direct UPDATE; v0.9.0 will
|
||||
# ship a UI). The test stamps `permission_decided_by` and
|
||||
# `permission_decided_at` as the v0.9.0 admin UI will, so the
|
||||
# column population exercises the schema slot. user_id=99 is
|
||||
# a placeholder admin row — provision it so the FK resolves.
|
||||
provision_user_row(user_id=99, login="adminuser", role="admin")
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET permission_state = 'granted',
|
||||
permission_decided_by = 99,
|
||||
permission_decided_at = datetime('now')
|
||||
WHERE email = ?
|
||||
""",
|
||||
("promoted@example.com",),
|
||||
)
|
||||
|
||||
# The next request reads the fresh column from the DB. The
|
||||
# propose endpoint reaches the route body now (it then refuses
|
||||
# for a different reason — the slug 't-prop' will fail
|
||||
# the slug-format check or hit a mock-gitea path — but the
|
||||
# status code is _not_ 403/401, which is the v0.8.0 assertion).
|
||||
r = client.post(
|
||||
"/api/rfcs/propose",
|
||||
json={"title": "Title", "slug": "tprop", "pitch": "Pitch text.", "tags": []},
|
||||
)
|
||||
assert r.status_code != 403, r.text
|
||||
assert r.status_code != 401, r.text
|
||||
|
||||
|
||||
def test_grandfathered_user_is_unaffected_by_migration(app_with_fake_gitea):
|
||||
"""An existing `users` row at migration time has
|
||||
`permission_state='granted'` via the column default. The
|
||||
grandfathered user passes write endpoints without filing a
|
||||
beta request and without the admin UI. v0.6.0 (anon-write
|
||||
audit) is the v0.6.0 contract; v0.8.0 widens the gate but
|
||||
does not break this case.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=5, login="oldhand", role="contributor")
|
||||
# provision_user_row uses INSERT OR REPLACE INTO users with
|
||||
# the column list it knows; permission_state is not in that
|
||||
# list, so it picks up the column default ('granted') on
|
||||
# insert. Confirm directly.
|
||||
row = db.conn().execute(
|
||||
"SELECT permission_state FROM users WHERE id = 5"
|
||||
).fetchone()
|
||||
assert row["permission_state"] == "granted"
|
||||
|
||||
sign_in_as(
|
||||
client,
|
||||
user_id=5,
|
||||
gitea_login="oldhand",
|
||||
display_name="Old Hand",
|
||||
role="contributor",
|
||||
)
|
||||
|
||||
# Propose is write-shaped; the call should not refuse on
|
||||
# the permission_state gate. (Subsequent failure modes —
|
||||
# e.g. mock-gitea wiring — are not the v0.8.0 concern; this
|
||||
# test asserts on the gate, not the propose body's success.)
|
||||
r = client.post(
|
||||
"/api/rfcs/propose",
|
||||
json={"title": "Title", "slug": "gf-slug", "pitch": "Pitch.", "tags": []},
|
||||
)
|
||||
assert r.status_code != 403, r.text
|
||||
assert r.status_code != 401, r.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /auth/otc/request accepts any email — the v0.7.0 allowlist gate is gone
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_request_accepts_any_email_regardless_of_allowlist(app_with_fake_gitea):
|
||||
"""v0.7.0 silently dropped OTC requests for emails not on the
|
||||
`allowed_emails` table. v0.8.0 reverses this: the request
|
||||
endpoint sends a code to any valid email; admission gates at
|
||||
`permission_state` post-verify instead. The `allowed_emails`
|
||||
table stays in the schema as a fast-path bypass for
|
||||
deployments that want to pre-mark known-good emails (the v0.9.0
|
||||
admin user-management page will collapse the two surfaces).
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
# Populate the allowlist with one specific email so the v0.7.0
|
||||
# gate would have engaged. v0.8.0 ignores it for the request
|
||||
# path.
|
||||
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("known@example.com",))
|
||||
|
||||
# An email NOT on the allowlist still gets a code under v0.8.0.
|
||||
r = client.post("/auth/otc/request", json={"email": "stranger@example.com"})
|
||||
assert r.status_code == 200
|
||||
codes = _outbound_otc_codes("stranger@example.com")
|
||||
assert len(codes) == 1, "OTC code must be sent regardless of allowlist state"
|
||||
|
||||
# The row is there and the user can complete sign-in (and will
|
||||
# land in 'pending' per the other tests).
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM otc_codes WHERE email = ?",
|
||||
("stranger@example.com",),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
|
||||
|
||||
def test_allowlist_table_still_present_in_schema(app_with_fake_gitea):
|
||||
"""The schema migration leaves the `allowed_emails` table in
|
||||
place — the admin UI from v0.3.0 still manages it for the
|
||||
fast-path bypass deployments may use. This is a regression net
|
||||
for "did the v0.8.0 cleanup accidentally drop the table"."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
# The table accepts inserts (i.e. it exists) — no schema check
|
||||
# gymnastics needed.
|
||||
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("kept@example.com",))
|
||||
row = db.conn().execute(
|
||||
"SELECT email FROM allowed_emails WHERE email = ?",
|
||||
("kept@example.com",),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
@@ -1,205 +0,0 @@
|
||||
"""End-to-end tests for v0.13.0 / roadmap item #11 — cookie / privacy consent.
|
||||
|
||||
Covers the §17 endpoints (`GET` / `PUT /api/users/me/cookie-consent`) and
|
||||
the §14.5 storage contract:
|
||||
|
||||
* GET on a fresh user returns no-choice-yet (recorded_at is None,
|
||||
essential=True, analytics=False, other=False).
|
||||
* PUT writes a row, stamps recorded_at, and the choice survives.
|
||||
* PUT with `analytics=true, other=false` round-trips faithfully.
|
||||
* `essential` is permanently true at the API surface — a PUT that
|
||||
requests essential=false is still persisted with essential=true.
|
||||
* The endpoint requires authentication (401 for anon).
|
||||
* A second PUT updates the existing row in place (single row per
|
||||
user, recorded_at re-stamps).
|
||||
* Choice persists across sign-out / sign-in.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
def test_get_cookie_consent_fresh_user_has_no_choice(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
r = client.get("/api/users/me/cookie-consent")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["essential"] is True
|
||||
assert body["analytics"] is False
|
||||
assert body["other"] is False
|
||||
assert body["recorded_at"] is None
|
||||
|
||||
|
||||
def test_put_cookie_consent_records_choice(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
r = client.put(
|
||||
"/api/users/me/cookie-consent",
|
||||
json={"essential": True, "analytics": True, "other": False},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["essential"] is True
|
||||
assert body["analytics"] is True
|
||||
assert body["other"] is False
|
||||
assert body["recorded_at"] is not None
|
||||
|
||||
# Round-trip the read endpoint.
|
||||
r = client.get("/api/users/me/cookie-consent")
|
||||
body = r.json()
|
||||
assert body["essential"] is True
|
||||
assert body["analytics"] is True
|
||||
assert body["other"] is False
|
||||
assert body["recorded_at"] is not None
|
||||
|
||||
|
||||
def test_put_cookie_consent_forces_essential_true(app_with_fake_gitea):
|
||||
"""§14.5: `essential` is permanently true at the API surface. A
|
||||
request that sets it to false is accepted (for symmetry with the
|
||||
other two flags) but persisted as true.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
r = client.put(
|
||||
"/api/users/me/cookie-consent",
|
||||
json={"essential": False, "analytics": False, "other": False},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["essential"] is True
|
||||
|
||||
# Confirm at the schema layer too — the persisted row has essential=1.
|
||||
row = db.conn().execute(
|
||||
"SELECT essential FROM cookie_consent WHERE user_id = ?",
|
||||
(2,),
|
||||
).fetchone()
|
||||
assert row["essential"] == 1
|
||||
|
||||
|
||||
def test_cookie_consent_requires_auth(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/users/me/cookie-consent")
|
||||
assert r.status_code == 401, r.text
|
||||
r = client.put(
|
||||
"/api/users/me/cookie-consent",
|
||||
json={"essential": True, "analytics": True, "other": True},
|
||||
)
|
||||
assert r.status_code == 401, r.text
|
||||
|
||||
|
||||
def test_put_cookie_consent_upserts_in_place(app_with_fake_gitea):
|
||||
"""A second PUT updates the existing row rather than inserting a new
|
||||
one. Verifies the §14.5 single-row-per-user shape.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
client.put(
|
||||
"/api/users/me/cookie-consent",
|
||||
json={"essential": True, "analytics": True, "other": False},
|
||||
)
|
||||
client.put(
|
||||
"/api/users/me/cookie-consent",
|
||||
json={"essential": True, "analytics": False, "other": True},
|
||||
)
|
||||
|
||||
rows = db.conn().execute(
|
||||
"SELECT analytics, other_cookies FROM cookie_consent WHERE user_id = ?",
|
||||
(2,),
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["analytics"] == 0
|
||||
assert rows[0]["other_cookies"] == 1
|
||||
|
||||
|
||||
def test_cookie_consent_persists_across_sign_out_in(app_with_fake_gitea):
|
||||
"""§14.5 precedence: the server row survives sign-out / sign-in.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
client.put(
|
||||
"/api/users/me/cookie-consent",
|
||||
json={"essential": True, "analytics": True, "other": True},
|
||||
)
|
||||
|
||||
# Simulate sign-out by clearing the session cookie.
|
||||
client.cookies.clear()
|
||||
|
||||
# Anonymous viewer cannot read.
|
||||
r = client.get("/api/users/me/cookie-consent")
|
||||
assert r.status_code == 401
|
||||
|
||||
# Sign back in as Alice. The server row is still there.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.get("/api/users/me/cookie-consent")
|
||||
body = r.json()
|
||||
assert body["analytics"] is True
|
||||
assert body["other"] is True
|
||||
assert body["recorded_at"] is not None
|
||||
|
||||
|
||||
def test_two_users_have_independent_rows(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
client.put(
|
||||
"/api/users/me/cookie-consent",
|
||||
json={"essential": True, "analytics": True, "other": False},
|
||||
)
|
||||
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
|
||||
client.put(
|
||||
"/api/users/me/cookie-consent",
|
||||
json={"essential": True, "analytics": False, "other": False},
|
||||
)
|
||||
|
||||
# Each user reads their own row.
|
||||
r = client.get("/api/users/me/cookie-consent").json()
|
||||
assert r["analytics"] is False # Bob's
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.get("/api/users/me/cookie-consent").json()
|
||||
assert r["analytics"] is True # Alice's
|
||||
@@ -13,13 +13,9 @@ sign-in path. The tests prove:
|
||||
* Expired codes refuse with 400.
|
||||
* Already-consumed codes refuse with 400 on re-use.
|
||||
* Wrong codes refuse with 400.
|
||||
* Allowlist gate (v0.8.0 update): v0.7.0 silently dropped requests
|
||||
for emails not on `allowed_emails`. v0.8.0 (item #6) removed
|
||||
that gate from the request path; the admission gate is now
|
||||
`permission_state` on the freshly-provisioned `users` row,
|
||||
asserted in test_beta_access_vertical.py. The tests below
|
||||
confirm v0.8.0's open-request shape for both on-list and
|
||||
off-list emails.
|
||||
* Allowlist gate: when `allowed_emails` is populated and the email
|
||||
isn't on it, the response is still 202 (no leak), but no email
|
||||
lands in the outbound buffer and verify finds no matching code.
|
||||
* Migration link: an existing OAuth-era user (with a `users.email`
|
||||
row) is linked by email on first OTC sign-in — `gitea_id` is
|
||||
preserved.
|
||||
@@ -205,45 +201,33 @@ def test_otc_request_cooldown_is_per_email_not_global(app_with_fake_gitea):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allowlist gate — v0.8.0 update
|
||||
#
|
||||
# v0.7.0 gated the OTC request endpoint on the `allowed_emails` table:
|
||||
# emails not on the list got a silent drop (still 202, but no code).
|
||||
# v0.8.0 (roadmap item #6) reverses this: the request endpoint
|
||||
# accepts any valid email and sends a code. The admission gate moves
|
||||
# to `permission_state` on the freshly-provisioned `users` row,
|
||||
# which the next-tier tests in test_beta_access_vertical.py cover.
|
||||
# The `allowed_emails` table stays in the schema as a fast-path
|
||||
# bypass for admin convenience.
|
||||
# Allowlist gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_request_admits_emails_regardless_of_allowlist_population(app_with_fake_gitea):
|
||||
"""v0.8.0: the OTC request path no longer consults `allowed_emails`.
|
||||
Whether the allowlist is empty or populated, every valid email
|
||||
receives a code; admission gates at `permission_state` post-verify.
|
||||
"""
|
||||
def test_otc_request_silently_drops_when_email_not_on_allowlist(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
# Populate the allowlist with one specific email; the v0.7.0
|
||||
# gate would have engaged here.
|
||||
# Populate the allowlist so the gate turns on.
|
||||
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("invited@example.com",))
|
||||
|
||||
# The not-on-list email still gets a code under v0.8.0.
|
||||
r = client.post("/auth/otc/request", json={"email": "stranger@example.com"})
|
||||
# Still 202 — the allowlist's state is not leaked to callers.
|
||||
assert r.status_code == 200
|
||||
assert len(_outbound_otc_codes("stranger@example.com")) == 1
|
||||
# But no email was sent, and no row landed in otc_codes.
|
||||
assert _outbound_otc_codes("stranger@example.com") == []
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM otc_codes WHERE email = ?",
|
||||
("stranger@example.com",),
|
||||
).fetchone()
|
||||
assert row is None
|
||||
|
||||
|
||||
def test_otc_request_admits_allowlisted_email(app_with_fake_gitea):
|
||||
"""v0.8.0: still works for emails that happen to be on the legacy
|
||||
allowlist — the table is no longer consulted at request time but
|
||||
populated rows are admitted alongside everyone else (since the
|
||||
gate is now open at the request surface)."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
|
||||
@@ -1,532 +0,0 @@
|
||||
"""End-to-end integration tests for the v0.10.0 user-set passcode
|
||||
vertical (§6.2, roadmap item #8).
|
||||
|
||||
After a successful OTC sign-in the user can set a passcode and use
|
||||
email + passcode for subsequent sign-ins. OTC remains the structural
|
||||
fallback — these tests prove:
|
||||
|
||||
* `/auth/passcode/set` requires an active session.
|
||||
* `/auth/passcode/check` returns `has_passcode` without leaking the
|
||||
hash, the set-at stamp, or the lockout state.
|
||||
* Happy path: OTC sign-in → set passcode → sign out → email +
|
||||
passcode signs in (no OTC roundtrip).
|
||||
* Wrong passcode increments the failure counter without locking.
|
||||
* Five consecutive failures lock the passcode path (HTTP 423) and
|
||||
persist `passcode_locked_until` on the user row.
|
||||
* The lockout expires after `passcode_locked_until`; a verify
|
||||
attempt past the window succeeds again and clears the counter.
|
||||
* The OTC path is unaffected by the passcode lockout — a user
|
||||
whose passcode is locked can still request and verify a fresh
|
||||
OTC to sign in.
|
||||
* Clearing the passcode wipes the hash; subsequent verify refuses
|
||||
with the no-passcode failure shape.
|
||||
* Setting a new passcode replaces the prior one (and resets the
|
||||
failure counter / lockout state).
|
||||
* `passcode_set_at` updates on every set call.
|
||||
* The validation denylist refuses obvious patterns (e.g. `0000`,
|
||||
`1234`).
|
||||
* Passcode length is enforced (4-20).
|
||||
|
||||
The fakes from `test_propose_vertical` give us a working app harness.
|
||||
The OTC envelope buffer from `test_otc_vertical` is reused for the
|
||||
OTC roundtrips this suite needs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers — mirror the OTC suite's outbound-buffer helpers.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _reset_outbound():
|
||||
from app import email as email_mod
|
||||
email_mod.reset_sent_envelopes()
|
||||
|
||||
|
||||
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
|
||||
from app import email as email_mod
|
||||
out = []
|
||||
for env in email_mod.sent_envelopes():
|
||||
if env.get("kind") != "otc":
|
||||
continue
|
||||
if to_address is not None and env["to"] != to_address:
|
||||
continue
|
||||
for line in env["body"].splitlines():
|
||||
tok = line.strip()
|
||||
if tok.isdigit() and len(tok) == 6:
|
||||
out.append(tok)
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _sign_in_via_otc(client, email: str) -> None:
|
||||
"""Run an OTC request+verify so the client carries an authenticated
|
||||
session. The cooldown is irrelevant on a fresh email; we don't
|
||||
need to drop it."""
|
||||
r = client.post("/auth/otc/request", json={"email": email})
|
||||
assert r.status_code == 200, r.text
|
||||
code = _outbound_otc_codes(email)[-1]
|
||||
r = client.post("/auth/otc/verify", json={"email": email, "code": code})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Set passcode — auth-required, happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_set_passcode_requires_session(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_set_passcode_after_otc_landing_persists_hash(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "alice@example.com")
|
||||
|
||||
r = client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT passcode_hash, passcode_set_at FROM users WHERE email = ? COLLATE NOCASE",
|
||||
("alice@example.com",),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row["passcode_hash"] is not None
|
||||
# Not the plaintext.
|
||||
assert row["passcode_hash"] != "secret123"
|
||||
assert row["passcode_set_at"] is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check endpoint — leak-free shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_check_endpoint_returns_false_for_unknown_email(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/auth/passcode/check", params={"email": "nobody@example.com"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"has_passcode": False}
|
||||
|
||||
|
||||
def test_check_endpoint_returns_false_for_user_without_passcode(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "bob@example.com")
|
||||
|
||||
r = client.get("/auth/passcode/check", params={"email": "bob@example.com"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"has_passcode": False}
|
||||
|
||||
|
||||
def test_check_endpoint_returns_true_after_set(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "carol@example.com")
|
||||
client.post("/auth/passcode/set", json={"passcode": "letmein9"})
|
||||
|
||||
# Drop the session so the check is read in the anonymous shape.
|
||||
client.cookies.clear()
|
||||
r = client.get("/auth/passcode/check", params={"email": "carol@example.com"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"has_passcode": True}
|
||||
# The response carries ONLY the boolean — no hash, no stamp.
|
||||
assert set(r.json().keys()) == {"has_passcode"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify path — happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_verify_passcode_signs_in_user(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "dave@example.com")
|
||||
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
client.cookies.clear()
|
||||
|
||||
r = client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "dave@example.com", "passcode": "secret123"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["authenticated"] is True
|
||||
assert me["user"]["email"] == "dave@example.com"
|
||||
assert me["user"]["has_passcode"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify path — failure modes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_verify_passcode_wrong_increments_counter_without_locking(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "erin@example.com")
|
||||
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
client.cookies.clear()
|
||||
|
||||
# Three bad attempts — under the lockout threshold.
|
||||
for _ in range(3):
|
||||
r = client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "erin@example.com", "passcode": "wrongwrong"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?",
|
||||
("erin@example.com",),
|
||||
).fetchone()
|
||||
assert row["passcode_failed_attempts"] == 3
|
||||
assert row["passcode_locked_until"] is None
|
||||
|
||||
|
||||
def test_verify_passcode_locks_after_five_failures(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "frank@example.com")
|
||||
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
client.cookies.clear()
|
||||
|
||||
# Five bad attempts — the last crosses the threshold and the
|
||||
# response shape flips to 423.
|
||||
statuses = []
|
||||
for _ in range(5):
|
||||
r = client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "frank@example.com", "passcode": "wrongwrong"},
|
||||
)
|
||||
statuses.append(r.status_code)
|
||||
# First four are 400, the fifth (threshold-crossing) is 423.
|
||||
assert statuses == [400, 400, 400, 400, 423]
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?",
|
||||
("frank@example.com",),
|
||||
).fetchone()
|
||||
assert row["passcode_failed_attempts"] >= 5
|
||||
assert row["passcode_locked_until"] is not None
|
||||
|
||||
# Sixth attempt — still locked, still 423, even with the correct
|
||||
# passcode (lockout overrides the verify).
|
||||
r = client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "frank@example.com", "passcode": "secret123"},
|
||||
)
|
||||
assert r.status_code == 423
|
||||
|
||||
|
||||
def test_verify_passcode_lockout_expires(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "gina@example.com")
|
||||
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
client.cookies.clear()
|
||||
|
||||
for _ in range(5):
|
||||
client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "gina@example.com", "passcode": "wrongwrong"},
|
||||
)
|
||||
|
||||
# Backdate the lockout to the past so the next attempt clears it.
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET passcode_locked_until = datetime('now', '-1 minute')
|
||||
WHERE email = ?
|
||||
""",
|
||||
("gina@example.com",),
|
||||
)
|
||||
|
||||
r = client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "gina@example.com", "passcode": "secret123"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Lockout cleared, counter reset.
|
||||
row = db.conn().execute(
|
||||
"SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?",
|
||||
("gina@example.com",),
|
||||
).fetchone()
|
||||
assert row["passcode_failed_attempts"] == 0
|
||||
assert row["passcode_locked_until"] is None
|
||||
|
||||
|
||||
def test_otc_path_unaffected_by_passcode_lockout(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Drop the OTC cooldown so the second request lands without a 429.
|
||||
# The cooldown is re-read from env on every `request_code` call so
|
||||
# this takes effect mid-process.
|
||||
monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0")
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "harvey@example.com")
|
||||
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
client.cookies.clear()
|
||||
|
||||
# Lock the passcode path.
|
||||
for _ in range(5):
|
||||
client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "harvey@example.com", "passcode": "wrongwrong"},
|
||||
)
|
||||
|
||||
# The OTC path is unaffected by the passcode lockout: the user
|
||||
# can still request and verify a fresh code to sign in.
|
||||
r = client.post("/auth/otc/request", json={"email": "harvey@example.com"})
|
||||
assert r.status_code == 200
|
||||
code = _outbound_otc_codes("harvey@example.com")[-1]
|
||||
r = client.post("/auth/otc/verify", json={"email": "harvey@example.com", "code": code})
|
||||
assert r.status_code == 200
|
||||
|
||||
# The user is now signed in via OTC even though the passcode
|
||||
# path is locked. The /api/auth/me payload reflects this.
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["authenticated"] is True
|
||||
assert me["user"]["email"] == "harvey@example.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Clear + replace
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_clear_passcode_wipes_the_hash(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "ivy@example.com")
|
||||
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
|
||||
r = client.delete("/auth/passcode")
|
||||
assert r.status_code == 200
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT passcode_hash, passcode_set_at FROM users WHERE email = ?",
|
||||
("ivy@example.com",),
|
||||
).fetchone()
|
||||
assert row["passcode_hash"] is None
|
||||
assert row["passcode_set_at"] is None
|
||||
|
||||
# Verify against the cleared passcode refuses (no-passcode shape
|
||||
# collapses to a generic 400).
|
||||
client.cookies.clear()
|
||||
r = client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "ivy@example.com", "passcode": "secret123"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_setting_new_passcode_replaces_old(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "jane@example.com")
|
||||
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
# Replace.
|
||||
r = client.post("/auth/passcode/set", json={"passcode": "newsecret9"})
|
||||
assert r.status_code == 200
|
||||
|
||||
client.cookies.clear()
|
||||
|
||||
# Old passcode refuses.
|
||||
r = client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "jane@example.com", "passcode": "secret123"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
# New passcode signs in.
|
||||
r = client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "jane@example.com", "passcode": "newsecret9"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_setting_new_passcode_resets_lockout(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0")
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "kate@example.com")
|
||||
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
|
||||
# Lock the passcode path with bad attempts (drop session first).
|
||||
client.cookies.clear()
|
||||
for _ in range(5):
|
||||
client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "kate@example.com", "passcode": "wrongwrong"},
|
||||
)
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT passcode_locked_until FROM users WHERE email = ?",
|
||||
("kate@example.com",),
|
||||
).fetchone()
|
||||
assert row["passcode_locked_until"] is not None
|
||||
|
||||
# Sign back in via OTC and reset the passcode.
|
||||
r = client.post("/auth/otc/request", json={"email": "kate@example.com"})
|
||||
assert r.status_code == 200
|
||||
code = _outbound_otc_codes("kate@example.com")[-1]
|
||||
r = client.post("/auth/otc/verify", json={"email": "kate@example.com", "code": code})
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.post("/auth/passcode/set", json={"passcode": "freshcode9"})
|
||||
assert r.status_code == 200
|
||||
|
||||
# Lockout cleared on set.
|
||||
row = db.conn().execute(
|
||||
"SELECT passcode_locked_until, passcode_failed_attempts FROM users WHERE email = ?",
|
||||
("kate@example.com",),
|
||||
).fetchone()
|
||||
assert row["passcode_locked_until"] is None
|
||||
assert row["passcode_failed_attempts"] == 0
|
||||
|
||||
|
||||
def test_passcode_set_at_updates_on_each_set(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
import time
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "luke@example.com")
|
||||
|
||||
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
first_stamp = db.conn().execute(
|
||||
"SELECT passcode_set_at FROM users WHERE email = ?",
|
||||
("luke@example.com",),
|
||||
).fetchone()["passcode_set_at"]
|
||||
assert first_stamp is not None
|
||||
|
||||
# SQLite's datetime('now') has second precision; sleep so the
|
||||
# stamp visibly advances on the next set.
|
||||
time.sleep(1.1)
|
||||
|
||||
client.post("/auth/passcode/set", json={"passcode": "newcode99"})
|
||||
second_stamp = db.conn().execute(
|
||||
"SELECT passcode_set_at FROM users WHERE email = ?",
|
||||
("luke@example.com",),
|
||||
).fetchone()["passcode_set_at"]
|
||||
assert second_stamp is not None
|
||||
assert second_stamp >= first_stamp
|
||||
# Lexicographic compare on ISO-8601 datetime strings works for
|
||||
# the SQLite shape.
|
||||
assert second_stamp > first_stamp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_set_passcode_refuses_too_short(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "mia@example.com")
|
||||
r = client.post("/auth/passcode/set", json={"passcode": "abc"})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_set_passcode_refuses_denylist_pattern(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "nick@example.com")
|
||||
for bad in ["0000", "1234", "aaaa", "qwerty", "password"]:
|
||||
r = client.post("/auth/passcode/set", json={"passcode": bad})
|
||||
assert r.status_code == 422, f"expected 422 for {bad!r}, got {r.status_code}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth me payload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_auth_me_carries_has_passcode_flag(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "olga@example.com")
|
||||
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["has_passcode"] is False
|
||||
assert me["user"]["passcode_set_at"] is None
|
||||
|
||||
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["has_passcode"] is True
|
||||
assert me["user"]["passcode_set_at"] is not None
|
||||
@@ -1,220 +0,0 @@
|
||||
"""End-to-end integration tests for the v0.12.0 CloudFlare Turnstile
|
||||
gate on `/auth/otc/request` (§6.2 / roadmap item #10).
|
||||
|
||||
The release gates the OTC request endpoint behind a one-step
|
||||
browser-side Turnstile challenge before the bcrypt hash + SMTP send.
|
||||
The tests prove:
|
||||
|
||||
* Happy path: with the secret set, a valid token admits the request
|
||||
and the OTC envelope lands.
|
||||
* Failure path: with the secret set, a token siteverify rejects
|
||||
refuses the request with 400 and produces no envelope.
|
||||
* Missing-token: with the secret set, a request without a token
|
||||
refuses with 400.
|
||||
* Missing-secret-soft: with the secret unset AND
|
||||
`TURNSTILE_REQUIRED=false` (the v0.12.0 default), the request
|
||||
admits — this is the dev / "operator hasn't wired it yet" path.
|
||||
* Missing-secret-hard: with the secret unset AND
|
||||
`TURNSTILE_REQUIRED=true`, the request refuses with 500
|
||||
"auth misconfigured" — the production fail-closed path once
|
||||
the operator has flipped the policy.
|
||||
|
||||
The Turnstile siteverify call is mocked at the `httpx.post` boundary
|
||||
inside `app.turnstile` so no real keys are needed and no real
|
||||
CloudFlare call is made. The Gitea fakes from `test_propose_vertical`
|
||||
remain in scope so the rest of the app boots cleanly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
def _reset_outbound():
|
||||
from app import email as email_mod
|
||||
email_mod.reset_sent_envelopes()
|
||||
|
||||
|
||||
def _outbound_otc_envelopes(to_address: str | None = None) -> list[dict]:
|
||||
from app import email as email_mod
|
||||
out = []
|
||||
for env in email_mod.sent_envelopes():
|
||||
if env.get("kind") != "otc":
|
||||
continue
|
||||
if to_address is not None and env["to"] != to_address:
|
||||
continue
|
||||
out.append(env)
|
||||
return out
|
||||
|
||||
|
||||
def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | None = None):
|
||||
"""Replace `httpx.post` inside `app.turnstile` with a stub that
|
||||
returns the requested success shape. The stub does not touch the
|
||||
real CloudFlare endpoint and never sees a real secret.
|
||||
"""
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, *, data=None, timeout=None, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["data"] = data
|
||||
body = {"success": bool(success)}
|
||||
if error_codes is not None:
|
||||
body["error-codes"] = error_codes
|
||||
return SimpleNamespace(json=lambda: body)
|
||||
|
||||
from app import turnstile as turnstile_mod
|
||||
monkeypatch.setattr(turnstile_mod.httpx, "post", fake_post)
|
||||
return captured
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Happy path: secret set, token valid → admit + OTC envelope lands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_request_admits_when_turnstile_token_is_valid(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
|
||||
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
|
||||
captured = _patch_siteverify(monkeypatch, success=True)
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
r = client.post(
|
||||
"/auth/otc/request",
|
||||
json={"email": "alice@example.com", "turnstile_token": "fake-token-abc"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
# The siteverify call was made with the secret + the token we sent.
|
||||
assert captured["data"]["secret"] == "test-secret-not-real"
|
||||
assert captured["data"]["response"] == "fake-token-abc"
|
||||
# And the OTC dispatch ran — exactly one envelope to the address.
|
||||
envs = _outbound_otc_envelopes("alice@example.com")
|
||||
assert len(envs) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Failure path: secret set, siteverify says success=false → 400 + no envelope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_request_refuses_when_turnstile_siteverify_fails(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
|
||||
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
|
||||
_patch_siteverify(monkeypatch, success=False, error_codes=["invalid-input-response"])
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
r = client.post(
|
||||
"/auth/otc/request",
|
||||
json={"email": "alice@example.com", "turnstile_token": "fake-bad-token"},
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
# The OTC bcrypt + SMTP path did not run — no envelope was buffered.
|
||||
assert _outbound_otc_envelopes("alice@example.com") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Missing-token: secret set, no token → 400 + no envelope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_request_refuses_when_turnstile_token_is_missing(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
|
||||
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
|
||||
# Even though we patch httpx.post, the missing-token check fires
|
||||
# before the siteverify call — so the patch is here only as a
|
||||
# safety net in case the implementation regresses to making the
|
||||
# network call anyway.
|
||||
_patch_siteverify(monkeypatch, success=False)
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
r = client.post(
|
||||
"/auth/otc/request",
|
||||
json={"email": "alice@example.com"}, # no turnstile_token field at all
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
assert _outbound_otc_envelopes("alice@example.com") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Missing-secret-soft: no secret, TURNSTILE_REQUIRED=false (default) → admit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_request_admits_when_secret_unset_and_not_required(app_with_fake_gitea, monkeypatch):
|
||||
"""v0.12.0 default: the operator has not yet wired the Turnstile
|
||||
secret and has not enabled `TURNSTILE_REQUIRED`. The gate stays
|
||||
open — this is the dev / test / pre-rollout path. Once the
|
||||
operator confirms the secret is in place and flips
|
||||
`TURNSTILE_REQUIRED=true`, missing-secret becomes fail-closed
|
||||
(covered in test_otc_request_refuses_when_required_but_secret_unset).
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
|
||||
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
|
||||
# The httpx.post inside turnstile must not be called in this path —
|
||||
# patch it to a sentinel that explodes if it ever runs.
|
||||
from app import turnstile as turnstile_mod
|
||||
|
||||
def must_not_be_called(*a, **kw):
|
||||
raise AssertionError("siteverify should not run when no secret is configured")
|
||||
|
||||
monkeypatch.setattr(turnstile_mod.httpx, "post", must_not_be_called)
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
r = client.post(
|
||||
"/auth/otc/request",
|
||||
json={"email": "alice@example.com"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
# The OTC path ran end-to-end — one envelope to the address.
|
||||
assert len(_outbound_otc_envelopes("alice@example.com")) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Missing-secret-hard: no secret, TURNSTILE_REQUIRED=true → 500 "misconfigured"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_request_refuses_when_required_but_secret_unset(app_with_fake_gitea, monkeypatch):
|
||||
"""Once the operator has flipped `TURNSTILE_REQUIRED=true` to lock
|
||||
down production, a missing secret stops being a soft-fail and
|
||||
becomes a fail-closed 500. This is the regression-detection shape
|
||||
the §20.4 upgrade-steps MAY block calls out — flip the flag once
|
||||
the secret is wired so a future config drift fails loudly instead
|
||||
of silently disabling abuse defense.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
|
||||
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
r = client.post(
|
||||
"/auth/otc/request",
|
||||
json={"email": "alice@example.com", "turnstile_token": "doesnt-matter"},
|
||||
)
|
||||
assert r.status_code == 500, r.text
|
||||
assert _outbound_otc_envelopes("alice@example.com") == []
|
||||
@@ -24,41 +24,3 @@ VITE_APP_NAME=
|
||||
# VITE_BETA_CONTACT=ben@wiggleverse.org
|
||||
# VITE_BETA_CONTACT=DM @ben on Matrix
|
||||
VITE_BETA_CONTACT=
|
||||
|
||||
# Optional URL to the deployment's privacy policy (v0.13.0+, SPEC §14.5).
|
||||
# The framework ships a minimal default privacy policy at `/privacy`
|
||||
# that describes the framework's stance and lists the cookies the
|
||||
# framework sets. When this var is set to an http(s) URL, the page
|
||||
# renders the framework's stub above a link to the configured URL —
|
||||
# deployments use this to layer their own policy content on top
|
||||
# without forking the framework. Unset is OK; the stub is sufficient
|
||||
# for a deployment that has nothing specific to add.
|
||||
#
|
||||
# Examples:
|
||||
# VITE_PRIVACY_POLICY_URL=https://wiggleverse.org/privacy
|
||||
VITE_PRIVACY_POLICY_URL=
|
||||
|
||||
# Optional URL to the deployment's cookies policy (v0.13.0+, SPEC §14.6).
|
||||
# Same shape as VITE_PRIVACY_POLICY_URL. The framework's default
|
||||
# `/cookies` page lists exactly which cookies the framework sets
|
||||
# (rfc_session, the consent-choice localStorage entry); a deployment
|
||||
# that adds its own cookies (analytics SDK once #13 lands, third-party
|
||||
# embeds) points this var at a page that documents the full list.
|
||||
# Unset is OK; the stub is sufficient for a default-config deployment.
|
||||
#
|
||||
# Examples:
|
||||
# VITE_COOKIES_POLICY_URL=https://wiggleverse.org/cookies
|
||||
VITE_COOKIES_POLICY_URL=
|
||||
|
||||
# v0.12.0 / roadmap item #10: CloudFlare Turnstile site key (public).
|
||||
# Provision a Turnstile site at dash.cloudflare.com → Turnstile → Add
|
||||
# site. The site key (this var) is embedded into the frontend bundle at
|
||||
# build time and rendered by the Turnstile widget on the /login email-
|
||||
# entry step. The secret key (private) lives in the backend env as
|
||||
# CLOUDFLARE_TURNSTILE_SECRET — see backend/.env.example. Leave unset
|
||||
# in dev to skip the widget; the backend's TURNSTILE_REQUIRED policy
|
||||
# decides what happens to a tokenless request.
|
||||
#
|
||||
# Examples:
|
||||
# VITE_TURNSTILE_SITE_KEY=0x4AAAAAAA...
|
||||
VITE_TURNSTILE_SITE_KEY=
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"version": "0.12.0",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rfc-app-frontend",
|
||||
"version": "0.12.0",
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.12.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -410,26 +410,6 @@
|
||||
cursor: pointer; padding: 0;
|
||||
}
|
||||
.otc-login .btn-link-quiet:hover { color: #1a1a1a; text-decoration: underline; }
|
||||
/* v0.8.0 — labels + textarea for the first-OTC profile capture step. */
|
||||
.otc-field-label {
|
||||
font-size: 12px; color: #666;
|
||||
margin: 8px 0 -4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.otc-login textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
font-size: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
.otc-login textarea:focus {
|
||||
outline: none;
|
||||
border-color: #1a1a1a;
|
||||
}
|
||||
.otc-shortcut-hint {
|
||||
color: #888; font-size: 12px; margin: 4px 0 0;
|
||||
}
|
||||
@@ -486,22 +466,6 @@
|
||||
.btn-link-quiet { color: #666; text-decoration: none; font-size: 13px; }
|
||||
.btn-link-quiet:hover { color: #1a1a1a; text-decoration: underline; }
|
||||
|
||||
/* v0.8.0 — thin "your beta access is in review" banner. Shown on every
|
||||
page (other than /beta-pending itself, which carries the larger
|
||||
form of the message). Sits just under the app header so it doesn't
|
||||
compete with the catalog rail. */
|
||||
.pending-access-banner {
|
||||
background: #fff8e0;
|
||||
border-bottom: 1px solid #e6dca0;
|
||||
color: #4a3f00;
|
||||
font-size: 13px;
|
||||
padding: 8px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.pending-access-banner a {
|
||||
color: #4a3f00; text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── §8 RFC view: three-column shape ─────────────────────────────────── */
|
||||
|
||||
.main-pane {
|
||||
@@ -1983,106 +1947,3 @@
|
||||
.discussion-readonly {
|
||||
font-size: 12px; color: #666; padding: 4px 0;
|
||||
}
|
||||
|
||||
/* §14.5 — cookie consent banner (v0.13.0) */
|
||||
.cookie-consent-banner {
|
||||
position: fixed;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
z-index: 1000;
|
||||
background: #fff;
|
||||
border-top: 1px solid #d1d5db;
|
||||
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08);
|
||||
padding: 20px 24px;
|
||||
}
|
||||
.cookie-consent-body {
|
||||
max-width: 880px; margin: 0 auto;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.cookie-consent-title {
|
||||
margin: 0; font-size: 16px; font-weight: 700; color: #111;
|
||||
}
|
||||
.cookie-consent-intro {
|
||||
margin: 0; font-size: 13px; color: #4b5563; line-height: 1.5;
|
||||
}
|
||||
.cookie-consent-choices {
|
||||
border: none; padding: 0; margin: 0;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.cookie-consent-choice {
|
||||
display: flex; gap: 10px; align-items: flex-start;
|
||||
padding: 10px 12px; border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cookie-consent-choice.is-selected {
|
||||
border-color: #111; background: #f9fafb;
|
||||
}
|
||||
.cookie-consent-choice input[type=radio] { margin-top: 3px; }
|
||||
.cookie-consent-choice-text {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.cookie-consent-choice-label {
|
||||
font-size: 13px; font-weight: 600; color: #111;
|
||||
}
|
||||
.cookie-consent-choice-desc {
|
||||
font-size: 12px; color: #6b7280; line-height: 1.5;
|
||||
}
|
||||
.cookie-consent-links {
|
||||
margin: 0; font-size: 12px; color: #6b7280;
|
||||
}
|
||||
.cookie-consent-links a { color: #111; text-decoration: underline; }
|
||||
.cookie-consent-error {
|
||||
margin: 0; font-size: 12px; color: #b91c1c;
|
||||
}
|
||||
.cookie-consent-actions {
|
||||
display: flex; gap: 8px; justify-content: flex-end;
|
||||
}
|
||||
.visually-hidden {
|
||||
position: absolute; width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px; overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
|
||||
/* §14.5 / §14.6 — privacy + cookies policy pages */
|
||||
.policy-page {
|
||||
max-width: 720px; margin: 0 auto;
|
||||
padding: 0 32px 80px;
|
||||
}
|
||||
.policy-header {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 20px 0; border-bottom: 1px solid #f3f4f6;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.policy-back {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: #6b7280; font-size: 13px; padding: 4px 8px;
|
||||
}
|
||||
.policy-back:hover { color: #111; }
|
||||
.policy-title { font-size: 13px; font-weight: 600; color: #6b7280; }
|
||||
.policy-body { line-height: 1.7; color: #111; }
|
||||
.policy-body h1 { font-size: 26px; margin: 0 0 6px; font-weight: 700; }
|
||||
.policy-body .policy-subtitle { color: #6b7280; margin: 0 0 24px; font-size: 14px; }
|
||||
.policy-body h2 { font-size: 16px; margin: 28px 0 8px; font-weight: 600; }
|
||||
.policy-body p { margin: 0 0 12px; }
|
||||
.policy-body ul { margin: 0 0 16px; padding-left: 22px; }
|
||||
.policy-body li { margin-bottom: 6px; }
|
||||
.policy-body code {
|
||||
background: #f3f4f6; padding: 1px 5px; border-radius: 3px;
|
||||
font-family: ui-monospace, monospace; font-size: 12px;
|
||||
}
|
||||
.policy-body .policy-footnote {
|
||||
margin-top: 24px; font-size: 12px; color: #6b7280;
|
||||
}
|
||||
.policy-table {
|
||||
width: 100%; border-collapse: collapse;
|
||||
font-size: 13px; margin: 8px 0 16px;
|
||||
}
|
||||
.policy-table th, .policy-table td {
|
||||
text-align: left; padding: 8px 10px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
vertical-align: top;
|
||||
}
|
||||
.policy-table th {
|
||||
font-size: 11px; text-transform: uppercase;
|
||||
color: #6b7280; letter-spacing: 0.05em; font-weight: 600;
|
||||
}
|
||||
|
||||
+4
-67
@@ -11,13 +11,9 @@ import Landing from './components/Landing.jsx'
|
||||
import Login from './components/Login.jsx'
|
||||
import BetaPending from './components/BetaPending.jsx'
|
||||
import Philosophy from './components/Philosophy.jsx'
|
||||
import Docs from './components/Docs.jsx'
|
||||
import NotificationSettings from './components/NotificationSettings.jsx'
|
||||
import Admin from './components/Admin.jsx'
|
||||
import ToastHost, { showToast } from './components/ToastHost.jsx'
|
||||
import CookieConsentBanner from './components/CookieConsentBanner.jsx'
|
||||
import Privacy from './pages/Privacy.jsx'
|
||||
import Cookies from './pages/Cookies.jsx'
|
||||
import './App.css'
|
||||
|
||||
export default function App() {
|
||||
@@ -28,19 +24,8 @@ export default function App() {
|
||||
const [inboxOpen, setInboxOpen] = useState(false)
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
const [inboxTick, setInboxTick] = useState(0)
|
||||
// §14.5: a tick that, when bumped, asks <CookieConsentBanner> to
|
||||
// re-open even if the user has already made a choice. The settings
|
||||
// "Privacy & cookies" tab dispatches a `rfc-app:cookie-consent-reopen`
|
||||
// event that bumps this.
|
||||
const [consentReopenTick, setConsentReopenTick] = useState(0)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setConsentReopenTick(t => t + 1)
|
||||
window.addEventListener('rfc-app:cookie-consent-reopen', handler)
|
||||
return () => window.removeEventListener('rfc-app:cookie-consent-reopen', handler)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
getMe()
|
||||
.then(setMe)
|
||||
@@ -88,15 +73,11 @@ export default function App() {
|
||||
// The deployment is in private beta: anonymous visitors get the full
|
||||
// app in read-only mode (viewer = null is passed through to every
|
||||
// component), and write affordances are hidden at the component
|
||||
// level. v0.8.0 (§6.1 / item #6): authenticated users with
|
||||
// `permission_state='pending'` also pass through as `viewer` with
|
||||
// their state attached — every write-gated affordance reads the
|
||||
// state and treats pending the same as anonymous, while reads
|
||||
// remain open. The /beta-pending page is the home root for a
|
||||
// pending user.
|
||||
// level. /beta-pending is the post-OAuth-rejection page reachable by
|
||||
// anyone. The original §14.1 Landing surface is retained for the
|
||||
// `/welcome` URL only, in case a deployment wants to link to it.
|
||||
const viewer = me?.authenticated ? me.user : null
|
||||
const isAdmin = viewer && (viewer.role === 'owner' || viewer.role === 'admin')
|
||||
const isPending = viewer && viewer.permission_state === 'pending'
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
@@ -112,9 +93,6 @@ export default function App() {
|
||||
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
||||
About
|
||||
</Link>
|
||||
<Link to="/docs" className="header-about" title="User guide">
|
||||
Docs
|
||||
</Link>
|
||||
{viewer && (
|
||||
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
|
||||
Settings
|
||||
@@ -150,18 +128,12 @@ export default function App() {
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{isPending && <PendingAccessBanner />}
|
||||
<div className="app-body">
|
||||
<Routes>
|
||||
<Route path="/welcome" element={<Landing />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
|
||||
<Route path="/beta-pending" element={<BetaPending />} />
|
||||
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
||||
<Route path="/docs" element={<DocsWithSidebar viewer={viewer} />} />
|
||||
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
|
||||
Available to anonymous and authenticated viewers alike. */}
|
||||
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
|
||||
<Route path="/cookies" element={<PolicyShell><Cookies /></PolicyShell>} />
|
||||
{viewer && (
|
||||
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={viewer} />} />
|
||||
)}
|
||||
@@ -202,18 +174,10 @@ export default function App() {
|
||||
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
|
||||
)}
|
||||
<ToastHost />
|
||||
<CookieConsentBanner viewer={viewer} forceOpen={consentReopenTick} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PolicyShell({ children }) {
|
||||
// §14.5 / §14.6 policy pages reuse the chrome-pane shape so they
|
||||
// render full-width without the catalog rail. The components inside
|
||||
// carry their own back affordance per Philosophy.jsx's pattern.
|
||||
return <main className="chrome-pane">{children}</main>
|
||||
}
|
||||
|
||||
function PhilosophyWithSidebar({ viewer }) {
|
||||
// The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
|
||||
// all use the full app body — no catalog left pane, no propose modal.
|
||||
@@ -226,14 +190,6 @@ function PhilosophyWithSidebar({ viewer }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DocsWithSidebar({ viewer }) {
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
<Docs authenticated={!!viewer} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationSettingsWithSidebar({ viewer }) {
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
@@ -250,26 +206,7 @@ function AdminWithSidebar({ viewer }) {
|
||||
)
|
||||
}
|
||||
|
||||
function PendingAccessBanner() {
|
||||
// v0.8.0 — thin banner shown on every page (other than /beta-pending
|
||||
// itself, which carries the same message in larger form) when the
|
||||
// signed-in user's `permission_state='pending'`. Sign-out works
|
||||
// normally via the header affordance.
|
||||
return (
|
||||
<div className="pending-access-banner">
|
||||
Your beta access request is in review.{' '}
|
||||
<Link to="/beta-pending">Learn more →</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Welcome({ viewer }) {
|
||||
// v0.8.0 — a pending user landing on "/" gets the same page they'd
|
||||
// see at /beta-pending, inline. This is the post-OTC home root for
|
||||
// a user awaiting admin grant.
|
||||
if (viewer && viewer.permission_state === 'pending') {
|
||||
return <BetaPending viewer={viewer} />
|
||||
}
|
||||
if (!viewer) {
|
||||
return (
|
||||
<div className="welcome">
|
||||
|
||||
+2
-90
@@ -31,19 +31,11 @@ export async function getMe() {
|
||||
// migration — the new UI just no longer points at it primarily. These
|
||||
// two helpers drive the Login.jsx surface.
|
||||
|
||||
export async function requestOtc(email, { turnstileToken } = {}) {
|
||||
// v0.12.0 / roadmap item #10: when the Turnstile widget has produced
|
||||
// a token, send it alongside the email so the backend can siteverify
|
||||
// before the OTC dispatch. The backend treats a missing token as
|
||||
// either soft-fail (no secret wired AND TURNSTILE_REQUIRED=false)
|
||||
// or hard-fail (verification required) — the frontend stays
|
||||
// uninvolved in the policy.
|
||||
const body = { email }
|
||||
if (turnstileToken) body.turnstile_token = turnstileToken
|
||||
export async function requestOtc(email) {
|
||||
const res = await fetch('/auth/otc/request', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
@@ -57,65 +49,6 @@ export async function verifyOtc(email, code) {
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── v0.8.0: open beta-access request flow (§6.1 / §14.1) ─────────────────
|
||||
//
|
||||
// On the first OTC sign-in, the user lands in `permission_state='pending'`
|
||||
// and `/api/auth/me` reports `needs_profile=true`. The Login.jsx surface
|
||||
// then prompts for first/last/why and POSTs them here. After this lands,
|
||||
// the user sees the /beta-pending page until an admin grants access.
|
||||
|
||||
export async function submitBetaRequest({ first_name, last_name, beta_request_reason }) {
|
||||
const res = await fetch('/api/auth/me/beta-request', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ first_name, last_name, beta_request_reason }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8) ─────────
|
||||
//
|
||||
// After a successful OTC sign-in, a contributor may set a passcode and
|
||||
// use email + passcode for subsequent sign-ins. OTC remains the
|
||||
// forgot-passcode fallback — 5 consecutive verify failures locks the
|
||||
// passcode path for 15 minutes (HTTP 423); the OTC path is unaffected.
|
||||
|
||||
export async function checkPasscode(email) {
|
||||
// Anonymous endpoint. Returns `{has_passcode: boolean}` so the
|
||||
// Login.jsx flow can decide whether to render a passcode input or
|
||||
// fall back to OTC. We URL-encode the email so addresses with '+'
|
||||
// round-trip cleanly.
|
||||
const params = new URLSearchParams({ email })
|
||||
const res = await fetch(`/auth/passcode/check?${params}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function verifyPasscode(email, passcode) {
|
||||
const res = await fetch('/auth/passcode/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, passcode }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function setPasscode(passcode) {
|
||||
// Requires an active session — the server returns 401 if not signed
|
||||
// in. The signed-in user is the implicit subject; the body carries
|
||||
// only the new passcode.
|
||||
const res = await fetch('/auth/passcode/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ passcode }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function clearPasscode() {
|
||||
const res = await fetch('/auth/passcode', { method: 'DELETE' })
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function listRFCs() {
|
||||
return jsonOrThrow(await fetch('/api/rfcs'))
|
||||
}
|
||||
@@ -599,23 +532,6 @@ export async function setQuietHours({ start, end, timezone } = {}) {
|
||||
}))
|
||||
}
|
||||
|
||||
// v0.13.0 / roadmap item #11: cookie consent (SPEC §14.5).
|
||||
export async function getCookieConsent() {
|
||||
return jsonOrThrow(await fetch('/api/users/me/cookie-consent'))
|
||||
}
|
||||
|
||||
export async function setCookieConsent({ analytics, other } = {}) {
|
||||
return jsonOrThrow(await fetch('/api/users/me/cookie-consent', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
essential: true,
|
||||
analytics: !!analytics,
|
||||
other: !!other,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function muteUser(userId) {
|
||||
return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'POST' }))
|
||||
}
|
||||
@@ -640,10 +556,6 @@ export async function getPhilosophy() {
|
||||
return jsonOrThrow(await fetch('/api/philosophy'))
|
||||
}
|
||||
|
||||
export async function getDocs() {
|
||||
return jsonOrThrow(await fetch('/api/docs'))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
|
||||
// typeahead).
|
||||
|
||||
@@ -1,59 +1,38 @@
|
||||
// BetaPending.jsx — the "your request is in review" page (§6.1 / §14.1).
|
||||
// BetaPending.jsx — the post-OAuth-rejection page.
|
||||
//
|
||||
// v0.3.0 introduced this surface as the post-OAuth-rejection page (a
|
||||
// user whose email wasn't on the `allowed_emails` table bounced here).
|
||||
// v0.8.0 (roadmap item #6) repurposes it as the post-OTC pending-grant
|
||||
// page: any authenticated user whose `permission_state='pending'` lands
|
||||
// here on root visits, after a fresh-OTC profile capture, or via the
|
||||
// header "Your beta access is in review" affordance.
|
||||
//
|
||||
// The deployment supplies a contact channel via VITE_BETA_CONTACT (an
|
||||
// email, URL, or short instruction). If unset, we render a generic
|
||||
// ask-the-operator line.
|
||||
// When a deployment is in private-beta mode (i.e. its `allowed_emails`
|
||||
// table has any rows), the OAuth callback redirects unrecognised users
|
||||
// here instead of provisioning them. The framework cannot know the
|
||||
// deployment operator's preferred contact channel — so the deployment
|
||||
// supplies one via VITE_BETA_CONTACT (an email, URL, or short
|
||||
// instruction). If unset, we render a generic ask-the-operator line.
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function BetaPending({ viewer }) {
|
||||
export default function BetaPending() {
|
||||
const contact = import.meta.env.VITE_BETA_CONTACT || ''
|
||||
const isPending = viewer?.permission_state === 'pending'
|
||||
return (
|
||||
<div className="beta-pending">
|
||||
<div className="beta-pending-inner">
|
||||
<h1>
|
||||
{isPending
|
||||
? 'Your request is in review.'
|
||||
: `${import.meta.env.VITE_APP_NAME} is in private Beta.`}
|
||||
</h1>
|
||||
{isPending ? (
|
||||
<>
|
||||
<p>
|
||||
Thanks for telling us a bit about yourself. An admin will
|
||||
review your request and get back to you as soon as we can.
|
||||
</p>
|
||||
<p>
|
||||
While you wait, the catalog on the left lists every super-draft
|
||||
and active RFC in the framework — reading is open. Discussion
|
||||
and contribution unlock once your access is granted.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
Discussion and contribution are gated to invited contributors for
|
||||
now. Reading is open — every super-draft, every active RFC, and
|
||||
every public conversation is visible without signing in.
|
||||
</p>
|
||||
)}
|
||||
<h1>{import.meta.env.VITE_APP_NAME} is in private Beta.</h1>
|
||||
<p>
|
||||
Discussion and contribution are gated to invited emails for now.
|
||||
Reading is open — every super-draft, every active RFC, and every
|
||||
public conversation is visible without signing in.
|
||||
</p>
|
||||
{contact ? (
|
||||
<p className="beta-pending-contact">
|
||||
Questions? Contact <strong>{contact}</strong>.
|
||||
To request access, contact <strong>{contact}</strong> with the
|
||||
email address you'd like to sign in with.
|
||||
</p>
|
||||
) : (
|
||||
<p className="beta-pending-contact">
|
||||
Questions? Contact the deployment operator.
|
||||
To request access, contact the deployment operator with the email
|
||||
address you'd like to sign in with.
|
||||
</p>
|
||||
)}
|
||||
<div className="beta-pending-actions">
|
||||
<Link className="btn-primary" to="/">Browse the catalog</Link>
|
||||
<Link className="btn-primary" to="/">Browse as a guest</Link>
|
||||
<Link className="btn-link-quiet" to="/philosophy">Read the philosophy →</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
// CookieConsentBanner.jsx — v0.13.0 / roadmap item #11 / SPEC §14.5.
|
||||
//
|
||||
// A non-modal bottom-of-page banner that asks the user once which
|
||||
// categories of cookies they accept. The framework's strictly-necessary
|
||||
// cookies (session, signed payloads) are always on; the user can opt in
|
||||
// or out of analytics (which gates the §13 SDK landing in v0.15.0) and
|
||||
// "other" (third-party embeds, social widgets if a deployment adds any).
|
||||
//
|
||||
// Visible until the user makes a choice. Hides itself once the choice
|
||||
// is recorded. The /settings/notifications "Privacy & cookies" tab
|
||||
// surfaces the current choice and re-opens the banner via `forceOpen`.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getConsent, setConsent, hasChosen, hydrateFromServer } from '../lib/consent.js'
|
||||
import { getCookieConsent, setCookieConsent } from '../api.js'
|
||||
|
||||
const CATEGORIES = [
|
||||
{
|
||||
key: 'essential-only',
|
||||
label: 'Essential only',
|
||||
description: 'Just the cookies the app needs to keep you signed in and protect submissions. (Sign-in session, signed payloads.)',
|
||||
flags: { analytics: false, other: false },
|
||||
},
|
||||
{
|
||||
key: 'essential-analytics',
|
||||
label: 'Essential + analytics',
|
||||
description: 'Adds anonymous usage analytics so the framework can see which surfaces get used. No third-party scripts beyond the analytics SDK.',
|
||||
flags: { analytics: true, other: false },
|
||||
},
|
||||
{
|
||||
key: 'essential-analytics-other',
|
||||
label: 'Essential + analytics + other',
|
||||
description: 'Adds analytics plus any third-party embeds the deployment configures (e.g. social widgets). Choose this if you want the full surface.',
|
||||
flags: { analytics: true, other: true },
|
||||
},
|
||||
]
|
||||
|
||||
function selectionKeyFor(consent) {
|
||||
if (consent.analytics && consent.other) return 'essential-analytics-other'
|
||||
if (consent.analytics && !consent.other) return 'essential-analytics'
|
||||
return 'essential-only'
|
||||
}
|
||||
|
||||
export default function CookieConsentBanner({ viewer, forceOpen, onClosed }) {
|
||||
const [open, setOpen] = useState(() => forceOpen || !hasChosen())
|
||||
const [choice, setChoice] = useState(() => selectionKeyFor(getConsent()))
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
// When forceOpen flips (settings "Change" affordance), re-render the
|
||||
// banner and pre-select the user's current choice.
|
||||
useEffect(() => {
|
||||
if (forceOpen) {
|
||||
setOpen(true)
|
||||
setChoice(selectionKeyFor(getConsent()))
|
||||
}
|
||||
}, [forceOpen])
|
||||
|
||||
// Server-side hydrate for authenticated viewers per the v0.13.0
|
||||
// precedence rule: a server row overrides local; absent server row,
|
||||
// upload the local choice.
|
||||
useEffect(() => {
|
||||
if (!viewer?.user_id) return
|
||||
let cancelled = false
|
||||
getCookieConsent()
|
||||
.then(record => {
|
||||
if (cancelled) return
|
||||
if (record.recorded_at) {
|
||||
// Server is authoritative — adopt + hide the banner unless
|
||||
// the settings page forced it open.
|
||||
hydrateFromServer(record)
|
||||
setChoice(selectionKeyFor(record))
|
||||
if (!forceOpen) setOpen(false)
|
||||
} else if (hasChosen()) {
|
||||
// Local has a choice the server doesn't know about yet — push.
|
||||
const local = getConsent()
|
||||
setCookieConsent({ analytics: local.analytics, other: local.other })
|
||||
.then(r => hydrateFromServer(r))
|
||||
.catch(() => {})
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Network or auth error — leave the local-only path in place.
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [viewer?.user_id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!open) return null
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
const picked = CATEGORIES.find(c => c.key === choice) || CATEGORIES[0]
|
||||
try {
|
||||
setConsent(picked.flags)
|
||||
if (viewer?.user_id) {
|
||||
// Best-effort server persistence. A failure here doesn't
|
||||
// invalidate the local choice; the banner still hides because
|
||||
// the user expressed their preference. The server can catch up
|
||||
// on the next sign-in via the hydrate path above.
|
||||
try {
|
||||
const r = await setCookieConsent(picked.flags)
|
||||
hydrateFromServer(r)
|
||||
} catch (e) {
|
||||
setError(`Saved locally; server sync failed (${e.message}).`)
|
||||
}
|
||||
}
|
||||
setOpen(false)
|
||||
onClosed?.()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cookie-consent-banner" role="region" aria-label="Cookie consent">
|
||||
<div className="cookie-consent-body">
|
||||
<h2 className="cookie-consent-title">Cookies & privacy</h2>
|
||||
<p className="cookie-consent-intro">
|
||||
This site uses cookies. Essential cookies keep you signed in and
|
||||
protect your submissions; analytics and other cookies are
|
||||
optional. Choose what you allow — you can change this any time
|
||||
from <Link to="/settings/notifications">Settings → Privacy & cookies</Link>.
|
||||
</p>
|
||||
<fieldset className="cookie-consent-choices">
|
||||
<legend className="visually-hidden">Cookie categories</legend>
|
||||
{CATEGORIES.map(c => (
|
||||
<label key={c.key} className={`cookie-consent-choice ${choice === c.key ? 'is-selected' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="cookie-consent-choice"
|
||||
value={c.key}
|
||||
checked={choice === c.key}
|
||||
onChange={() => setChoice(c.key)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span className="cookie-consent-choice-text">
|
||||
<span className="cookie-consent-choice-label">{c.label}</span>
|
||||
<span className="cookie-consent-choice-desc">{c.description}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<p className="cookie-consent-links">
|
||||
<Link to="/cookies">Cookies policy</Link>
|
||||
<span aria-hidden> · </span>
|
||||
<Link to="/privacy">Privacy policy</Link>
|
||||
</p>
|
||||
{error && <p className="cookie-consent-error">{error}</p>}
|
||||
<div className="cookie-consent-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save choice'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// `/docs` — the user-facing guide.
|
||||
//
|
||||
// Sibling of Philosophy.jsx: same chrome, same data path, different
|
||||
// source file. Renders DOCS.md verbatim with light chrome around it.
|
||||
// Reachable anonymously, same as `/philosophy`, so a visitor can read
|
||||
// the guide before deciding to sign in.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||
import { getDocs } from '../api.js'
|
||||
|
||||
export default function Docs({ authenticated }) {
|
||||
const [body, setBody] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
getDocs()
|
||||
.then(r => { if (active) setBody(r.body || '') })
|
||||
.catch(e => { if (active) setError(e.message || String(e)) })
|
||||
.finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="philosophy-page">
|
||||
<header className="philosophy-header">
|
||||
<button
|
||||
className="philosophy-back"
|
||||
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span className="philosophy-title">User guide</span>
|
||||
{!authenticated && (
|
||||
<Link className="philosophy-signin" to="/">Home</Link>
|
||||
)}
|
||||
</header>
|
||||
<article className="philosophy-body">
|
||||
{loading && <p className="muted">Loading…</p>}
|
||||
{error && <p className="error">Could not load the guide: {error}</p>}
|
||||
{!loading && !error && (
|
||||
<MarkdownPreview content={body} />
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,131 +1,41 @@
|
||||
// Login.jsx — the composed sign-in surface (§6.2) after the v0.12.0
|
||||
// (CloudFlare Turnstile gate on OTC dispatch, roadmap item #10) /
|
||||
// v0.10.0 (passcodes, roadmap item #8) rebase onto v0.8.0
|
||||
// (beta-access-request capture, §6.1 / §14.1, roadmap item #6). v0.7.0
|
||||
// (roadmap item #5) established the email + OTC scaffolding the later
|
||||
// releases extended.
|
||||
// Login.jsx — v0.7.0's primary sign-in surface (§6.2).
|
||||
//
|
||||
// v0.12.0: the email-entry step renders a Turnstile widget. The token
|
||||
// it produces is sent to `/auth/otc/request` alongside the email. The
|
||||
// passcode step also renders a widget for the "Use a code instead"
|
||||
// fallback dispatch (same backend endpoint, same gate). When the
|
||||
// `VITE_TURNSTILE_SITE_KEY` build var is unset, the widget renders
|
||||
// nothing — the form still submits and the backend's TURNSTILE_REQUIRED
|
||||
// policy decides admission.
|
||||
// Two-step:
|
||||
// 1. Enter email → POST /auth/otc/request → on 200, advance.
|
||||
// On 429 (rate-limit), surface a "wait a moment" hint and keep
|
||||
// the user on step 1.
|
||||
// 2. Enter the six-digit code from the email → POST /auth/otc/verify
|
||||
// → on 200, redirect to the post-login landing. Cmd/Ctrl+Enter
|
||||
// on the code field is the keyboard shortcut.
|
||||
//
|
||||
// Four-to-six-step flow (most users see three; the longest path is
|
||||
// pending-user with no passcode, who never sees the passcode steps):
|
||||
// Server-side, /auth/otc/request always returns 202 for an unrecognized
|
||||
// email (so the allowlist gate doesn't leak), so this surface never
|
||||
// distinguishes "we couldn't reach you" from "we don't know you" —
|
||||
// it just advances to step 2. If a user is genuinely blocked, the
|
||||
// code never arrives.
|
||||
//
|
||||
// 1. 'email' Enter email → GET /auth/passcode/check.
|
||||
// * has_passcode=true → step 'passcode'.
|
||||
// * has_passcode=false → POST /auth/otc/request,
|
||||
// step 'code'.
|
||||
// 429 on either dispatch surfaces a "wait a
|
||||
// moment" hint and keeps the user on step 1.
|
||||
//
|
||||
// 2a. 'passcode' Enter passcode → POST /auth/passcode/verify.
|
||||
// * 200 → redirect to "/".
|
||||
// * 423 (lockout, 5 consecutive failures) →
|
||||
// auto-fall back to OTC by requesting a fresh
|
||||
// code and advancing to step 'code'.
|
||||
// * 400 → wrong passcode; user can retry or
|
||||
// click "Use a code instead" to fall back
|
||||
// manually.
|
||||
//
|
||||
// 2b. 'code' Enter the six-digit code → POST /auth/otc/verify.
|
||||
// On 200, fetch /api/auth/me and branch:
|
||||
// * needs_profile === true → 'capture-profile'
|
||||
// * has_passcode === false → 'offer-passcode'
|
||||
// * otherwise → redirect to "/".
|
||||
// needs_profile WINS over has_passcode — a
|
||||
// pending user goes through the §6.1 capture
|
||||
// flow first; setting a passcode while waiting
|
||||
// for admin grant gains them nothing.
|
||||
// Cmd/Ctrl+Enter on the code field is the
|
||||
// keyboard shortcut.
|
||||
//
|
||||
// 3. 'capture-profile' (v0.8.0, §6.1) First name, last name, and "why
|
||||
// I should be included in the beta" → POST
|
||||
// /api/auth/me/beta-request → redirect to
|
||||
// /beta-pending. The user's row stays
|
||||
// permission_state='pending' until an admin
|
||||
// grants access; they can set a passcode later
|
||||
// from settings, or on a future sign-in once
|
||||
// granted.
|
||||
//
|
||||
// 4a. 'offer-passcode' (v0.10.0) "Set a passcode for faster sign-in
|
||||
// next time?" Yes → 'set-passcode'. Skip → "/".
|
||||
//
|
||||
// 4b. 'set-passcode' Pick a passcode (4–20 chars) → POST
|
||||
// /auth/passcode/set → redirect to "/". A
|
||||
// "Skip for now" link also redirects to "/".
|
||||
//
|
||||
// Server-side, /auth/otc/request returns 202 uniformly and
|
||||
// /auth/passcode/check returns has_passcode=false for an unknown
|
||||
// email, so this surface never distinguishes "we couldn't reach you"
|
||||
// from "we don't know you" — an unknown email always lands in the
|
||||
// OTC path with no account-enumeration signal.
|
||||
//
|
||||
// The legacy Gitea OAuth callback remains at /auth/login →
|
||||
// /auth/callback during the v0.7.0 migration; we surface a "Sign in
|
||||
// with Gitea" link as a fallback in the footer so users with active
|
||||
// OAuth sessions or older invite emails still have a path. We hide
|
||||
// the fallback on 'capture-profile' so a half-captured pending user
|
||||
// doesn't bail out into the OAuth path mid-form.
|
||||
// The legacy Gitea OAuth callback remains at /auth/login → /auth/callback
|
||||
// during the v0.7.0 migration; we surface a "Sign in with Gitea" link
|
||||
// as a fallback in the footer so users with active OAuth sessions or
|
||||
// older invite emails still have a path.
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import {
|
||||
requestOtc,
|
||||
verifyOtc,
|
||||
submitBetaRequest,
|
||||
checkPasscode,
|
||||
verifyPasscode,
|
||||
setPasscode as apiSetPasscode,
|
||||
} from '../api'
|
||||
import TurnstileWidget, { turnstileEnabled } from './TurnstileWidget'
|
||||
import { requestOtc, verifyOtc } from '../api'
|
||||
|
||||
export default function Login() {
|
||||
// Steps: 'email' → 'passcode' or 'code' → (on the OTC path, after
|
||||
// verify) one of: 'capture-profile' (pending user), 'offer-passcode'
|
||||
// (no passcode yet), or straight to "/". 'set-passcode' is reached
|
||||
// from 'offer-passcode'.
|
||||
const [step, setStep] = useState('email')
|
||||
const [email, setEmail] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [passcode, setPasscode] = useState('')
|
||||
const [newPasscode, setNewPasscode] = useState('')
|
||||
// v0.8.0 — capture-profile fields.
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
// v0.12.0: Turnstile token captured by the widget. `null` means no
|
||||
// challenge solved yet (or the site key is unset, in which case the
|
||||
// widget surfaces null on mount). The token is single-use; we clear
|
||||
// it back to null right after we send it so a second request on the
|
||||
// same form remount re-challenges. `turnstileReady` is true once the
|
||||
// widget has produced a token OR the widget is not configured at
|
||||
// build time (no site key) — the submit button reads from it so the
|
||||
// form locks up when the operator has wired Turnstile but the user
|
||||
// hasn't solved the challenge yet.
|
||||
const [turnstileToken, setTurnstileToken] = useState(null)
|
||||
const turnstileOn = turnstileEnabled()
|
||||
const turnstileReady = !turnstileOn || !!turnstileToken
|
||||
const emailRef = useRef(null)
|
||||
const codeRef = useRef(null)
|
||||
const passcodeRef = useRef(null)
|
||||
const newPasscodeRef = useRef(null)
|
||||
const firstNameRef = useRef(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (step === 'email') emailRef.current?.focus()
|
||||
else if (step === 'code') codeRef.current?.focus()
|
||||
else if (step === 'passcode') passcodeRef.current?.focus()
|
||||
else if (step === 'capture-profile') firstNameRef.current?.focus()
|
||||
else if (step === 'set-passcode') newPasscodeRef.current?.focus()
|
||||
else codeRef.current?.focus()
|
||||
}, [step])
|
||||
|
||||
async function submitEmail(e) {
|
||||
@@ -137,93 +47,20 @@ export default function Login() {
|
||||
setBusy(true)
|
||||
setStatus('')
|
||||
try {
|
||||
const { has_passcode } = await checkPasscode(email.trim())
|
||||
if (has_passcode) {
|
||||
setStep('passcode')
|
||||
setStatus('')
|
||||
} else {
|
||||
await requestOtc(email.trim(), { turnstileToken })
|
||||
// v0.12.0: the token is single-use; drop it so a re-request
|
||||
// from the code step (via "Use a different email" → back to
|
||||
// email) starts with a fresh challenge.
|
||||
setTurnstileToken(null)
|
||||
setStep('code')
|
||||
setStatus('Check your inbox — a six-digit code is on the way.')
|
||||
}
|
||||
await requestOtc(email.trim())
|
||||
setStep('code')
|
||||
setStatus('Check your inbox — a six-digit code is on the way.')
|
||||
} catch (err) {
|
||||
// Any failure consumes the token from CloudFlare's side; clear
|
||||
// so the widget re-renders a fresh challenge on retry.
|
||||
setTurnstileToken(null)
|
||||
if (err.status === 429) {
|
||||
setStatus('Slow down — wait a minute before requesting another code.')
|
||||
} else if (err.status === 400) {
|
||||
setStatus("Couldn't verify you're human. Please retry the challenge.")
|
||||
} else {
|
||||
setStatus(err.message || 'Could not start sign-in. Try again.')
|
||||
setStatus(err.message || 'Could not request a code. Try again.')
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPasscode(e) {
|
||||
if (e) e.preventDefault()
|
||||
if (!passcode.trim()) {
|
||||
setStatus('Enter your passcode.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setStatus('')
|
||||
try {
|
||||
await verifyPasscode(email.trim(), passcode.trim())
|
||||
// Reload so App.jsx's getMe() picks up the fresh session. A
|
||||
// returning passcode user is by definition already past the
|
||||
// §6.1 capture step (they couldn't have set a passcode while
|
||||
// pending), so we go straight to "/".
|
||||
window.location.assign('/')
|
||||
} catch (err) {
|
||||
if (err.status === 423) {
|
||||
// Lockout — auto-fall back to OTC. The OTC request endpoint
|
||||
// is independent of the passcode lockout, so this lands a
|
||||
// fresh code in the user's inbox immediately.
|
||||
setPasscode('')
|
||||
try {
|
||||
// v0.12.0: pass whatever token the widget on the passcode
|
||||
// step has produced. If the operator has Turnstile required
|
||||
// and the user hasn't solved the passcode-step widget, the
|
||||
// backend refuses and we bounce them back to email-entry
|
||||
// with a clear status (see catch below).
|
||||
await requestOtc(email.trim(), { turnstileToken })
|
||||
setTurnstileToken(null)
|
||||
setStep('code')
|
||||
setStatus(
|
||||
'Too many failed attempts. We sent a one-time code to your email — use it to sign in.',
|
||||
)
|
||||
} catch (e2) {
|
||||
setTurnstileToken(null)
|
||||
if (e2.status === 429) {
|
||||
setStep('code')
|
||||
setStatus(
|
||||
'Too many failed attempts. Wait a minute, then request a one-time code to sign in.',
|
||||
)
|
||||
} else if (e2.status === 400) {
|
||||
setStep('email')
|
||||
setStatus(
|
||||
'Too many failed attempts. Solve the challenge below to receive a one-time code.',
|
||||
)
|
||||
} else {
|
||||
setStatus(
|
||||
'Too many failed attempts. Use the "Use a code instead" link to sign in via email.',
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setStatus('Wrong passcode. Try again, or use a one-time code instead.')
|
||||
}
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCode(e) {
|
||||
if (e) e.preventDefault()
|
||||
if (!code.trim() || code.trim().length !== 6) {
|
||||
@@ -234,38 +71,9 @@ export default function Login() {
|
||||
setStatus('')
|
||||
try {
|
||||
await verifyOtc(email.trim(), code.trim())
|
||||
// OTC verified — the server has signed in the user. Fetch the
|
||||
// canonical /api/auth/me to decide where to land:
|
||||
// * needs_profile → §6.1 capture (then /beta-pending).
|
||||
// * no passcode → §6.2 offer-passcode (then /).
|
||||
// * otherwise → /.
|
||||
// needs_profile wins over has_passcode: a pending user can't yet
|
||||
// do anything that benefits from faster sign-in, so we don't
|
||||
// distract them with the passcode offer mid-admission.
|
||||
const meResp = await fetch('/api/auth/me', { credentials: 'include' })
|
||||
let me = null
|
||||
if (meResp.ok) {
|
||||
try {
|
||||
me = await meResp.json()
|
||||
} catch (_) {
|
||||
me = null
|
||||
}
|
||||
}
|
||||
if (me?.needs_profile === true) {
|
||||
setStep('capture-profile')
|
||||
setStatus('')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
if (me?.has_passcode === false) {
|
||||
setStep('offer-passcode')
|
||||
setStatus('')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
// Either /me returned the granted-with-passcode shape, or the
|
||||
// call failed but the session cookie is set — fall through to
|
||||
// a hard reload so App.jsx re-fetches and renders accordingly.
|
||||
// Reload so App.jsx's getMe() picks up the fresh session. We
|
||||
// navigate to "/" via a hard load so any cached "anonymous"
|
||||
// view state in memory is dropped cleanly.
|
||||
window.location.assign('/')
|
||||
} catch (err) {
|
||||
setStatus('That code is invalid or expired. Try again, or request a new code.')
|
||||
@@ -273,54 +81,6 @@ export default function Login() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitProfile(e) {
|
||||
if (e) e.preventDefault()
|
||||
const fn = firstName.trim()
|
||||
const ln = lastName.trim()
|
||||
const why = reason.trim()
|
||||
if (!fn || !ln || !why) {
|
||||
setStatus('All three fields are required.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setStatus('')
|
||||
try {
|
||||
await submitBetaRequest({
|
||||
first_name: fn,
|
||||
last_name: ln,
|
||||
beta_request_reason: why,
|
||||
})
|
||||
// Hard-load so App.jsx re-fetches /api/auth/me and picks up
|
||||
// the captured fields. The user stays permission_state='pending'
|
||||
// until an admin grants access — the next thing they should
|
||||
// see is the "your request is in review" page.
|
||||
window.location.assign('/beta-pending')
|
||||
} catch (err) {
|
||||
setStatus(err.message || 'Could not submit your request. Try again.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitNewPasscode(e) {
|
||||
if (e) e.preventDefault()
|
||||
const pc = newPasscode.trim()
|
||||
if (pc.length < 4) {
|
||||
setStatus('Passcode must be at least 4 characters.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setStatus('')
|
||||
try {
|
||||
await apiSetPasscode(pc)
|
||||
window.location.assign('/')
|
||||
} catch (err) {
|
||||
// 422 carries the validation message verbatim (denylist /
|
||||
// length); surface it as-is so the user knows what to change.
|
||||
setStatus(err.message || 'Could not set passcode. Try a different one.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function onCodeKey(e) {
|
||||
// §6.2 ergonomic: Cmd/Ctrl+Enter submits from the code field.
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
@@ -328,69 +88,12 @@ export default function Login() {
|
||||
}
|
||||
}
|
||||
|
||||
function onPasscodeKey(e) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
submitPasscode(e)
|
||||
}
|
||||
}
|
||||
|
||||
function onReasonKey(e) {
|
||||
// §6.1 ergonomic: Cmd/Ctrl+Enter submits the capture form from
|
||||
// the reason textarea (the multi-line input that would otherwise
|
||||
// swallow Enter as a newline).
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
submitProfile(e)
|
||||
}
|
||||
}
|
||||
|
||||
function onNewPasscodeKey(e) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
submitNewPasscode(e)
|
||||
}
|
||||
}
|
||||
|
||||
function backToEmail() {
|
||||
setStep('email')
|
||||
setCode('')
|
||||
setPasscode('')
|
||||
setStatus('')
|
||||
}
|
||||
|
||||
async function fallbackToOtc() {
|
||||
// Manual "Use a code instead" from the passcode step. Same shape
|
||||
// as the email-step OTC dispatch. v0.12.0: pass through whatever
|
||||
// Turnstile token the passcode-step widget has produced (or null
|
||||
// when the widget is disabled at build time).
|
||||
setBusy(true)
|
||||
setStatus('')
|
||||
try {
|
||||
await requestOtc(email.trim(), { turnstileToken })
|
||||
setTurnstileToken(null)
|
||||
setPasscode('')
|
||||
setStep('code')
|
||||
setStatus('Check your inbox — a six-digit code is on the way.')
|
||||
} catch (err) {
|
||||
setTurnstileToken(null)
|
||||
if (err.status === 429) {
|
||||
setStatus('Slow down — wait a minute before requesting another code.')
|
||||
} else if (err.status === 400) {
|
||||
// v0.12.0: the widget rejected or no token was sent. Bounce
|
||||
// the user back to the email step so they get a fresh
|
||||
// challenge alongside the email input.
|
||||
setStep('email')
|
||||
setStatus("Couldn't verify you're human. Please retry the challenge.")
|
||||
} else {
|
||||
setStatus(err.message || 'Could not request a code. Try again.')
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function skipPasscodeOffer() {
|
||||
window.location.assign('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="otc-login">
|
||||
<div className="otc-login-inner">
|
||||
@@ -398,8 +101,7 @@ export default function Login() {
|
||||
{step === 'email' && (
|
||||
<form onSubmit={submitEmail}>
|
||||
<p className="otc-hint">
|
||||
Enter your email. If you've set a passcode, you'll enter that
|
||||
next; otherwise we'll send a one-time code.
|
||||
Enter your email. We'll send you a one-time code.
|
||||
</p>
|
||||
<input
|
||||
ref={emailRef}
|
||||
@@ -411,72 +113,11 @@ export default function Login() {
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
{/*
|
||||
v0.12.0: CloudFlare Turnstile widget. Renders nothing
|
||||
when VITE_TURNSTILE_SITE_KEY is unset (and turnstileReady
|
||||
defaults to true in that case so the submit gate doesn't
|
||||
lock up). On every challenge the widget calls onToken
|
||||
with the fresh token; we feed it to /auth/otc/request.
|
||||
*/}
|
||||
<TurnstileWidget onToken={setTurnstileToken} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !email.trim() || !turnstileReady}
|
||||
>
|
||||
{busy ? 'Checking…' : 'Continue'}
|
||||
<button type="submit" disabled={busy || !email.trim()}>
|
||||
{busy ? 'Sending…' : 'Send code'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{step === 'passcode' && (
|
||||
<form onSubmit={submitPasscode}>
|
||||
<p className="otc-hint">
|
||||
Enter the passcode for <strong>{email}</strong>.
|
||||
</p>
|
||||
<input
|
||||
ref={passcodeRef}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={passcode}
|
||||
onChange={e => setPasscode(e.target.value)}
|
||||
onKeyDown={onPasscodeKey}
|
||||
placeholder="Your passcode"
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
{/*
|
||||
v0.12.0: a second Turnstile widget for the
|
||||
"Use a code instead" fallback dispatch. The passcode
|
||||
verify path does not consume a Turnstile token (it has
|
||||
its own 5-attempt lockout shape from v0.10.0), but if
|
||||
the user falls back to OTC the same /auth/otc/request
|
||||
endpoint runs and needs a token. We render the widget
|
||||
on this step too so the fallback works without bouncing
|
||||
back to email-entry first.
|
||||
*/}
|
||||
<TurnstileWidget onToken={setTurnstileToken} />
|
||||
<div className="otc-actions">
|
||||
<button type="submit" disabled={busy || !passcode.trim()}>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link-quiet"
|
||||
onClick={fallbackToOtc}
|
||||
disabled={busy || !turnstileReady}
|
||||
>
|
||||
Use a code instead
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link-quiet"
|
||||
onClick={backToEmail}
|
||||
disabled={busy}
|
||||
>
|
||||
Use a different email
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{step === 'code' && (
|
||||
<form onSubmit={submitCode}>
|
||||
<p className="otc-hint">
|
||||
@@ -514,126 +155,12 @@ export default function Login() {
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
{step === 'capture-profile' && (
|
||||
<form onSubmit={submitProfile}>
|
||||
<p className="otc-hint">
|
||||
You're signed in. {import.meta.env.VITE_APP_NAME} is in private
|
||||
beta — tell us a bit about yourself and an admin will review
|
||||
your request.
|
||||
</p>
|
||||
<label className="otc-field-label">First name</label>
|
||||
<input
|
||||
ref={firstNameRef}
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
value={firstName}
|
||||
onChange={e => setFirstName(e.target.value)}
|
||||
required
|
||||
disabled={busy}
|
||||
maxLength={120}
|
||||
/>
|
||||
<label className="otc-field-label">Last name</label>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
value={lastName}
|
||||
onChange={e => setLastName(e.target.value)}
|
||||
required
|
||||
disabled={busy}
|
||||
maxLength={120}
|
||||
/>
|
||||
<label className="otc-field-label">
|
||||
Why you'd like to be included in the beta
|
||||
</label>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={e => setReason(e.target.value)}
|
||||
onKeyDown={onReasonKey}
|
||||
required
|
||||
disabled={busy}
|
||||
rows={5}
|
||||
maxLength={4000}
|
||||
placeholder="A sentence or two is plenty."
|
||||
/>
|
||||
<div className="otc-actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !firstName.trim() || !lastName.trim() || !reason.trim()}
|
||||
>
|
||||
{busy ? 'Submitting…' : 'Submit request'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="otc-shortcut-hint">
|
||||
Tip: <kbd>⌘</kbd>+<kbd>Enter</kbd> (or <kbd>Ctrl</kbd>+<kbd>Enter</kbd>) to submit.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
{step === 'offer-passcode' && (
|
||||
<div className="otc-offer-passcode">
|
||||
<p className="otc-hint">
|
||||
You're signed in. Want to set a passcode for faster sign-in
|
||||
next time? You can always use a one-time code instead — and
|
||||
you can change or remove the passcode from your settings.
|
||||
</p>
|
||||
<div className="otc-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep('set-passcode'); setStatus('') }}
|
||||
>
|
||||
Set a passcode
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link-quiet"
|
||||
onClick={skipPasscodeOffer}
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 'set-passcode' && (
|
||||
<form onSubmit={submitNewPasscode}>
|
||||
<p className="otc-hint">
|
||||
Pick a passcode (4–20 characters). You'll use it with your
|
||||
email to sign in next time.
|
||||
</p>
|
||||
<input
|
||||
ref={newPasscodeRef}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPasscode}
|
||||
onChange={e => setNewPasscode(e.target.value)}
|
||||
onKeyDown={onNewPasscodeKey}
|
||||
placeholder="New passcode"
|
||||
required
|
||||
disabled={busy}
|
||||
minLength={4}
|
||||
maxLength={20}
|
||||
/>
|
||||
<div className="otc-actions">
|
||||
<button type="submit" disabled={busy || newPasscode.trim().length < 4}>
|
||||
{busy ? 'Saving…' : 'Save passcode'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link-quiet"
|
||||
onClick={skipPasscodeOffer}
|
||||
disabled={busy}
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{status && <p className="otc-status">{status}</p>}
|
||||
{step !== 'capture-profile' && (
|
||||
<p className="otc-fallback">
|
||||
<Link to="/philosophy">Read the philosophy →</Link>
|
||||
<span className="otc-fallback-sep">·</span>
|
||||
<a href="/auth/login">Sign in with Gitea (fallback)</a>
|
||||
</p>
|
||||
)}
|
||||
<p className="otc-fallback">
|
||||
<Link to="/philosophy">Read the philosophy →</Link>
|
||||
<span className="otc-fallback-sep">·</span>
|
||||
<a href="/auth/login">Sign in with Gitea (fallback)</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -28,12 +28,7 @@ import {
|
||||
unmuteUser,
|
||||
muteUser,
|
||||
searchUsers,
|
||||
getCookieConsent,
|
||||
getMe,
|
||||
setPasscode,
|
||||
clearPasscode,
|
||||
} from '../api.js'
|
||||
import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js'
|
||||
|
||||
const CHURN_REFUSAL = 'Per-commit and per-message email is intentionally not offered. The digest aggregates this activity weekly.'
|
||||
|
||||
@@ -53,223 +48,10 @@ export default function NotificationSettings({ viewer }) {
|
||||
<QuietHoursSection />
|
||||
<WatchesSection />
|
||||
<MutesSection viewer={viewer} />
|
||||
<SignInSection />
|
||||
<PrivacyCookiesSection />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── §6.2 sign-in (v0.10.0 / roadmap item #8): passcode management ──────────
|
||||
|
||||
function SignInSection() {
|
||||
// Source of truth for `has_passcode` and `passcode_set_at` is the
|
||||
// /api/auth/me payload (v0.10.0 added both fields). We re-read after
|
||||
// every mutation so the surface reflects what just landed.
|
||||
const [me, setMe] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [mode, setMode] = useState('idle') // 'idle' | 'set' | 'change'
|
||||
const [draft, setDraft] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [savedNote, setSavedNote] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
getMe()
|
||||
.then(payload => setMe(payload.user || null))
|
||||
.catch(e => setError(e.message))
|
||||
}, [])
|
||||
|
||||
async function refresh() {
|
||||
const payload = await getMe()
|
||||
setMe(payload.user || null)
|
||||
}
|
||||
|
||||
async function save(e) {
|
||||
if (e) e.preventDefault()
|
||||
const pc = draft.trim()
|
||||
if (pc.length < 4) {
|
||||
setError('Passcode must be at least 4 characters.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setSavedNote('')
|
||||
try {
|
||||
await setPasscode(pc)
|
||||
setDraft('')
|
||||
setMode('idle')
|
||||
setSavedNote('Passcode saved.')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
// 422 carries the validation message verbatim (denylist /
|
||||
// length); surface it as-is so the user knows what to change.
|
||||
setError(err.message || 'Could not save passcode. Try a different one.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setTimeout(() => setSavedNote(''), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!confirm('Remove your passcode? You will sign in with a one-time code next time.')) {
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setSavedNote('')
|
||||
try {
|
||||
await clearPasscode()
|
||||
setSavedNote('Passcode removed.')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not remove passcode.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setTimeout(() => setSavedNote(''), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
if (!me) return <SectionShell title="Sign-in" subtitle={error || 'Loading…'} />
|
||||
|
||||
const hasPasscode = !!me.has_passcode
|
||||
|
||||
return (
|
||||
<SectionShell
|
||||
title="Sign-in"
|
||||
subtitle="How you sign in. A passcode lets you skip the one-time-code email; the one-time-code path is always available as a fallback (and as the recovery path if you forget your passcode)."
|
||||
>
|
||||
<div className="settings-row">
|
||||
<span className="settings-note">
|
||||
<strong>Passcode:</strong>{' '}
|
||||
{hasPasscode ? 'Set.' : 'Not set — you sign in with a one-time code each time.'}
|
||||
</span>
|
||||
</div>
|
||||
{hasPasscode && me.passcode_set_at && (
|
||||
<p className="settings-note muted">Set on {me.passcode_set_at}.</p>
|
||||
)}
|
||||
|
||||
{mode === 'idle' && (
|
||||
<div className="settings-row">
|
||||
{hasPasscode ? (
|
||||
<>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => { setMode('change'); setDraft(''); setError(null) }}
|
||||
disabled={busy}
|
||||
>
|
||||
Change passcode
|
||||
</button>
|
||||
<button
|
||||
className="btn-link-muted"
|
||||
onClick={remove}
|
||||
disabled={busy}
|
||||
>
|
||||
Remove passcode
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => { setMode('set'); setDraft(''); setError(null) }}
|
||||
disabled={busy}
|
||||
>
|
||||
Set passcode
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(mode === 'set' || mode === 'change') && (
|
||||
<form className="settings-row" onSubmit={save}>
|
||||
<label>
|
||||
{mode === 'change' ? 'New passcode' : 'Passcode'}
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
placeholder="4–20 characters"
|
||||
minLength={4}
|
||||
maxLength={20}
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn-primary" type="submit" disabled={busy || draft.trim().length < 4}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link-muted"
|
||||
onClick={() => { setMode('idle'); setDraft(''); setError(null) }}
|
||||
disabled={busy}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{savedNote && <p className="settings-note">{savedNote}</p>}
|
||||
{error && <p className="settings-note warning">{error}</p>}
|
||||
</SectionShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ── §14.5 cookie / privacy consent (v0.13.0 / roadmap item #11) ────────────
|
||||
|
||||
function PrivacyCookiesSection() {
|
||||
const [consent, setConsent] = useState(() => getConsent())
|
||||
|
||||
useEffect(() => {
|
||||
// Pull the server-side row on mount; if it has a recorded_at the
|
||||
// local snapshot is updated via hydrate.
|
||||
getCookieConsent()
|
||||
.then(record => { if (record.recorded_at) hydrateFromServer(record) })
|
||||
.catch(() => {})
|
||||
return onConsentChange(next => setConsent(next))
|
||||
}, [])
|
||||
|
||||
function reopenBanner() {
|
||||
// App.jsx listens for this event and bumps the forceOpen tick on
|
||||
// <CookieConsentBanner>. The banner pre-selects the current choice
|
||||
// from the snapshot, so the user can revise rather than restart.
|
||||
window.dispatchEvent(new CustomEvent('rfc-app:cookie-consent-reopen'))
|
||||
}
|
||||
|
||||
const summary = (() => {
|
||||
if (!consent.recorded_at) {
|
||||
return 'No choice recorded — the consent banner is being shown to you.'
|
||||
}
|
||||
if (consent.analytics && consent.other) {
|
||||
return 'Essential + analytics + other.'
|
||||
}
|
||||
if (consent.analytics) {
|
||||
return 'Essential + analytics.'
|
||||
}
|
||||
return 'Essential only.'
|
||||
})()
|
||||
|
||||
return (
|
||||
<SectionShell
|
||||
title="Privacy & cookies"
|
||||
subtitle="What categories of cookies you've allowed. Essential cookies are always on; analytics and other categories are opt-in."
|
||||
>
|
||||
<div className="settings-row">
|
||||
<span className="settings-note"><strong>Current choice:</strong> {summary}</span>
|
||||
</div>
|
||||
{consent.recorded_at && (
|
||||
<p className="settings-note muted">Recorded {consent.recorded_at}.</p>
|
||||
)}
|
||||
<div className="settings-row">
|
||||
<button className="btn-primary" onClick={reopenBanner}>
|
||||
Change
|
||||
</button>
|
||||
<Link to="/cookies" className="btn-link-muted">Cookies policy</Link>
|
||||
<Link to="/privacy" className="btn-link-muted">Privacy policy</Link>
|
||||
</div>
|
||||
</SectionShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ── §15.4 email category toggles ───────────────────────────────────────────
|
||||
|
||||
function EmailPreferencesSection() {
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
// TurnstileWidget.jsx — v0.12.0 / roadmap item #10.
|
||||
//
|
||||
// Renders the CloudFlare Turnstile JS widget on the email-entry step of
|
||||
// `/login`. Reads the site key from `import.meta.env.VITE_TURNSTILE_SITE_KEY`
|
||||
// (Vite convention — VITE_* prefix is build-time embedded). When the
|
||||
// site key is unset/empty, this component renders nothing and reports
|
||||
// a `null` token through `onToken` so the parent form can still submit.
|
||||
// The backend's `TURNSTILE_REQUIRED` policy decides what happens to a
|
||||
// request that arrives without a token; the frontend is intentionally
|
||||
// not in that loop. See `backend/app/turnstile.py` for the matrix.
|
||||
//
|
||||
// The CloudFlare script is loaded once per page on first widget mount.
|
||||
// Subsequent mounts (e.g. user goes back to email-entry after a failed
|
||||
// OTC request) reuse the script tag and re-render the widget on the
|
||||
// fresh container `div`. Unmounting removes the widget instance via
|
||||
// `turnstile.remove(widgetId)` so a remount produces a new challenge
|
||||
// rather than reusing a stale, already-consumed token.
|
||||
//
|
||||
// Turnstile contract:
|
||||
// * `data-callback` fires with the token string on a successful
|
||||
// challenge; the token is single-use and expires after ~5 minutes.
|
||||
// * `data-error-callback` fires on a failed challenge (network,
|
||||
// blocked, etc.); we surface a `null` token so the parent shows
|
||||
// a retry hint.
|
||||
// * `data-expired-callback` fires when the token times out before
|
||||
// submission; we also drop to `null` and re-render so the user
|
||||
// gets a fresh challenge on retry.
|
||||
//
|
||||
// We do **not** import the CloudFlare script at build time; loading it
|
||||
// dynamically here keeps the bundle clean of an external request the
|
||||
// page may not need (anonymous viewers reading RFCs never see Login).
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
|
||||
const SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
|
||||
|
||||
// Promise-keyed: only one script tag, only one resolution chain.
|
||||
let scriptLoadPromise = null
|
||||
|
||||
function loadTurnstileScript() {
|
||||
if (typeof window === 'undefined') return Promise.resolve(null)
|
||||
if (window.turnstile) return Promise.resolve(window.turnstile)
|
||||
if (scriptLoadPromise) return scriptLoadPromise
|
||||
|
||||
scriptLoadPromise = new Promise((resolve, reject) => {
|
||||
const existing = document.querySelector(`script[src="${TURNSTILE_SCRIPT_URL}"]`)
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => resolve(window.turnstile))
|
||||
existing.addEventListener('error', reject)
|
||||
return
|
||||
}
|
||||
const script = document.createElement('script')
|
||||
script.src = TURNSTILE_SCRIPT_URL
|
||||
script.async = true
|
||||
script.defer = true
|
||||
script.addEventListener('load', () => resolve(window.turnstile))
|
||||
script.addEventListener('error', reject)
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
return scriptLoadPromise
|
||||
}
|
||||
|
||||
export function turnstileEnabled() {
|
||||
return !!SITE_KEY
|
||||
}
|
||||
|
||||
export default function TurnstileWidget({ onToken, theme = 'auto' }) {
|
||||
const containerRef = useRef(null)
|
||||
const widgetIdRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!SITE_KEY) {
|
||||
// No site key configured → surface a null token immediately so
|
||||
// the parent form's submit-disabled gate doesn't lock up
|
||||
// waiting on a challenge that will never arrive. The backend
|
||||
// decides whether a tokenless request is admitted.
|
||||
onToken?.(null)
|
||||
return undefined
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
loadTurnstileScript()
|
||||
.then(turnstile => {
|
||||
if (cancelled || !turnstile || !containerRef.current) return
|
||||
widgetIdRef.current = turnstile.render(containerRef.current, {
|
||||
sitekey: SITE_KEY,
|
||||
theme,
|
||||
callback: token => onToken?.(token),
|
||||
'error-callback': () => onToken?.(null),
|
||||
'expired-callback': () => onToken?.(null),
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
// Script load failure — surface null so the parent can decide
|
||||
// what to do (today: still let submit through; the backend
|
||||
// policy decides admission).
|
||||
if (!cancelled) onToken?.(null)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (widgetIdRef.current && window.turnstile) {
|
||||
try {
|
||||
window.turnstile.remove(widgetIdRef.current)
|
||||
} catch (_) {
|
||||
// Already gone or never registered — nothing to clean up.
|
||||
}
|
||||
widgetIdRef.current = null
|
||||
}
|
||||
}
|
||||
// We intentionally do not list `onToken` in the dependency array;
|
||||
// a parent re-rendering with a fresh closure should not tear down
|
||||
// and rebuild the widget (which would consume a fresh challenge).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
if (!SITE_KEY) return null
|
||||
return <div ref={containerRef} className="turnstile-widget" />
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
// consent.js — cookie / privacy consent state (v0.13.0, SPEC §14.5).
|
||||
//
|
||||
// The framework's analytics SDK gating (roadmap item #13, target v0.15.0)
|
||||
// will read from this module. v0.13.0 ships the storage + the banner +
|
||||
// the on-change pub/sub; no analytics SDK ships yet.
|
||||
//
|
||||
// Shape of a consent record:
|
||||
//
|
||||
// { essential: true, analytics: bool, other: bool, recorded_at: string | null }
|
||||
//
|
||||
// `essential` is always true at the API surface; it's included for
|
||||
// symmetry. `recorded_at` is null when the user has not yet made a
|
||||
// choice — the banner is shown until it's non-null.
|
||||
//
|
||||
// Precedence:
|
||||
// - Anonymous viewer: localStorage is the only source.
|
||||
// - Authenticated viewer: on sign-in, the server row (if any) overrides
|
||||
// local; if the server has no row, the local choice is uploaded.
|
||||
//
|
||||
// The fan-out is intentionally tiny — three flags. The banner writes
|
||||
// once; subscribers re-read on demand via `getConsent()` and can
|
||||
// register `onConsentChange(cb)` to be notified of subsequent updates.
|
||||
//
|
||||
// IMPORTANT: don't import this from analytics SDKs that themselves
|
||||
// set cookies on load. Read consent first, then conditionally `import()`
|
||||
// the SDK module — that's the contract item #13 will follow.
|
||||
|
||||
const LS_KEY = 'rfc-app.cookie-consent.v1'
|
||||
|
||||
const DEFAULT = Object.freeze({
|
||||
essential: true,
|
||||
analytics: false,
|
||||
other: false,
|
||||
recorded_at: null,
|
||||
})
|
||||
|
||||
const listeners = new Set()
|
||||
|
||||
function readLocal() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== 'object') return null
|
||||
return normalize(parsed)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocal(record) {
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(normalize(record)))
|
||||
} catch {
|
||||
// localStorage may be unavailable (private mode, disabled storage).
|
||||
// In that case we behave as if no choice was ever made — the banner
|
||||
// shows on every load. Acceptable per §14.5: the user can still
|
||||
// refuse to consent on each visit.
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(record) {
|
||||
return {
|
||||
essential: true,
|
||||
analytics: !!record.analytics,
|
||||
other: !!record.other,
|
||||
recorded_at: record.recorded_at || null,
|
||||
}
|
||||
}
|
||||
|
||||
// In-memory snapshot. Initialised lazily on first read so the module
|
||||
// import order doesn't matter; refreshed by `setConsent` and
|
||||
// `hydrateFromServer`.
|
||||
let _snapshot = null
|
||||
|
||||
function snapshot() {
|
||||
if (_snapshot == null) {
|
||||
_snapshot = readLocal() || { ...DEFAULT }
|
||||
}
|
||||
return _snapshot
|
||||
}
|
||||
|
||||
function emit() {
|
||||
for (const cb of listeners) {
|
||||
try { cb(snapshot()) } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the current consent record. Always returns a normalized object;
|
||||
* `recorded_at: null` means the user has not yet chosen. */
|
||||
export function getConsent() {
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
/** True if the user has made a choice. The banner uses this to decide
|
||||
* whether to render itself on load. */
|
||||
export function hasChosen() {
|
||||
return snapshot().recorded_at != null
|
||||
}
|
||||
|
||||
/** Subscribe to consent updates. Returns an unsubscribe function. */
|
||||
export function onConsentChange(cb) {
|
||||
listeners.add(cb)
|
||||
return () => listeners.delete(cb)
|
||||
}
|
||||
|
||||
/** Write a new choice locally and emit. Returns the new snapshot. The
|
||||
* server-side persistence path is handled separately by the banner /
|
||||
* settings surface via the API client; this helper is for both anon
|
||||
* and authenticated callers because localStorage is the always-on
|
||||
* layer (the server row is a backup that survives sign-out). */
|
||||
export function setConsent({ analytics = false, other = false } = {}) {
|
||||
const next = normalize({
|
||||
analytics,
|
||||
other,
|
||||
recorded_at: new Date().toISOString(),
|
||||
})
|
||||
_snapshot = next
|
||||
writeLocal(next)
|
||||
emit()
|
||||
return next
|
||||
}
|
||||
|
||||
/** Adopt a server-side record as authoritative. Called by the banner /
|
||||
* settings surface after sign-in when the server returns a non-null
|
||||
* recorded_at. Updates local + memory + emits to subscribers. */
|
||||
export function hydrateFromServer(record) {
|
||||
if (!record || !record.recorded_at) return snapshot()
|
||||
const next = normalize(record)
|
||||
_snapshot = next
|
||||
writeLocal(next)
|
||||
emit()
|
||||
return next
|
||||
}
|
||||
|
||||
/** Reset local state — used by the settings "Change" affordance to
|
||||
* re-prompt the banner. Does not touch the server row; the user must
|
||||
* re-confirm a choice and the banner uploads on save. */
|
||||
export function clearLocal() {
|
||||
try { localStorage.removeItem(LS_KEY) } catch {}
|
||||
_snapshot = { ...DEFAULT }
|
||||
emit()
|
||||
return _snapshot
|
||||
}
|
||||
|
||||
// Cross-tab sync: if another tab writes the key, mirror the change here.
|
||||
// Wrapped in a guard so SSR / non-browser test contexts don't blow up.
|
||||
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
||||
window.addEventListener('storage', e => {
|
||||
if (e.key !== LS_KEY) return
|
||||
_snapshot = readLocal() || { ...DEFAULT }
|
||||
emit()
|
||||
})
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
// Cookies.jsx — v0.13.0 / roadmap item #11 / SPEC §14.6.
|
||||
//
|
||||
// Lists the framework's cookies, by category, with each cookie's
|
||||
// purpose. Deployments override via `VITE_COOKIES_POLICY_URL` (linked
|
||||
// below the framework's stub list, same shape as the privacy page).
|
||||
//
|
||||
// Keeping the list in source makes the framework self-documenting:
|
||||
// when a future framework release adds or removes a cookie, this page
|
||||
// is the change-record. Item #13's analytics SDK will add its own row
|
||||
// to the analytics-category list in v0.15.0.
|
||||
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
|
||||
const COOKIES = [
|
||||
{
|
||||
name: 'rfc_session',
|
||||
category: 'Essential',
|
||||
purpose: "Signed session cookie that remembers who you're signed in as. itsdangerous-signed; HttpOnly; SameSite=Lax.",
|
||||
lifetime: 'Session (cleared on sign-out).',
|
||||
},
|
||||
{
|
||||
name: 'rfc-app.cookie-consent.v1',
|
||||
category: 'Essential',
|
||||
purpose: 'localStorage entry (not a cookie strictly, but tracked here for symmetry) that remembers your consent choice on this device. Cleared on browser data reset.',
|
||||
lifetime: 'Until cleared.',
|
||||
},
|
||||
]
|
||||
|
||||
export default function Cookies() {
|
||||
const navigate = useNavigate()
|
||||
const deploymentUrl = (import.meta.env.VITE_COOKIES_POLICY_URL || '').trim()
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'this deployment'
|
||||
|
||||
return (
|
||||
<div className="policy-page">
|
||||
<header className="policy-header">
|
||||
<button
|
||||
className="policy-back"
|
||||
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span className="policy-title">Cookies policy</span>
|
||||
</header>
|
||||
<article className="policy-body">
|
||||
<h1>Cookies policy</h1>
|
||||
<p className="policy-subtitle">
|
||||
What {appName} stores in your browser, by category.
|
||||
</p>
|
||||
|
||||
<h2>Categories</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Essential</strong> — required for the app to keep
|
||||
you signed in, protect submissions, and remember your
|
||||
consent choice. Cannot be switched off (without these the
|
||||
app cannot function).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Analytics</strong> — optional anonymous usage
|
||||
telemetry. Off by default; opt-in via the consent banner.
|
||||
As of v0.13.0 no analytics SDK ships; roadmap item #13
|
||||
(v0.15.0) adds one behind this gate.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Other</strong> — third-party embeds, social
|
||||
widgets, or anything else the deployment chooses to enable.
|
||||
Off by default; opt-in via the consent banner. The
|
||||
framework ships no such cookies by default.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Current cookies set by the framework</h2>
|
||||
<table className="policy-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Category</th>
|
||||
<th>Purpose</th>
|
||||
<th>Lifetime</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{COOKIES.map(c => (
|
||||
<tr key={c.name}>
|
||||
<td><code>{c.name}</code></td>
|
||||
<td>{c.category}</td>
|
||||
<td>{c.purpose}</td>
|
||||
<td>{c.lifetime}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Manage your choice</h2>
|
||||
<p>
|
||||
Change your consent any time from{' '}
|
||||
<Link to="/settings/notifications">
|
||||
Settings → Privacy & cookies
|
||||
</Link>. The "Change" affordance re-opens the consent banner
|
||||
with your current selection pre-loaded.
|
||||
</p>
|
||||
|
||||
{deploymentUrl ? (
|
||||
<>
|
||||
<h2>Deployment-specific cookies</h2>
|
||||
<p>
|
||||
This deployment may add additional cookies on top of the
|
||||
framework's. See the full deployment policy at:
|
||||
</p>
|
||||
<p>
|
||||
<a href={deploymentUrl} target="_blank" rel="noopener noreferrer">
|
||||
{deploymentUrl}
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<p className="policy-footnote">
|
||||
See also the <Link to="/privacy">privacy policy</Link>.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
// Privacy.jsx — v0.13.0 / roadmap item #11 / SPEC §14.5.
|
||||
//
|
||||
// The framework's default privacy policy page. Reachable by anonymous
|
||||
// and authenticated viewers alike at `/privacy`. The text below is a
|
||||
// minimal stub that describes the framework's stance; deployments are
|
||||
// expected to override it via the `VITE_PRIVACY_POLICY_URL` env var.
|
||||
//
|
||||
// When `VITE_PRIVACY_POLICY_URL` is set:
|
||||
// - http(s) URL → the page renders the framework's stub above a
|
||||
// "Read the full deployment policy" link to the configured URL.
|
||||
// We don't iframe-embed third-party policy hosts because their
|
||||
// Content-Security-Policy frequently refuses framing; the link is
|
||||
// the predictable affordance.
|
||||
//
|
||||
// The framework's stub is intentionally short — the rules that matter
|
||||
// to a user are: (1) what categories of cookies the app sets, (2) how
|
||||
// to change consent, (3) where to reach the deployment operator with a
|
||||
// complaint. Each deployment's content repo can carry a fuller version.
|
||||
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
|
||||
export default function Privacy() {
|
||||
const navigate = useNavigate()
|
||||
const deploymentUrl = (import.meta.env.VITE_PRIVACY_POLICY_URL || '').trim()
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'this deployment'
|
||||
|
||||
return (
|
||||
<div className="policy-page">
|
||||
<header className="policy-header">
|
||||
<button
|
||||
className="policy-back"
|
||||
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span className="policy-title">Privacy policy</span>
|
||||
</header>
|
||||
<article className="policy-body">
|
||||
<h1>Privacy policy</h1>
|
||||
<p className="policy-subtitle">
|
||||
What {appName} stores, why, and how to control it.
|
||||
</p>
|
||||
|
||||
<h2>What we store</h2>
|
||||
<p>
|
||||
{appName} runs on the Wiggleverse RFC framework. The framework
|
||||
stores the identity you sign in with (your Gitea login,
|
||||
display name, email, and avatar URL), the proposals and edits
|
||||
you author, the discussion threads you participate in, and
|
||||
your notification preferences. Authoring is public by design —
|
||||
this is a framework for public-async RFC work, and threads,
|
||||
changes, and PRs are visible to anyone who reaches the
|
||||
deployment. Settings (notification toggles, quiet hours, mute
|
||||
list, cookie consent) are private to your account.
|
||||
</p>
|
||||
|
||||
<h2>Cookies</h2>
|
||||
<p>
|
||||
The app sets a small set of cookies. The full list is on the{' '}
|
||||
<Link to="/cookies">cookies policy page</Link>. You can choose
|
||||
which categories you allow from the consent banner shown on
|
||||
your first visit or from <Link to="/settings/notifications">
|
||||
Settings → Privacy & cookies</Link> any time
|
||||
afterwards.
|
||||
</p>
|
||||
|
||||
<h2>Analytics</h2>
|
||||
<p>
|
||||
The framework supports an optional anonymous analytics layer
|
||||
gated behind your consent choice. As of v0.13.0 no analytics
|
||||
SDK ships in the framework; deployments that enable analytics
|
||||
do so via a later framework version (roadmap item #13). The
|
||||
consent toggle exists today so the gate is already in place
|
||||
when the SDK lands.
|
||||
</p>
|
||||
|
||||
<h2>Your data, your control</h2>
|
||||
<ul>
|
||||
<li>Revoke cookie consent any time from settings.</li>
|
||||
<li>
|
||||
Edit notification preferences — including the global email
|
||||
opt-out — from{' '}
|
||||
<Link to="/settings/notifications">notification settings</Link>.
|
||||
</li>
|
||||
<li>
|
||||
Your authored content (proposals, threads, edits) is public
|
||||
and not retractable from the meta-repo's Git history. If you
|
||||
need a redaction, reach the deployment operator directly.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{deploymentUrl ? (
|
||||
<>
|
||||
<h2>Deployment-specific policy</h2>
|
||||
<p>
|
||||
This deployment may layer additional policy on top of the
|
||||
framework's defaults. Read the full deployment policy at:
|
||||
</p>
|
||||
<p>
|
||||
<a href={deploymentUrl} target="_blank" rel="noopener noreferrer">
|
||||
{deploymentUrl}
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2>Deployment contact</h2>
|
||||
<p>
|
||||
For deployment-specific privacy questions — data subject
|
||||
requests, redaction requests, complaints — contact the
|
||||
operator of {appName}.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user