Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7872b921ed | |||
| de28272914 | |||
| 55beba5c0a |
+516
-105
@@ -23,6 +23,522 @@ skip versions are the composition of each intervening adjacent
|
|||||||
release's steps in order — no A-to-B path is pre-computed beyond
|
release's steps in order — no A-to-B path is pre-computed beyond
|
||||||
that.
|
that.
|
||||||
|
|
||||||
|
## 0.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.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.9.0 — 2026-05-28
|
||||||
|
|
||||||
|
**Minor — no schema migration; reuses v0.8.0 columns and existing
|
||||||
|
SMTP.** This release ships the admin user-management surface at
|
||||||
|
`/admin/users` and the new-beta-request notifications that feed
|
||||||
|
it (roadmap item #7, SPEC §6.1 / §15 / §17). The two halves
|
||||||
|
compose: admins receive an inbox + email signal the moment a
|
||||||
|
pending user submits the v0.8.0 capture form; clicking through
|
||||||
|
lands on the page where they Grant or Revoke access.
|
||||||
|
|
||||||
|
The page consumes the v0.8.0 schema columns
|
||||||
|
(`permission_state`, `first_name`, `last_name`,
|
||||||
|
`beta_request_reason`, `permission_decided_by`,
|
||||||
|
`permission_decided_at`) without adding new ones — migration
|
||||||
|
slot 016 stays reserved for a future release. The single new
|
||||||
|
write endpoint, `POST /api/admin/users/<id>/permission`,
|
||||||
|
replaces v0.8.0's documented manual `UPDATE users SET
|
||||||
|
permission_state='granted'` gesture with an audited UI flip.
|
||||||
|
|
||||||
|
Admin notifications ride the existing §15 chokepoint — the
|
||||||
|
`new_beta_request` event_kind is added to the enum with
|
||||||
|
category `admin-actionable`, fan-out is to every owner / admin
|
||||||
|
minus the requester themselves, and the §15.4 email dispatch
|
||||||
|
only reaches recipients whose `email_admin_actionable` toggle
|
||||||
|
is on (the default for owners + admins). The event is the
|
||||||
|
framework's first non-RFC-scoped notification — `rfc_slug` is
|
||||||
|
NULL and the email deep-link points `/admin/users` instead of
|
||||||
|
`/rfc/<slug>`.
|
||||||
|
|
||||||
|
Decision on `/admin/allowlist`: the surface stays as a
|
||||||
|
sibling sub-tab, not folded into `/admin/users`. The two have
|
||||||
|
different keys (allowlist by email pre-sign-up, user list by
|
||||||
|
user_id post-sign-up) and a union row would be confusing
|
||||||
|
rather than clarifying. The allowlist's fast-path-bypass role
|
||||||
|
from v0.8.0 is unchanged; retiring the table outright is
|
||||||
|
deferred to a later session (see §19.2).
|
||||||
|
|
||||||
|
### Upgrade steps (from 0.10.0)
|
||||||
|
|
||||||
|
1. **MAY** rebuild the frontend. The build is the same shape
|
||||||
|
as v0.10.0; the lockfile pins to `0.9.0` so `npm install`
|
||||||
|
in `frontend/` updates it cleanly. No new env vars on the
|
||||||
|
frontend; the existing `VITE_APP_NAME` requirement carries
|
||||||
|
over.
|
||||||
|
2. **MAY** restart the backend. No schema migration runs in
|
||||||
|
this release — every v0.9.0 column is from
|
||||||
|
`014_beta_access.sql` (v0.8.0). Restart only if you want
|
||||||
|
the new endpoints registered in this version's binary.
|
||||||
|
3. **SHOULD** verify SMTP can reach the deployment's admin
|
||||||
|
inbox before the first pending user submits the capture
|
||||||
|
form. v0.9.0 reuses the v0.7.0 SMTP configuration
|
||||||
|
(`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
|
||||||
|
`SMTP_STARTTLS`, `EMAIL_FROM`, `EMAIL_FROM_NAME`); a
|
||||||
|
misconfigured deployment will still write the inbox row,
|
||||||
|
but the admin won't hear about it through email. No new
|
||||||
|
env var is required — the framework reuses the existing
|
||||||
|
admin-user list (role IN ('owner', 'admin')) as the
|
||||||
|
notification recipients.
|
||||||
|
4. **SHOULD** announce the surface to existing admins. Wording
|
||||||
|
suggestion: "There's now a Users tab in /admin where you can
|
||||||
|
Grant or Revoke beta access — and you'll get an email +
|
||||||
|
inbox row when a fresh request lands. The manual SQL gesture
|
||||||
|
from v0.8.0 still works but is no longer the documented
|
||||||
|
path."
|
||||||
|
5. **MAY** drain the existing pending queue through the new UI.
|
||||||
|
If your deployment carried pending users through the v0.8.0
|
||||||
|
manual-UPDATE window, the Pending bucket on `/admin/users`
|
||||||
|
surfaces all of them with their captured profile. Granting
|
||||||
|
from the UI stamps `permission_decided_by` /
|
||||||
|
`permission_decided_at`, which any prior manual UPDATE
|
||||||
|
gestures may have left NULL (no harm done — the v0.8.0
|
||||||
|
contract didn't require those stamps).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`POST /api/admin/users/<id>/permission`** — body
|
||||||
|
`{state: 'pending'|'granted'|'revoked'}`. Flips the column,
|
||||||
|
stamps `permission_decided_by` + `permission_decided_at`,
|
||||||
|
and writes a `permission_events` row with event_kind in
|
||||||
|
`{permission_granted, permission_revoked,
|
||||||
|
permission_repended}`. Refuses 422 on self-flip (symmetric
|
||||||
|
to `set_mute` / `set_role` self-action refusals) and 422
|
||||||
|
on invalid state. Returns `{ok, permission_state, changed}`
|
||||||
|
where `changed=false` indicates a no-op (the requested
|
||||||
|
state already matched).
|
||||||
|
- **Widened `GET /api/admin/users` response** carrying
|
||||||
|
`permission_state`, `first_name`, `last_name`,
|
||||||
|
`beta_request_reason`, `created_at`,
|
||||||
|
`permission_decided_at`, plus joined
|
||||||
|
`permission_decided_by_login` /
|
||||||
|
`permission_decided_by_display`. Sort order surfaces
|
||||||
|
`pending` rows first (the admin queue), then `granted`,
|
||||||
|
then `revoked`; within a bucket, owners precede admins
|
||||||
|
precede contributors, with recency as the tiebreaker.
|
||||||
|
- **`new_beta_request` event_kind** in the §15.1 enum. Fired
|
||||||
|
by `notify.fan_out_new_beta_request` from the first
|
||||||
|
successful `POST /api/auth/me/beta-request` (re-submits
|
||||||
|
from the same pending user don't re-fire — the row's
|
||||||
|
first-time-complete check guards against carpet-bombing).
|
||||||
|
Recipients: every owner + admin minus the requester
|
||||||
|
themselves. Category: `admin-actionable`. Deep-link:
|
||||||
|
`/admin/users`. Actor: the requester per §15.9.
|
||||||
|
- **`/admin/users` page enhancements** in `Admin.jsx`. The
|
||||||
|
Users tab gains a state filter chip row (All / Pending /
|
||||||
|
Granted / Revoked with counts), a Grant / Revoke control
|
||||||
|
column, a Permission state badge, and an expandable
|
||||||
|
"why they want access" row beneath each pending user.
|
||||||
|
Sign-up timestamp surfaces in a new column.
|
||||||
|
- **`frontend/src/api.js#setUserPermission`** — client for
|
||||||
|
the new endpoint, neighboring `setUserMute` and
|
||||||
|
`setUserRole`.
|
||||||
|
- **`/beta-pending` copy update** in `BetaPending.jsx`. The
|
||||||
|
"your request is in review" page now honestly references
|
||||||
|
the admin-email signal v0.9.0 ships and admits the
|
||||||
|
framework does not commit to an SLA — turnaround depends
|
||||||
|
on operator availability, and the deployment operator is
|
||||||
|
the right person to ask if a wait runs long. No
|
||||||
|
deployment-specific text is baked in; per-deployment copy
|
||||||
|
lives in §13 of the deployment's repo, not in the
|
||||||
|
framework.
|
||||||
|
- **`backend/tests/test_admin_users_vertical.py`** — 10 new
|
||||||
|
tests covering: a beta-request submission fans
|
||||||
|
notifications to every admin + owner (and not to the
|
||||||
|
requester or to contributors); the requester's profile
|
||||||
|
fields land in the notification payload; re-submitting
|
||||||
|
the capture form does not re-fan; the `admin-actionable`
|
||||||
|
category mapping is wired; the `/api/admin/users`
|
||||||
|
listing carries the v0.8.0 columns with `pending` rows
|
||||||
|
sorted first; the permission-flip endpoint promotes
|
||||||
|
pending → granted with the right audit shape; the
|
||||||
|
endpoint promotes granted → revoked; the endpoint
|
||||||
|
refuses self-flip with 422; the endpoint refuses
|
||||||
|
non-admin callers with 403 and anonymous callers with
|
||||||
|
401; the endpoint refuses invalid states with 422; a
|
||||||
|
state-already-matches flip returns `changed=false`
|
||||||
|
without writing an audit row.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`backend/app/api.py`** — the `POST /api/auth/me/beta-request`
|
||||||
|
handler now calls `notify.fan_out_new_beta_request` after the
|
||||||
|
capture UPDATE lands, gated on the row not previously having
|
||||||
|
all three profile fields populated (so re-submits don't
|
||||||
|
re-fan).
|
||||||
|
- **`backend/app/api_admin.py`** — the `list_users` query joins
|
||||||
|
against `users d ON d.id = u.permission_decided_by` for the
|
||||||
|
deciding-admin handle. The new `set_permission` endpoint
|
||||||
|
lives alongside `set_role` / `set_mute`.
|
||||||
|
- **`backend/app/notify.py`** — adds
|
||||||
|
`CATEGORY_ADMIN_ACTIONABLE`, the `fan_out_new_beta_request`
|
||||||
|
helper, and the `new_beta_request` arm in `render_summary`.
|
||||||
|
- **`backend/app/email.py`** — the `_EVENT_TO_CATEGORY` map
|
||||||
|
carries `new_beta_request → admin-actionable`, and
|
||||||
|
`_deep_link` routes framework-scoped admin signals to
|
||||||
|
`/admin/users` instead of `/rfc/<slug>`.
|
||||||
|
- **`frontend/src/components/Admin.jsx`** — the `UsersTab`
|
||||||
|
component is rewritten with state filter chips, a per-row
|
||||||
|
`UserRow` + `PermissionCell` decomposition, and a
|
||||||
|
`useMemo`-cached counts table. The pending-row reason
|
||||||
|
blockquote renders as a secondary `<tr>` beneath the user
|
||||||
|
row when present.
|
||||||
|
- **`frontend/src/components/BetaPending.jsx`** — pending-state
|
||||||
|
copy revised to reference the admin email signal honestly.
|
||||||
|
- **`frontend/src/App.css`** — new admin-chip / permission-badge
|
||||||
|
/ user-row-reason rules.
|
||||||
|
- **`SPEC.md`** §6 opening, §15.1 event-kinds enum, §17 admin
|
||||||
|
endpoints, §19.2 candidates list — per §19.3 rule-2.
|
||||||
|
- **`VERSION`** → `0.9.0`. `frontend/package.json#version` and
|
||||||
|
the lockfile mirror.
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
None new. The release reuses the v0.7.0 SMTP configuration
|
||||||
|
(`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
|
||||||
|
`SMTP_STARTTLS`, `EMAIL_FROM`, `EMAIL_FROM_NAME`,
|
||||||
|
`EMAIL_ENABLED`, `EMAIL_BUNDLE_THRESHOLD`) and the existing
|
||||||
|
admin-user list (role IN ('owner', 'admin')) as the
|
||||||
|
notification recipients. A deployment whose SMTP is misconfigured
|
||||||
|
will still see the inbox rows; only the email channel is muted.
|
||||||
|
|
||||||
|
### Deferred to later releases
|
||||||
|
|
||||||
|
- **Grant / revoke notification to the user** — symmetric
|
||||||
|
signal: when an admin grants or revokes access, fire a
|
||||||
|
`personal-direct` notification (event_kind
|
||||||
|
`permission_change_affecting_me`, already in the §15.1
|
||||||
|
enum) so the affected user sees the state change in
|
||||||
|
their inbox and email. v0.9.0 audits the gesture in
|
||||||
|
`permission_events` but does not yet escape the
|
||||||
|
app-internal log to the user. See §19.2.
|
||||||
|
- **Decline-with-reason on revoke** — the current Revoke
|
||||||
|
gesture takes only a confirmation; a follow-up release
|
||||||
|
may capture a free-text reason in
|
||||||
|
`permission_events.details`. See §19.2.
|
||||||
|
- **Allowlist deprecation** — the `/admin/allowlist` sub-tab
|
||||||
|
stays in place in v0.9.0 (the two surfaces have different
|
||||||
|
keys and a union row would be confusing). Retiring the
|
||||||
|
table outright is deferred to a session that can re-read
|
||||||
|
the post-v0.9.0 operator experience and decide whether
|
||||||
|
the fast-path-bypass role is still pulling weight. See
|
||||||
|
§19.2.
|
||||||
|
|
||||||
## 0.8.0 — 2026-05-28
|
## 0.8.0 — 2026-05-28
|
||||||
|
|
||||||
**Minor — schema migration required; admission semantics shift.**
|
**Minor — schema migration required; admission semantics shift.**
|
||||||
@@ -209,107 +725,6 @@ direct DB `UPDATE`.
|
|||||||
state is wired in the schema and the auth gate; v0.9.0 ships
|
state is wired in the schema and the auth gate; v0.9.0 ships
|
||||||
the admin UI that flips the column.
|
the admin UI that flips the column.
|
||||||
|
|
||||||
## 0.13.0 — 2026-05-28
|
|
||||||
|
|
||||||
**Minor — schema migration required; new optional env vars.** This
|
|
||||||
release ships the cookie / privacy consent surface (roadmap item #11,
|
|
||||||
SPEC §14.5 / §14.6). Every viewer — authenticated and anonymous alike —
|
|
||||||
now sees a non-modal bottom-of-page banner on first visit asking which
|
|
||||||
categories of cookies they allow (essential / essential + analytics /
|
|
||||||
essential + analytics + other). The choice persists in `localStorage`
|
|
||||||
for anonymous viewers and in a new `cookie_consent` table for
|
|
||||||
authenticated viewers, with server-side overriding local on sign-in.
|
|
||||||
The framework also ships default `/privacy` and `/cookies` policy pages
|
|
||||||
that deployments can layer their own policy URL on top of via two new
|
|
||||||
optional env vars. No analytics SDK ships in this release — the
|
|
||||||
consent infrastructure is wired so item #13 (v0.15.0) can read from
|
|
||||||
`frontend/src/lib/consent.js` when the SDK lands.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Cookie consent banner** (`frontend/src/components/CookieConsentBanner.jsx`).
|
|
||||||
Non-modal, bottom of viewport. Three single-select choices with
|
|
||||||
inline descriptions. Visible until the user makes a choice; hides
|
|
||||||
thereafter. Reachable for revision via the settings surface.
|
|
||||||
- **Consent helper** (`frontend/src/lib/consent.js`). Exports
|
|
||||||
`getConsent()`, `hasChosen()`, `onConsentChange(cb)`, `setConsent()`,
|
|
||||||
`hydrateFromServer()`, `clearLocal()`. Cross-tab sync via the
|
|
||||||
`storage` event. Item #13's analytics SDK reads consent here before
|
|
||||||
importing.
|
|
||||||
- **Privacy and cookies policy pages**
|
|
||||||
(`frontend/src/pages/Privacy.jsx`, `frontend/src/pages/Cookies.jsx`).
|
|
||||||
Default minimal policies that describe the framework's stance and
|
|
||||||
list the cookies the framework sets. Deployments override via the
|
|
||||||
two new env vars below; the framework's stub always renders above
|
|
||||||
the link so the framework-level contract stays visible.
|
|
||||||
- **"Privacy & cookies" tab** in `/settings/notifications` showing
|
|
||||||
the current consent choice, the recorded-at stamp, and a "Change"
|
|
||||||
button that re-opens the banner via a custom DOM event.
|
|
||||||
- **`§17` endpoints** —
|
|
||||||
- `GET /api/users/me/cookie-consent` — read the current consent
|
|
||||||
record.
|
|
||||||
- `PUT /api/users/me/cookie-consent` — write a new consent record.
|
|
||||||
Upserts a single row per user, stamps `recorded_at` to now,
|
|
||||||
accepts `essential` for symmetry but always persists it as true.
|
|
||||||
- **Schema migration** `013_cookie_consent.sql` — new
|
|
||||||
`cookie_consent` table keyed by `user_id`, three flags
|
|
||||||
(`essential`, `analytics`, `other_cookies`), and `recorded_at`.
|
|
||||||
(Renumbered from `012_*` during driver integration because v0.7.0
|
|
||||||
also added a `012_otc.sql` migration that landed in the integration
|
|
||||||
order before this one.)
|
|
||||||
- **SPEC `§14.5` Cookie / privacy consent** — settles the banner
|
|
||||||
shape, the three-category single-select, the storage shape (local
|
|
||||||
for anon, server row for authenticated), the precedence rule on
|
|
||||||
sign-in, and the `consent.js` helper surface for downstream
|
|
||||||
callers including item #13.
|
|
||||||
- **SPEC `§14.6` Privacy and cookies policy pages** — settles the
|
|
||||||
`/privacy` and `/cookies` routes, the framework's stub content, and
|
|
||||||
the `VITE_PRIVACY_POLICY_URL` / `VITE_COOKIES_POLICY_URL` override
|
|
||||||
shape.
|
|
||||||
- **SPEC `§5`** — names the `cookie_consent` table in the canonical
|
|
||||||
app-tables list.
|
|
||||||
- **SPEC `§17`** — lists the two new cookie-consent endpoints.
|
|
||||||
- **SPEC `§19.2`** — surfaces four candidates: policy content via
|
|
||||||
content-repo file vs env var, GPC / DNT headers, multi-language
|
|
||||||
consent text, and the item #13 analytics-SDK gating dependency.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **`frontend/.env.example`** — documents the two new optional env
|
|
||||||
vars `VITE_PRIVACY_POLICY_URL` and `VITE_COOKIES_POLICY_URL`. Unset
|
|
||||||
is supported; defaults render the framework's stub.
|
|
||||||
- **`backend/app/api_notifications.py`** — module docstring grew two
|
|
||||||
endpoint lines; the new endpoints sit alongside the existing
|
|
||||||
`/api/users/me/*` neighbors.
|
|
||||||
- **`frontend/src/App.jsx`** — registers `/privacy` and `/cookies`
|
|
||||||
routes (anonymous-reachable), wires `<CookieConsentBanner>` into
|
|
||||||
the global chrome, and listens for a `rfc-app:cookie-consent-reopen`
|
|
||||||
custom event to re-open the banner from the settings surface.
|
|
||||||
|
|
||||||
### Upgrade steps (from 0.7.0)
|
|
||||||
|
|
||||||
- You **MUST** rebuild the frontend and restart the backend after
|
|
||||||
upgrading. `frontend/package.json#version` and `VERSION` both move
|
|
||||||
to `0.13.0` and the build embeds the new env-var contract.
|
|
||||||
- You **MUST** apply schema migration `013_cookie_consent.sql`. The
|
|
||||||
migration creates a single new table keyed by `user_id` with three
|
|
||||||
flag columns and a `recorded_at` stamp. The framework runs
|
|
||||||
migrations automatically at process start; no manual step is
|
|
||||||
required beyond restarting the backend so the migration runner
|
|
||||||
picks the file up.
|
|
||||||
- You **MAY** set `VITE_PRIVACY_POLICY_URL` to an http(s) URL that
|
|
||||||
points at your deployment's full privacy policy. The framework's
|
|
||||||
`/privacy` page renders its built-in stub above a link to the
|
|
||||||
configured URL. Unset is supported — the stub is sufficient for a
|
|
||||||
default-config deployment.
|
|
||||||
- You **MAY** set `VITE_COOKIES_POLICY_URL` to an http(s) URL that
|
|
||||||
points at your deployment's full cookies policy. Same shape as the
|
|
||||||
privacy URL.
|
|
||||||
- You **MAY** announce the new consent banner to your users. Existing
|
|
||||||
authenticated users will see the banner on their next visit
|
|
||||||
(because their `cookie_consent` row does not yet exist); their
|
|
||||||
current sessions remain valid.
|
|
||||||
|
|
||||||
## 0.7.0 — 2026-05-28
|
## 0.7.0 — 2026-05-28
|
||||||
|
|
||||||
**Minor — schema migration required; new auth path is additive.**
|
**Minor — schema migration required; new auth path is additive.**
|
||||||
@@ -897,7 +1312,3 @@ names itself.
|
|||||||
- `CLAUDE.md` at the repo root capturing the separation-of-concerns
|
- `CLAUDE.md` at the repo root capturing the separation-of-concerns
|
||||||
rule for working sessions.
|
rule for working sessions.
|
||||||
|
|
||||||
## 0.1.0 — v1 build
|
|
||||||
|
|
||||||
Initial release. See `docs/DEV.md` for the slicing plan and build
|
|
||||||
history.
|
|
||||||
|
|||||||
@@ -0,0 +1,605 @@
|
|||||||
|
# Using the RFC app
|
||||||
|
|
||||||
|
This is the user-facing guide to the Wiggleverse RFC framework — how to
|
||||||
|
read what's here, propose a new RFC, contribute to one that already
|
||||||
|
exists, and understand who is allowed to do what.
|
||||||
|
|
||||||
|
This guide describes the framework. Individual deployments brand and
|
||||||
|
configure themselves independently — the name in the header and the
|
||||||
|
corpus the RFCs are about belong to the deployment, not to this
|
||||||
|
document.
|
||||||
|
|
||||||
|
For the *why* of the framework, read the [philosophy](/philosophy).
|
||||||
|
For the binding technical contract, see `SPEC.md` in the repository.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reading without signing in
|
||||||
|
|
||||||
|
You can read the catalog and every public RFC without an account.
|
||||||
|
Anonymous visitors can:
|
||||||
|
|
||||||
|
- Browse the catalog of super-drafts and active RFCs.
|
||||||
|
- Open any RFC and read its canonical body.
|
||||||
|
- Read any public branch — its diff and its chat thread.
|
||||||
|
- Read any pull request — its diff, its conversation, its review
|
||||||
|
comments.
|
||||||
|
- Read the discussion that has accumulated on an RFC's main view.
|
||||||
|
|
||||||
|
Reading is open by design. The framework's claim is that the *argument
|
||||||
|
behind a definition* is the evidence that the definition was earned,
|
||||||
|
and an argument that disappears behind a sign-in wall stops carrying
|
||||||
|
that evidence.
|
||||||
|
|
||||||
|
What you cannot do without an account: chat, propose a new RFC,
|
||||||
|
create a branch, open a PR, drop a flag, or post on a discussion
|
||||||
|
thread. Every write affordance is replaced with a sign-in prompt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Signing in
|
||||||
|
|
||||||
|
While the framework is in private beta, only invited email addresses
|
||||||
|
can complete sign-in. If your email is on the allowlist, the
|
||||||
|
"Sign in" button in the header completes the flow and lands you on
|
||||||
|
the catalog with full read and write access. If your email is not on
|
||||||
|
the allowlist, you'll be sent to a short "pending" page explaining
|
||||||
|
the gate.
|
||||||
|
|
||||||
|
Once you have an account, you're a **contributor** by default — the
|
||||||
|
role that grants every write affordance the app exposes, scoped by
|
||||||
|
the per-RFC and per-branch rules described below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposing a new RFC
|
||||||
|
|
||||||
|
A new RFC begins as a proposal. The "+ Propose new RFC" button at
|
||||||
|
the bottom of the catalog opens a small modal that collects four
|
||||||
|
things:
|
||||||
|
|
||||||
|
- **Title.** The word, concept, or topic this RFC would define.
|
||||||
|
- **Slug.** A kebab-cased identifier derived from the title. It is
|
||||||
|
the entry's stable handle from this moment until it graduates;
|
||||||
|
collisions with existing entries or open proposals are caught
|
||||||
|
inline.
|
||||||
|
- **Pitch.** One or two paragraphs answering *why this RFC is
|
||||||
|
needed*. This becomes the body of the entry.
|
||||||
|
- **Tags.** Optional. The AI suggests tags from the pitch; you can
|
||||||
|
accept, dismiss, or type your own.
|
||||||
|
|
||||||
|
Submitting the modal does one concrete thing: it opens a pull
|
||||||
|
request against the framework's meta repository, adding one new
|
||||||
|
file under `rfcs/`. There is no other Git artifact and no other
|
||||||
|
side-effect. You are returned to the **pending-idea view** for the
|
||||||
|
new proposal.
|
||||||
|
|
||||||
|
A pending idea is publicly readable but not yet a super-draft. The
|
||||||
|
catalog surfaces it in a "Pending ideas" disclosure at the bottom
|
||||||
|
of the list. A conversation can accumulate on the pending-idea view
|
||||||
|
before it is admitted — contributors can argue, in public, about
|
||||||
|
whether the entry belongs in the catalog at all.
|
||||||
|
|
||||||
|
Three outcomes are possible:
|
||||||
|
|
||||||
|
- **Merge.** An admin or owner merges the proposal PR. The entry
|
||||||
|
becomes a super-draft and graduates from the "Pending ideas"
|
||||||
|
section into the main catalog. Any conversation that accumulated
|
||||||
|
on the pending-idea view migrates with it.
|
||||||
|
- **Decline.** An admin or owner declines, attaching a written
|
||||||
|
comment. You see the comment on your next visit, along with a
|
||||||
|
one-click affordance to revise and re-propose.
|
||||||
|
- **Withdraw.** You can withdraw your own proposal at any time. The
|
||||||
|
entry will not appear in any default view; the conversation that
|
||||||
|
accumulated stays attached to the closed PR as historical record.
|
||||||
|
|
||||||
|
You are automatically the first owner of any RFC you propose. The
|
||||||
|
claim flow described under [Roles & permissions](#roles--permissions)
|
||||||
|
is for *other* contributors to add themselves as owners later, not
|
||||||
|
for the proposer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What a super-draft is
|
||||||
|
|
||||||
|
A super-draft is an entry that has been admitted to the catalog but
|
||||||
|
does not yet have its own dedicated repository. Most of the
|
||||||
|
argument that shapes a definition happens here. The framework
|
||||||
|
assumes — and the philosophy explicitly invites — that many
|
||||||
|
super-drafts will not survive the argument, and that is fine. The
|
||||||
|
entries that do survive earn their place in the catalog by being
|
||||||
|
defensible in public.
|
||||||
|
|
||||||
|
Opening a super-draft from the catalog gives you the same surface
|
||||||
|
an active RFC uses:
|
||||||
|
|
||||||
|
- The canonical body in the centre, read-only by default.
|
||||||
|
- A chat thread on the right where the public conversation lives.
|
||||||
|
- A breadcrumb dropdown listing any in-flight edit branches and
|
||||||
|
any open body-edit PRs against this entry.
|
||||||
|
- A "Start Contributing" affordance that cuts a fresh edit branch
|
||||||
|
and lands you in contribute mode.
|
||||||
|
|
||||||
|
Edits to a super-draft body propagate through pull requests against
|
||||||
|
the meta repository — there is no dedicated RFC repository yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What an active RFC is
|
||||||
|
|
||||||
|
An active RFC is an entry that has been **graduated**. It has its
|
||||||
|
own dedicated repository, an integer `RFC-NNNN` identifier, and a
|
||||||
|
canonical body file (`RFC.md`) inside that repository. The catalog
|
||||||
|
distinguishes super-drafts and active RFCs at a glance.
|
||||||
|
|
||||||
|
Opening an active RFC gives you:
|
||||||
|
|
||||||
|
- `main` — the canonical body, always read-only. Changes to `main`
|
||||||
|
arrive exclusively through pull requests.
|
||||||
|
- A breadcrumb listing every open branch and pull request on this
|
||||||
|
RFC.
|
||||||
|
- A per-branch chat thread on the right. Each branch has its own
|
||||||
|
conversation, including `main` itself.
|
||||||
|
- A "Start Contributing" affordance: on `main` it cuts a new branch
|
||||||
|
and lands you on it in contribute mode; on any other branch you
|
||||||
|
already have push access to, it flips that branch into
|
||||||
|
contribute mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Discussion vs contribution
|
||||||
|
|
||||||
|
The framework draws an explicit distinction between two surfaces
|
||||||
|
that other tools tend to conflate:
|
||||||
|
|
||||||
|
- **Discussion** is what the RFC is *for*. The chat thread on an
|
||||||
|
RFC's main view is the place for "what about this part?" or
|
||||||
|
"have we considered…?" questions that don't yet warrant proposing
|
||||||
|
a specific edit. Posting on a discussion thread does not create
|
||||||
|
any Git artifact; the conversation lives in the app database.
|
||||||
|
- **Contribution** is how an RFC *changes*. Editing the canonical
|
||||||
|
body requires opening a branch and, eventually, a pull request.
|
||||||
|
The pull request is the place a specific proposed change is
|
||||||
|
reviewed and merged.
|
||||||
|
|
||||||
|
Reading both surfaces is open to anonymous visitors. Posting on
|
||||||
|
either requires a contributor account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Working on a branch
|
||||||
|
|
||||||
|
Contribute mode flips one branch into edit-enabled. The centre
|
||||||
|
column splits: a markdown source pane on the left, a live-rendered
|
||||||
|
preview on the right. Fenced `mermaid` blocks render as diagrams in
|
||||||
|
the preview.
|
||||||
|
|
||||||
|
Two kinds of edits accumulate on a branch:
|
||||||
|
|
||||||
|
- **AI-proposed changes.** You ask the AI a question or request a
|
||||||
|
revision in the branch's chat. When the AI proposes a concrete
|
||||||
|
edit, that edit appears as a *change card* in a panel below the
|
||||||
|
chat — not yet applied to the document. You can **accept**,
|
||||||
|
**decline**, or **edit before accepting**. Accepting produces
|
||||||
|
one commit on the branch with the original text, the proposed
|
||||||
|
text, and the AI's reason recorded in the commit body.
|
||||||
|
- **Manual edits.** Typing directly into the source pane buffers
|
||||||
|
locally and flushes as a single commit on an idle window, a
|
||||||
|
branch switch, or an explicit "Save now" button. Manual edits
|
||||||
|
also appear as change cards in the same panel — same evidence
|
||||||
|
shape, different author.
|
||||||
|
|
||||||
|
Every accepted change is one commit. The framework does not
|
||||||
|
support squash-merges or fixup-style cleanups: the per-change
|
||||||
|
commit granularity is the framework's evidence unit, and
|
||||||
|
collapsing it would erase what was earned.
|
||||||
|
|
||||||
|
### Discuss mode vs contribute mode
|
||||||
|
|
||||||
|
A branch defaults to discuss mode — read-only, with chat enabled.
|
||||||
|
AI proposals still appear in chat, but they are *buffered* rather
|
||||||
|
than applied; a single CTA invites you to flip the branch into
|
||||||
|
contribute mode if you want to act on them. The toggle is an
|
||||||
|
*intent* affordance, not a permission one. If you don't have push
|
||||||
|
access to the branch, the toggle is disabled with a sign-in or
|
||||||
|
request-access path.
|
||||||
|
|
||||||
|
`main` is special: contribute mode is never available there. The
|
||||||
|
"Start Contributing" button on `main` always cuts a new branch.
|
||||||
|
|
||||||
|
### Flags
|
||||||
|
|
||||||
|
Anywhere you can read, you can drop a flag. A flag is the
|
||||||
|
lightweight "I'm pointing at this, it's a problem" gesture — a
|
||||||
|
single short declarative statement anchored to a passage. Creating
|
||||||
|
a flag requires a contributor account but does not require push
|
||||||
|
access to the branch: any signed-in contributor who can read a
|
||||||
|
passage can point at it and say it's wrong.
|
||||||
|
|
||||||
|
Flags don't block PR merges by design — making them a merge gate
|
||||||
|
would re-create the failure mode where contributors hastily "resolve"
|
||||||
|
threads to unblock a button. Flags are prominent on PR headers but
|
||||||
|
non-blocking.
|
||||||
|
|
||||||
|
### Branch visibility
|
||||||
|
|
||||||
|
A new branch is publicly readable by default. The branch creator
|
||||||
|
can flip a branch to private, in which case only the creator, any
|
||||||
|
explicit grantees, and the RFC's per-RFC owners and arbiters can
|
||||||
|
read it. Owners and arbiters can flip it back.
|
||||||
|
|
||||||
|
**Opening a PR makes the branch fully public.** If your branch is
|
||||||
|
currently private, the "Open PR" affordance asks you to confirm
|
||||||
|
this before submitting. There is no concept of a private PR — the
|
||||||
|
framework's evidence claim depends on the argument being readable.
|
||||||
|
|
||||||
|
### Who can push to a branch
|
||||||
|
|
||||||
|
Every branch has one of three contribute modes:
|
||||||
|
|
||||||
|
- **`just-me`** (default) — only the branch creator can push.
|
||||||
|
- **`specific`** — only the branch creator and explicitly granted
|
||||||
|
contributors can push.
|
||||||
|
- **`any-contributor`** — any signed-in contributor can push.
|
||||||
|
|
||||||
|
The branch creator and the RFC's per-RFC owners and arbiters can
|
||||||
|
change this setting at any time.
|
||||||
|
|
||||||
|
### Branch hygiene
|
||||||
|
|
||||||
|
A branch with no associated PR auto-closes after 30 days of
|
||||||
|
inactivity. A closed branch is deleted from the Git host 60 days
|
||||||
|
later. Closed branches remain in the catalog under a "show closed"
|
||||||
|
filter — closing is a state, not a censorship event. The chat
|
||||||
|
attached to a closed or deleted branch is preserved as historical
|
||||||
|
record.
|
||||||
|
|
||||||
|
Owners and arbiters can *pin* a branch to disable the auto-close
|
||||||
|
timer if the work is paused but legitimately ongoing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opening and reviewing a pull request
|
||||||
|
|
||||||
|
A pull request is the deliberate "ready for review" gesture for
|
||||||
|
work that has accumulated on a branch. The "Open PR" affordance is
|
||||||
|
available on any branch with at least one commit ahead of `main`.
|
||||||
|
|
||||||
|
The PR creation modal collects two AI-drafted fields, both editable
|
||||||
|
before submit:
|
||||||
|
|
||||||
|
- **Title.** A one-line description of the change, in spec voice.
|
||||||
|
- **Description.** Two to four sentences pulling from the branch
|
||||||
|
chat, written for an arbiter.
|
||||||
|
|
||||||
|
There is no reviewer picker. The RFC's arbiters are the implicit
|
||||||
|
reviewer set.
|
||||||
|
|
||||||
|
### The PR review page
|
||||||
|
|
||||||
|
The review page shows the diff, the branch's compressed chat
|
||||||
|
(messages that produced accepted changes are expanded, the rest is
|
||||||
|
behind a "Show full conversation" toggle), and the review-comment
|
||||||
|
surface inline below the chat.
|
||||||
|
|
||||||
|
Review comments are not a separate concept from chat — they live in
|
||||||
|
the same thread, anchored to a range in the diff. The framework's
|
||||||
|
claim is that the disagreement an arbiter raises about a proposed
|
||||||
|
change is the same *kind* of thing as the disagreement that
|
||||||
|
produced the proposed change in the first place, and the two should
|
||||||
|
share a surface.
|
||||||
|
|
||||||
|
Each PR records a per-user seen-cursor. New diff hunks and new
|
||||||
|
conversation messages since your last visit render with a subtle
|
||||||
|
accent. The cursor advances on view; you do not have to mark
|
||||||
|
anything as read.
|
||||||
|
|
||||||
|
### Merging a PR
|
||||||
|
|
||||||
|
Per-RFC owners and arbiters can merge; app-wide admins and owners
|
||||||
|
also retain this capability. The merge produces a no-fast-forward
|
||||||
|
commit on `main`, preserving every per-acceptance commit as an
|
||||||
|
individually reachable node in `main`'s history.
|
||||||
|
|
||||||
|
Merge is hard-blocked **only** by Git-level conflicts with `main`.
|
||||||
|
Open review threads, pending change-cards, unresolved chat threads,
|
||||||
|
and open flags do not block merge by design.
|
||||||
|
|
||||||
|
### Conflicts with main
|
||||||
|
|
||||||
|
A conflict surfaces on the PR page as a read-only banner. A "Start
|
||||||
|
resolution branch" affordance cuts a fresh branch off `main`'s
|
||||||
|
current tip, replays the work into it (asking the AI to resolve
|
||||||
|
unambiguous conflicts, surfacing the rest for you), and opens a new
|
||||||
|
PR. The original PR auto-closes when the resolution PR merges.
|
||||||
|
|
||||||
|
Fixup commits on the existing branch are not supported. Per-change
|
||||||
|
commit granularity is the framework's evidence unit; admitting
|
||||||
|
"fix merge conflict with main" commits would dilute it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Graduation: super-draft → active RFC
|
||||||
|
|
||||||
|
Graduation is the moment a super-draft becomes a canonical entry
|
||||||
|
in the catalog. It is initiated by an app-wide admin, an app-wide
|
||||||
|
owner, or one of the RFC's per-RFC owners or arbiters from the
|
||||||
|
super-draft's page.
|
||||||
|
|
||||||
|
Two preconditions block the action:
|
||||||
|
|
||||||
|
- **The super-draft must have at least one owner.** The proposer
|
||||||
|
is automatically the first owner; if they have stepped away, any
|
||||||
|
contributor can use the "Claim ownership" affordance to add
|
||||||
|
themselves.
|
||||||
|
- **No open body-edit PRs against the super-draft's entry.** An
|
||||||
|
open body-edit PR would attempt to re-introduce a body to a
|
||||||
|
frontmatter-only entry after graduation runs. Merge or withdraw
|
||||||
|
them first.
|
||||||
|
|
||||||
|
When the dialog confirms, the framework runs a transactional
|
||||||
|
sequence: create a fresh Git repository for the RFC, seed it with
|
||||||
|
the super-draft's body as `RFC.md`, update the meta-repo entry to
|
||||||
|
`state: active` with the integer ID and the new repository's URL,
|
||||||
|
auto-merge that update. If any step fails partway, the sequence
|
||||||
|
rolls back — the half-created repository is deleted and the
|
||||||
|
unmerged update is abandoned. The dialog shows each step in flight
|
||||||
|
and tells you exactly what happened.
|
||||||
|
|
||||||
|
The chat thread on the super-draft moves to the new repository's
|
||||||
|
`main` chat at graduation. Edit-branch chats from the super-draft
|
||||||
|
phase stay attached to their original branches on the meta repo
|
||||||
|
and surface from the new RFC view under a "Pre-graduation history"
|
||||||
|
section.
|
||||||
|
|
||||||
|
Graduation is not reversible. The path forward from an active RFC
|
||||||
|
is withdrawal, not back to super-draft.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Withdrawing and reopening
|
||||||
|
|
||||||
|
An active RFC or a super-draft can be withdrawn by the proposer
|
||||||
|
(for a super-draft they proposed) or by an admin or owner. A
|
||||||
|
withdrawn entry stays in the catalog as a historical record but is
|
||||||
|
hidden from default views. The entry is filterable back in.
|
||||||
|
|
||||||
|
An admin or owner can reopen a withdrawn entry back into the
|
||||||
|
super-draft state. The history is preserved across the transition.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI in the chat
|
||||||
|
|
||||||
|
The chat on every RFC, super-draft, branch, and PR has an AI
|
||||||
|
participant by default. The framework treats the AI as one voice
|
||||||
|
among many in a public argument — not an oracle, and not a
|
||||||
|
co-author whose name lands on commits.
|
||||||
|
|
||||||
|
You invoke the AI by writing into the chat composer and submitting.
|
||||||
|
Each message can pick a model from the picker (the option list is
|
||||||
|
configurable per RFC). The AI responds in the chat; when its
|
||||||
|
response includes a concrete change to the document, that change
|
||||||
|
appears as a card you can accept, decline, or edit.
|
||||||
|
|
||||||
|
When you accept an AI's proposed change, the commit's
|
||||||
|
`On-behalf-of:` trailer names *you*, not the AI. The AI's authorship
|
||||||
|
survives only as evidence — the original proposal in the commit body
|
||||||
|
and the message that produced it in the chat record. The framework
|
||||||
|
is explicit about this: AI participation produces evidence; it does
|
||||||
|
not produce authorship.
|
||||||
|
|
||||||
|
Two configuration knobs scope AI participation per RFC:
|
||||||
|
|
||||||
|
- **Which models are available.** The meta-repo entry's frontmatter
|
||||||
|
carries an optional `models:` list. Absent means the RFC inherits
|
||||||
|
whatever models the deployment is provisioned to run. An empty
|
||||||
|
list (`models: []`) opts the RFC out of AI entirely — every AI
|
||||||
|
surface is absent rather than disabled-but-present.
|
||||||
|
- **Whose credentials pay.** By default the deployment operator's
|
||||||
|
API credentials cover AI calls on every RFC. A `funder:`
|
||||||
|
frontmatter field can name a single contributor whose registered
|
||||||
|
credentials pay for AI calls on this RFC instead. The named
|
||||||
|
contributor must explicitly consent from their settings page;
|
||||||
|
either side can revoke at any time.
|
||||||
|
|
||||||
|
Per-RFC AI configuration is edited through the meta-repo PR flow
|
||||||
|
that governs the rest of the entry's frontmatter — by the RFC's
|
||||||
|
per-RFC owners and arbiters, or by app-wide admins or owners.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
The framework's public-async work model produces signals that
|
||||||
|
shouldn't all reach you the same way. Five surfaces compose:
|
||||||
|
|
||||||
|
- **In-app inbox.** The durable triage surface. One mental space
|
||||||
|
across every RFC you have any relationship to, with per-RFC and
|
||||||
|
per-category filters. Reachable from the inbox icon in the
|
||||||
|
header.
|
||||||
|
- **Badges.** Ambient pull-ins. A single integer beside the inbox
|
||||||
|
icon (count of unread notifications). A small binary dot on
|
||||||
|
individual catalog rows for watched RFCs with unseen activity.
|
||||||
|
No per-row counts and no per-section counts.
|
||||||
|
- **Toasts.** Transient mid-session signals. Used only for your own
|
||||||
|
actions completing, and for events arriving on the view you're
|
||||||
|
currently looking at.
|
||||||
|
- **Email.** The single channel that escapes the app. Opt-in per
|
||||||
|
category, conservative defaults. One-click unsubscribe per
|
||||||
|
category.
|
||||||
|
- **Digest.** Aggregation for activity on watched RFCs you haven't
|
||||||
|
triaged through any other channel.
|
||||||
|
|
||||||
|
### Watch states
|
||||||
|
|
||||||
|
Every RFC has one of three implicit relationship states for you:
|
||||||
|
|
||||||
|
- **Watching.** You receive structural signals for the RFC.
|
||||||
|
- **Following.** You receive only churn-grade signals (new
|
||||||
|
commits, new chat messages on threads you didn't participate
|
||||||
|
in). This is a lighter relationship than watching.
|
||||||
|
- **Muted.** You receive no signals for the RFC. The mute is
|
||||||
|
per-RFC and self-imposed; it does not affect what others see
|
||||||
|
or what reaches you on *other* RFCs.
|
||||||
|
|
||||||
|
Watch states transition automatically based on your participation,
|
||||||
|
with explicit overrides available from each RFC's header and from
|
||||||
|
the notification settings page.
|
||||||
|
|
||||||
|
### Email categories
|
||||||
|
|
||||||
|
Four categories with distinct defaults:
|
||||||
|
|
||||||
|
- **Personal-direct events** — default on. Signals where you are
|
||||||
|
the named subject. The contract is that when your name is on the
|
||||||
|
action, the framework reaches out of band.
|
||||||
|
- **Watched-RFC structural events** — default off. PR opened on a
|
||||||
|
watched RFC, PR merged, graduation, withdrawal. Inbox and badges
|
||||||
|
carry these by default; the email toggle is opt-in.
|
||||||
|
- **Watched-RFC churn** — permanently off, by design. Per-commit
|
||||||
|
and per-message email is intentionally not offered. The digest
|
||||||
|
aggregates this activity weekly.
|
||||||
|
- **Admin-actionable events** — default on for admins and owners,
|
||||||
|
unused for contributors.
|
||||||
|
|
||||||
|
### Quiet hours
|
||||||
|
|
||||||
|
You can set a daily window during which email notifications are
|
||||||
|
held. Messages held during the window are released at window end —
|
||||||
|
bundled into a single "Activity while you were away" email if a
|
||||||
|
threshold accumulated, otherwise sent individually.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roles & permissions
|
||||||
|
|
||||||
|
Authorization in this framework is owned by the app itself, not by
|
||||||
|
the Git host. The Git host sees only a single bot account — every
|
||||||
|
commit, every PR, every merge passes through it on a user's behalf
|
||||||
|
— and the *app* decides which users are authorized to ask the bot
|
||||||
|
to do which things.
|
||||||
|
|
||||||
|
### The four app-wide roles
|
||||||
|
|
||||||
|
Each role is a strict superset of the one below it.
|
||||||
|
|
||||||
|
1. **Anonymous.** Anyone who has not signed in. Can read public
|
||||||
|
RFCs, public branches, and public PRs; cannot chat, propose,
|
||||||
|
create branches, or open PRs.
|
||||||
|
|
||||||
|
2. **Contributor.** The default role for any authenticated
|
||||||
|
account. Adds everything anonymous can do, plus: propose new
|
||||||
|
RFCs, create branches on any RFC repository, open PRs from
|
||||||
|
branches they have push access to, post on chat anywhere they
|
||||||
|
can read, claim ownership of unclaimed super-drafts.
|
||||||
|
|
||||||
|
3. **Admin.** Adds the ability to act on any RFC, anywhere in the
|
||||||
|
framework. Concretely: merge any PR on any RFC, graduate any
|
||||||
|
super-draft, set branch visibility on anyone's behalf, withdraw
|
||||||
|
or reopen any entry, write-mute or restore any contributor,
|
||||||
|
grant or revoke the **admin** role.
|
||||||
|
|
||||||
|
4. **Owner.** Adds two capabilities admin does not have: grant or
|
||||||
|
revoke the **owner** role itself, and disable an account
|
||||||
|
entirely. The framework names a single "owner zero" at
|
||||||
|
bootstrap.
|
||||||
|
|
||||||
|
The practical difference between admin and owner is narrow but
|
||||||
|
load-bearing: admin is the operational tier — it does the day-to-
|
||||||
|
day moderation and stewardship work; owner is the tier that
|
||||||
|
controls the admin tier. Disabling an account and creating other
|
||||||
|
owners are owner-only because they affect the framework's chain of
|
||||||
|
authority itself.
|
||||||
|
|
||||||
|
The app refuses to let the last owner demote themselves silently —
|
||||||
|
losing the last owner would leave nobody able to grant the role
|
||||||
|
back. Role changes are recorded in an append-only `permission_events`
|
||||||
|
log; an admin's own admin/users page shows the log of who promoted,
|
||||||
|
demoted, or muted whom.
|
||||||
|
|
||||||
|
### Per-RFC delegated authority
|
||||||
|
|
||||||
|
The four roles above are framework-wide. Within an individual RFC,
|
||||||
|
the meta-repo entry's frontmatter names two additional groups:
|
||||||
|
|
||||||
|
- **`owners:`** — contributors elevated for this RFC. They can
|
||||||
|
grant push access on any branch in the RFC, merge any PR on the
|
||||||
|
RFC, change branch visibility, and withdraw the RFC.
|
||||||
|
- **`arbiters:`** — contributors with merge authority for this RFC.
|
||||||
|
Functionally similar to per-RFC owners for merge decisions; the
|
||||||
|
distinction matters in some configuration paths.
|
||||||
|
|
||||||
|
Per-RFC owners and arbiters are **not** app-wide admins. Their
|
||||||
|
elevated powers are scoped strictly to the RFC named in the
|
||||||
|
frontmatter. This is what lets the framework distribute work
|
||||||
|
without putting one person on the hook for every action.
|
||||||
|
|
||||||
|
The proposer of an RFC is automatically the first per-RFC owner.
|
||||||
|
Additional per-RFC owners are added through a "Claim ownership"
|
||||||
|
PR against the meta repository; app-wide admins or owners merge
|
||||||
|
it.
|
||||||
|
|
||||||
|
### Per-branch contribute grants
|
||||||
|
|
||||||
|
Within an RFC, the branch creator and the RFC's per-RFC owners
|
||||||
|
and arbiters can grant push access to specific contributors on a
|
||||||
|
specific branch — `specific` contribute mode, described under
|
||||||
|
"Working on a branch."
|
||||||
|
|
||||||
|
### The write-mute
|
||||||
|
|
||||||
|
An app-wide admin or owner can **mute** a contributor. A muted
|
||||||
|
account retains read access and keeps its existing branches, but
|
||||||
|
cannot create new branches, open new PRs, propose new RFCs, or
|
||||||
|
post chat. This is a moderation tool, distinct from removing the
|
||||||
|
account; restoring is the reverse gesture.
|
||||||
|
|
||||||
|
The write-mute applies only to contributors. Promoting a user to
|
||||||
|
admin or owner is the way to remove a user's write-restriction in
|
||||||
|
the structural sense; the write-mute is for *retaining* an account
|
||||||
|
while removing its ability to act.
|
||||||
|
|
||||||
|
Every mute and every restore is recorded in `permission_events`.
|
||||||
|
|
||||||
|
### Three different "mutes"
|
||||||
|
|
||||||
|
The word "mute" appears in three structurally distinct places.
|
||||||
|
They share a word and nothing else.
|
||||||
|
|
||||||
|
- **Write-mute.** Admin-imposed. Removes a contributor's ability
|
||||||
|
to post or push. Described above.
|
||||||
|
- **Per-RFC notification mute.** Self-imposed. Sets your watch
|
||||||
|
state on a specific RFC to *muted* — you stop receiving signals
|
||||||
|
for that RFC, in inbox, badges, and email. Does not affect what
|
||||||
|
others see.
|
||||||
|
- **Per-user notification mute.** Self-imposed. Suppresses
|
||||||
|
notifications produced by a specific other user, anywhere in
|
||||||
|
the framework. Notification-volume only — it does not affect
|
||||||
|
what you can read.
|
||||||
|
|
||||||
|
A write-muted contributor continues to receive notifications
|
||||||
|
normally, so they can triage what they can't act on, and so a
|
||||||
|
restore lands cleanly.
|
||||||
|
|
||||||
|
### Audit trail
|
||||||
|
|
||||||
|
Every gesture that changes app state — role changes, mutes,
|
||||||
|
graduations, withdrawals, grant changes — is recorded in
|
||||||
|
append-only logs the app maintains. Git commit history is for
|
||||||
|
code archaeology; the app's audit log is the accountability
|
||||||
|
record. An admin's page surfaces both `permission_events` (the
|
||||||
|
role/mute log) and `actions` (the state-transition log) for
|
||||||
|
review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where to learn more
|
||||||
|
|
||||||
|
- The framework's *why* lives in [the philosophy
|
||||||
|
document](/philosophy).
|
||||||
|
- The binding technical contract — section numbers (`§n.n`)
|
||||||
|
referenced throughout this guide — is in `SPEC.md` in the
|
||||||
|
framework's source repository.
|
||||||
|
- Deployment operators have their own recipe in
|
||||||
|
`docs/DEPLOYMENTS.md`.
|
||||||
@@ -356,18 +356,37 @@ merge with no data movement.
|
|||||||
|
|
||||||
Authorization is owned by the app. Gitea sees only the bot account.
|
Authorization is owned by the app. Gitea sees only the bot account.
|
||||||
|
|
||||||
Authentication, as of v0.7.0, is by email + one-time-code: a visitor
|
Authentication has three paths, in the order a visitor encounters
|
||||||
enters their email address, receives a six-digit code via SMTP, and
|
them:
|
||||||
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
|
1. **Email + one-time code (OTC).** The v0.7.0 primary path: a
|
||||||
during the v0.7.0 window — `users.gitea_id` is preserved on existing
|
visitor enters their email address, receives a six-digit code via
|
||||||
rows so a grandfathered user signing in via either path resolves to
|
SMTP, and exchanges the code for a session. Used by every visitor
|
||||||
the same row — but the primary surface points at OTC. `users.email`
|
on first sign-in, and as the fallback for the other two paths.
|
||||||
is the identity key for everything provisioned after v0.7.0;
|
2. **Email + passcode (with OTC fallback).** Added in v0.10.0
|
||||||
`users.gitea_id` is the grandfathering linker (nullable, partial-
|
(roadmap item #8). After a successful OTC sign-in, the visitor
|
||||||
unique). The Gitea bot user + token are still required for server-
|
may set a user-chosen passcode (4–20 characters, bcrypt-hashed at
|
||||||
side git operations (repo reads, PR creation); only the operator-
|
rest) and use email + passcode on subsequent sign-ins. Five
|
||||||
facing sign-in surface moved.
|
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
|
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
|
v0.3.0 `allowed_emails` table forward as the admission gate at the
|
||||||
@@ -379,9 +398,23 @@ endpoints accept the user. The capture-fields step (first name,
|
|||||||
last name, free-text "why I should be included in the beta") feeds
|
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
|
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
|
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
|
it — but the OTC request path no longer consults it. v0.9.0
|
||||||
admin user-management page replaces the allowlist UI and ships the
|
(roadmap item #7) shipped the user-management surface that
|
||||||
pending-queue triage surface.
|
consumes the `permission_state` column: `/admin/users` carries
|
||||||
|
every user with their state, profile, and sign-up reason, plus
|
||||||
|
Grant / Revoke controls that flip the column and write a
|
||||||
|
`permission_events` audit row. The capture-form submission also
|
||||||
|
fans a `new_beta_request` notification out to every admin/owner
|
||||||
|
through the §15 substrate, so the queue surfaces in the inbox +
|
||||||
|
email channels admins already have.
|
||||||
|
|
||||||
|
The `/admin/allowlist` sub-tab stays in place alongside
|
||||||
|
`/admin/users` rather than merging: the two surfaces have
|
||||||
|
different keys (allowlist by email pre-sign-up, user list by
|
||||||
|
user_id post-sign-up) and a union row would be confusing rather
|
||||||
|
than clarifying. The allowlist's role narrowed to "fast-path
|
||||||
|
bypass for known-good emails" with the v0.8.0 admission shift;
|
||||||
|
v0.9.0 retains that role unchanged.
|
||||||
|
|
||||||
### 6.1 Four roles, each a strict superset of the one below
|
### 6.1 Four roles, each a strict superset of the one below
|
||||||
|
|
||||||
@@ -2035,6 +2068,30 @@ with Gitea"; v0.7.0's email/OTC surface replaced that as the primary
|
|||||||
gesture, with a small "Sign in with Gitea (fallback)" link surviving
|
gesture, with a small "Sign in with Gitea (fallback)" link surviving
|
||||||
on `/login` itself for the migration window.
|
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
|
This is the front door. It sets expectation before the user encounters
|
||||||
the mechanics, so the mechanics (super-drafts, graduation, public
|
the mechanics, so the mechanics (super-drafts, graduation, public
|
||||||
arguments, AI participation in chat) read as load-bearing rather than
|
arguments, AI participation in chat) read as load-bearing rather than
|
||||||
@@ -2243,8 +2300,14 @@ signal taxonomy this section commits to. The starting set:
|
|||||||
`graduation_complete`, `graduation_rolled_back`, `rfc_withdrawn`,
|
`graduation_complete`, `graduation_rolled_back`, `rfc_withdrawn`,
|
||||||
`rfc_reopened`, `claim_opened`, `claim_merged`,
|
`rfc_reopened`, `claim_opened`, `claim_merged`,
|
||||||
`permission_change_affecting_me`, `app_wide_mute_set`,
|
`permission_change_affecting_me`, `app_wide_mute_set`,
|
||||||
`app_wide_mute_lifted`, `digest_emitted`. The enum is extensible; the
|
`app_wide_mute_lifted`, `new_beta_request`, `digest_emitted`.
|
||||||
build session adjusts as new gestures are wired in.
|
The enum is extensible; the build session adjusts as new gestures
|
||||||
|
are wired in. The `new_beta_request` event (v0.9.0, roadmap item
|
||||||
|
#7) is framework-scoped rather than RFC-scoped — the row's
|
||||||
|
`rfc_slug` is NULL and the deep-link points `/admin/users`
|
||||||
|
instead of `/rfc/<slug>` — but otherwise rides the standard
|
||||||
|
fan-out chokepoint with category `admin-actionable` so the §15.4
|
||||||
|
email gate only reaches owners/admins.
|
||||||
|
|
||||||
### 15.2 The inbox
|
### 15.2 The inbox
|
||||||
|
|
||||||
@@ -2728,6 +2791,38 @@ The follow-up session will refine this. A minimal starting set:
|
|||||||
v0.8.0 — the first-OTC profile-capture endpoint (roadmap item
|
v0.8.0 — the first-OTC profile-capture endpoint (roadmap item
|
||||||
#6). v0.9.0's admin user-management page consumes this column
|
#6). v0.9.0's admin user-management page consumes this column
|
||||||
set to render the request queue.
|
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.
|
||||||
- `GET /api/rfcs` — list entries with state, id, title, slug, repo,
|
- `GET /api/rfcs` — list entries with state, id, title, slug, repo,
|
||||||
owners, last_active_at, has_open_prs, starred-by-me. Supports
|
owners, last_active_at, has_open_prs, starred-by-me. Supports
|
||||||
search, sort, filter chips, and the `unclaimed` predicate.
|
search, sort, filter chips, and the `unclaimed` predicate.
|
||||||
@@ -2870,8 +2965,15 @@ The follow-up session will refine this. A minimal starting set:
|
|||||||
- `POST /api/rfcs/<slug>/prs/<pr_number>/withdraw` — withdraw per §10.8.
|
- `POST /api/rfcs/<slug>/prs/<pr_number>/withdraw` — withdraw per §10.8.
|
||||||
- `POST /api/rfcs/<slug>/prs/<pr_number>/resolution-branch` — cut a
|
- `POST /api/rfcs/<slug>/prs/<pr_number>/resolution-branch` — cut a
|
||||||
fresh resolution branch and replay per §10.9.
|
fresh resolution branch and replay per §10.9.
|
||||||
- `GET /api/admin/users` — list users with role and write-mute state,
|
- `GET /api/admin/users` — list users for the §6 / Slice 7 admin
|
||||||
for the §6 / Slice 7 admin surface.
|
surface. v0.9.0 (roadmap item #7) widened the payload to carry
|
||||||
|
`permission_state`, `first_name`, `last_name`, `beta_request_reason`,
|
||||||
|
`created_at`, `permission_decided_at`, and the joined
|
||||||
|
`permission_decided_by_login` / `permission_decided_by_display`
|
||||||
|
for the user-management page. Sort order surfaces `pending` rows
|
||||||
|
first (the daily admin queue), then `granted`, then `revoked`;
|
||||||
|
within a bucket, owners precede admins precede contributors,
|
||||||
|
with recency as the tiebreaker.
|
||||||
- `POST /api/admin/users/<id>/role` — set role. Only owners may grant
|
- `POST /api/admin/users/<id>/role` — set role. Only owners may grant
|
||||||
or revoke `owner`; admins may flip contributor ↔ admin freely. An
|
or revoke `owner`; admins may flip contributor ↔ admin freely. An
|
||||||
owner-self-demotion is refused on this endpoint; owner succession
|
owner-self-demotion is refused on this endpoint; owner succession
|
||||||
@@ -2880,6 +2982,16 @@ The follow-up session will refine this. A minimal starting set:
|
|||||||
write-mute (not the §15.8 notification mutes). Refused on owners
|
write-mute (not the §15.8 notification mutes). Refused on owners
|
||||||
and admins — for them, the role-change channel is the right
|
and admins — for them, the role-change channel is the right
|
||||||
refusal. Writes a `permission_events` row.
|
refusal. Writes a `permission_events` row.
|
||||||
|
- `POST /api/admin/users/<id>/permission` — v0.9.0 (roadmap item #7).
|
||||||
|
Flip `permission_state` between `pending`, `granted`, and `revoked`.
|
||||||
|
Stamps `permission_decided_by` + `permission_decided_at` on the
|
||||||
|
row and writes a `permission_events` row with event_kind in
|
||||||
|
`{permission_granted, permission_revoked, permission_repended}`.
|
||||||
|
Refuses with 422 if the admin tries to flip their own row
|
||||||
|
(symmetric to the `set_mute` / `set_role` self-action refusals).
|
||||||
|
v0.8.0 shipped the column shape with no admin UI — operators ran
|
||||||
|
a manual `UPDATE users` to grant access; v0.9.0 retires the
|
||||||
|
manual gesture.
|
||||||
- `GET /api/admin/audit` — paged read of the `actions` log with
|
- `GET /api/admin/audit` — paged read of the `actions` log with
|
||||||
filters `action_kind`, `actor_user_id`, `rfc_slug`, plus `before_id`
|
filters `action_kind`, `actor_user_id`, `rfc_slug`, plus `before_id`
|
||||||
for the page boundary. Returns the joined actor login/display so
|
for the page boundary. Returns the joined actor login/display so
|
||||||
@@ -3719,56 +3831,64 @@ on every other page until an admin grants. v0.8.0 (roadmap item
|
|||||||
Candidates surfaced during v0.8.0 (open beta-access request flow,
|
Candidates surfaced during v0.8.0 (open beta-access request flow,
|
||||||
§6.1 / §14.1, item #6):
|
§6.1 / §14.1, item #6):
|
||||||
|
|
||||||
- **Admin user-management page** (`/admin/users`). *Surfaced by
|
- **Admin user-management page** (`/admin/users`). *Shipped in
|
||||||
v0.8.0 — the release ships the pending-state column but no
|
v0.9.0 (roadmap item #7).* The listing surfaces every user with
|
||||||
admin UI to triage it.* For v0.8.0, the admin gesture is an
|
permission_state, profile fields, sign-up reason, and a Grant /
|
||||||
out-of-band `UPDATE users SET permission_state='granted' WHERE
|
Revoke control set; the `POST /api/admin/users/<id>/permission`
|
||||||
email=?`. v0.9.0 (roadmap item #7) is expected to ship the
|
endpoint flips the column and writes a `permission_events` row.
|
||||||
triage queue: a list of `permission_state='pending'` rows
|
v0.9.0 left the `/admin/allowlist` sub-tab in place rather than
|
||||||
sorted by `created_at`, each showing the captured first /
|
merging (see allowlist deprecation below). The grant/revoke
|
||||||
last / why fields, with Grant and Revoke buttons that stamp
|
notify-the-user surface is deferred (see the new candidate
|
||||||
`permission_decided_by` and `permission_decided_at` (schema
|
below).
|
||||||
slots already in place per `migrations/014_beta_access.sql`).
|
- **Allowlist deprecation.** *Decision deferred past v0.9.0.*
|
||||||
The page composes naturally with the existing `/admin/allowlist`
|
v0.9.0 considered merging `/admin/allowlist` into the new
|
||||||
surface — both are admission-control gestures — so v0.9.0 may
|
`/admin/users` page but kept the surface as a sibling sub-tab:
|
||||||
fold the allowlist UI into this page (see next candidate).
|
the two have different keys (allowlist by email pre-sign-up,
|
||||||
Decision points: do grants / revokes also fire email
|
user list by user_id post-sign-up) and a union row would be
|
||||||
notifications to the user (probably yes — the notifications
|
confusing rather than clarifying. The fast-path-bypass role
|
||||||
layer from v0.6.0 has the personal-direct channel for it); is
|
the allowlist has carried since v0.8.0 stays intact; the
|
||||||
there a "decline with reason" gesture that surfaces in the
|
cutover to retire the table outright is a later session.
|
||||||
user's view (probably yes — symmetric with §9.3's
|
Decision points unchanged from v0.8.0: drop the table outright
|
||||||
proposal-decline shape); does the page support bulk grants
|
(a schema migration) or leave it as a non-functional surface
|
||||||
(probably no for v0.9.0 — the queue volume is operator-scale,
|
and remove only the UI (a frontend-only change); how to handle
|
||||||
not user-scale). Earns its session as the v0.9.0 design pass.
|
existing `allowed_emails` rows at the cutover (probably: walk
|
||||||
- **Allowlist deprecation.** *Surfaced by v0.8.0 — the
|
them into the pending queue with `permission_state='granted'`
|
||||||
`allowed_emails` table stays in the schema but the OTC
|
for any matching `users` row, leave unmatched rows as a no-op
|
||||||
request path no longer consults it.* v0.8.0 left the table
|
since v0.8.0 doesn't consult them anymore). Earns its session
|
||||||
and the `/admin/allowlist` UI in place as a fast-path bypass
|
once the v0.9.0 admin queue has run long enough to confirm the
|
||||||
for deployments that want to pre-mark known-good emails (the
|
allowlist's bypass role is no longer pulling weight.
|
||||||
v0.8.0 contract is that those emails still go through the
|
- **Admin notification on new beta request.** *Shipped in v0.9.0
|
||||||
pending-grant flow; the table itself is no longer a gate). A
|
(roadmap item #7).* The `POST /api/auth/me/beta-request`
|
||||||
future release retires both — probably v0.9.0 alongside the
|
handler now calls `notify.fan_out_new_beta_request`, which
|
||||||
admin user-management page, since the two surfaces are
|
fans a `new_beta_request` event (category `admin-actionable`,
|
||||||
functionally redundant once the pending queue lands.
|
rfc_slug NULL) out to every owner / admin. The §15 chokepoint
|
||||||
Decision points: drop the table outright (a schema migration)
|
handles the SSE broadcast and the §15.4 email dispatch; the
|
||||||
or leave it as a non-functional surface and remove only the
|
email reaches only recipients whose `email_admin_actionable`
|
||||||
UI (a frontend-only change); how to handle existing
|
toggle is on (the default for owners + admins).
|
||||||
`allowed_emails` rows at the cutover (probably: walk them
|
- **Grant / revoke notification to the user.** *Surfaced by
|
||||||
into the pending queue with `permission_state='granted'` for
|
v0.9.0.* The new flip endpoint stamps `permission_decided_by` +
|
||||||
any matching `users` row, leave unmatched rows as a no-op
|
writes a `permission_events` row but does not yet signal the
|
||||||
since v0.8.0 doesn't consult them anymore). Earns its
|
affected user that their state changed. A future release could
|
||||||
session as a sub-topic of the v0.9.0 admin user-management
|
fire a `personal-direct` notification (event_kind
|
||||||
pass.
|
`permission_change_affecting_me`, already in the §15.1 enum) so
|
||||||
- **Admin notification on new beta request.** *Surfaced by
|
a granted user sees "Your beta-access request was approved" in
|
||||||
v0.8.0 — the capture endpoint writes to the row but does
|
their inbox and email, and a revoked user sees a parallel
|
||||||
not signal admins.* v0.9.0 candidate (item #7 again): when a
|
refusal notice. Decision points: does revocation include a
|
||||||
`POST /api/auth/me/beta-request` lands, fire an `admin-actionable`
|
reason field (probably yes — symmetric with §9.3's decline
|
||||||
notification (per §15.4's category set) to every owner / admin
|
comment); does grant carry a welcome message (probably no —
|
||||||
so the queue doesn't go stale. The §15 infrastructure already
|
the existing welcome surfaces are sufficient); does the
|
||||||
supports the category; the open question is whether the
|
notification escape the §15.8 mute path (probably yes — it's
|
||||||
notification is per-request (one email per submission) or
|
a personal-direct admission state change). Earns its session
|
||||||
digested (a daily summary). Earns its session alongside the
|
as a follow-up to the v0.9.0 page.
|
||||||
admin user-management page.
|
- **Decline-with-reason on permission revoke.** *Surfaced by
|
||||||
|
v0.9.0.* The current Revoke gesture takes only a confirmation;
|
||||||
|
there is no audit-visible reason captured. A future release
|
||||||
|
could add a free-text reason input that lands in the
|
||||||
|
`permission_events.details` JSON column (no schema change
|
||||||
|
needed — the column is already JSON-shaped). This is the
|
||||||
|
symmetric companion to the §9.3 proposal-decline contract.
|
||||||
|
Earns its session alongside the grant/revoke notification
|
||||||
|
candidate above.
|
||||||
- **Removing the Gitea OAuth fallback.** *Surfaced by v0.7.0.*
|
- **Removing the Gitea OAuth fallback.** *Surfaced by v0.7.0.*
|
||||||
v0.7.0 keeps `/auth/callback` functional and links to it as a
|
v0.7.0 keeps `/auth/callback` functional and links to it as a
|
||||||
"Sign in with Gitea (fallback)" affordance on the new `/login`
|
"Sign in with Gitea (fallback)" affordance on the new `/login`
|
||||||
@@ -3785,16 +3905,17 @@ Candidates surfaced during v0.8.0 (open beta-access request flow,
|
|||||||
its session once the OTC adoption curve flattens.
|
its session once the OTC adoption curve flattens.
|
||||||
- **Device trust (30-day skip).** *Surfaced by v0.7.0 — the
|
- **Device trust (30-day skip).** *Surfaced by v0.7.0 — the
|
||||||
signed-in cookie already lasts 30 days via SessionMiddleware,
|
signed-in cookie already lasts 30 days via SessionMiddleware,
|
||||||
but every sign-in still requires a fresh OTC.* The roadmap
|
but every sign-in still requires a fresh OTC or passcode.* The
|
||||||
item-#9 candidate adds a "trust this device" affordance on the
|
roadmap item-#9 candidate adds a "trust this device" affordance
|
||||||
verify step that issues a longer-lived rotating token, so
|
on the verify step that issues a longer-lived rotating token,
|
||||||
returning visitors on the same device skip the OTC step. The
|
so returning visitors on the same device skip both the OTC and
|
||||||
shape question is whether the trust is a signed cookie distinct
|
the passcode step. The shape question is whether the trust is a
|
||||||
from the session, a row in a `device_trust` table keyed by a
|
signed cookie distinct from the session, a row in a `device_trust`
|
||||||
random device-id, or a property of the session itself; and
|
table keyed by a random device-id, or a property of the session
|
||||||
whether the trust survives password-equivalent events (none
|
itself; and whether the trust survives password-equivalent events
|
||||||
exist yet — passcodes are item #8 / v0.10.0) or only survives
|
— v0.10.0's passcode-change and passcode-clear gestures are the
|
||||||
explicit logout. Earns its session as the v0.11.0 design pass.
|
v1 instances — or only survives explicit logout. Earns its
|
||||||
|
session as the v0.11.0 design pass.
|
||||||
- **Cloudflare Turnstile (or equivalent) on `/auth/otc/request`.**
|
- **Cloudflare Turnstile (or equivalent) on `/auth/otc/request`.**
|
||||||
*Surfaced by v0.7.0 — the endpoint is now the new abuse hot
|
*Surfaced by v0.7.0 — the endpoint is now the new abuse hot
|
||||||
path.* Per-email cooldown stops the trivial loop; what it
|
path.* Per-email cooldown stops the trivial loop; what it
|
||||||
@@ -3814,6 +3935,47 @@ Candidates surfaced during v0.8.0 (open beta-access request flow,
|
|||||||
harness mocks the challenge. Earns its session as the v0.12.0
|
harness mocks the challenge. Earns its session as the v0.12.0
|
||||||
design pass.
|
design pass.
|
||||||
|
|
||||||
|
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
|
Candidates surfaced during v0.13.0 (cookie / privacy consent, §14.5
|
||||||
and §14.6):
|
and §14.6):
|
||||||
|
|
||||||
|
|||||||
+34
-6
@@ -26,10 +26,12 @@ from . import (
|
|||||||
api_prs,
|
api_prs,
|
||||||
auth,
|
auth,
|
||||||
db,
|
db,
|
||||||
|
docs as docs_mod,
|
||||||
entry as entry_mod,
|
entry as entry_mod,
|
||||||
cache,
|
cache,
|
||||||
funder,
|
funder,
|
||||||
health,
|
health,
|
||||||
|
notify,
|
||||||
philosophy,
|
philosophy,
|
||||||
providers as providers_mod,
|
providers as providers_mod,
|
||||||
)
|
)
|
||||||
@@ -122,6 +124,17 @@ def make_router(
|
|||||||
payload = philosophy.load()
|
payload = philosophy.load()
|
||||||
return {"body": payload["body"]}
|
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.
|
# Auth surface — reads role from our users table per §6.
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
@@ -131,13 +144,12 @@ def make_router(
|
|||||||
user = auth.current_user(request)
|
user = auth.current_user(request)
|
||||||
if user is None:
|
if user is None:
|
||||||
return {"authenticated": False, "user": None}
|
return {"authenticated": False, "user": None}
|
||||||
# v0.8.0: surface `permission_state` plus the capture-flow
|
# v0.8.0 + v0.10.0: single round-trip for everything the
|
||||||
# readiness signal (`needs_profile`). The frontend gates
|
# frontend gates UI off of — beta-access state + passcode state.
|
||||||
# the /beta-pending page and the inline banner off these
|
|
||||||
# fields, and decides whether to prompt for the first/last/why
|
|
||||||
# capture on first OTC sign-in.
|
|
||||||
row = db.conn().execute(
|
row = db.conn().execute(
|
||||||
"SELECT first_name, last_name, beta_request_reason FROM users WHERE id = ?",
|
"SELECT first_name, last_name, beta_request_reason, "
|
||||||
|
"passcode_hash, passcode_set_at "
|
||||||
|
"FROM users WHERE id = ?",
|
||||||
(user.user_id,),
|
(user.user_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
first_name = (row["first_name"] if row else None) or ""
|
first_name = (row["first_name"] if row else None) or ""
|
||||||
@@ -153,6 +165,8 @@ def make_router(
|
|||||||
and not last_name
|
and not last_name
|
||||||
and not beta_request_reason
|
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 {
|
return {
|
||||||
"authenticated": True,
|
"authenticated": True,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -167,6 +181,8 @@ def make_router(
|
|||||||
"last_name": last_name,
|
"last_name": last_name,
|
||||||
"beta_request_reason": beta_request_reason,
|
"beta_request_reason": beta_request_reason,
|
||||||
"needs_profile": needs_profile,
|
"needs_profile": needs_profile,
|
||||||
|
"has_passcode": has_passcode,
|
||||||
|
"passcode_set_at": passcode_set_at,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +238,18 @@ def make_router(
|
|||||||
user.user_id,
|
user.user_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
# v0.9.0 (roadmap item #7): notify every admin/owner of the
|
||||||
|
# fresh request. Only the first submission is the
|
||||||
|
# "newly-pending" gesture — re-submits from the same user
|
||||||
|
# would otherwise carpet the admin inbox. We fire only when
|
||||||
|
# this is the row's first time getting all three fields
|
||||||
|
# populated (the prior row carried at least one NULL).
|
||||||
|
prior = row # captured before the UPDATE above
|
||||||
|
was_already_complete = bool(
|
||||||
|
prior["first_name"] and prior["last_name"] and prior["beta_request_reason"]
|
||||||
|
)
|
||||||
|
if not was_already_complete:
|
||||||
|
notify.fan_out_new_beta_request(requester_user_id=user.user_id)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|||||||
+117
-4
@@ -50,6 +50,16 @@ class MuteBody(BaseModel):
|
|||||||
muted: bool
|
muted: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionStateBody(BaseModel):
|
||||||
|
# v0.9.0: the admin flip from the user-management page (roadmap
|
||||||
|
# item #7). `pending` is not surfaceable from the admin UI —
|
||||||
|
# only the OTC verify path lands a row in `pending` — but we
|
||||||
|
# accept it in the pattern in case a future restore-to-queue
|
||||||
|
# gesture wants to re-pend a granted user; today the UI only
|
||||||
|
# exposes `granted` and `revoked`.
|
||||||
|
state: str = Field(pattern="^(pending|granted|revoked)$")
|
||||||
|
|
||||||
|
|
||||||
class AllowlistAddBody(BaseModel):
|
class AllowlistAddBody(BaseModel):
|
||||||
email: str = Field(min_length=3, max_length=320)
|
email: str = Field(min_length=3, max_length=320)
|
||||||
note: str | None = Field(default=None, max_length=200)
|
note: str | None = Field(default=None, max_length=200)
|
||||||
@@ -68,13 +78,41 @@ def make_router(config: Config) -> APIRouter:
|
|||||||
|
|
||||||
@router.get("/api/admin/users")
|
@router.get("/api/admin/users")
|
||||||
async def list_users(request: Request) -> dict[str, Any]:
|
async def list_users(request: Request) -> dict[str, Any]:
|
||||||
|
"""v0.9.0: the user-management surface (roadmap item #7).
|
||||||
|
|
||||||
|
The listing carries every column the admin queue needs to triage
|
||||||
|
pending beta-access requests alongside the existing role/mute
|
||||||
|
affordances. Sort order surfaces pending requests first (so the
|
||||||
|
admin lands on the inbox shape), then granted, then revoked;
|
||||||
|
within a state, ownership/role and recency are the tiebreakers
|
||||||
|
so the legacy ordering (owner first, then admin, then by name)
|
||||||
|
is preserved inside the granted bucket.
|
||||||
|
|
||||||
|
`permission_decided_by_login` joins the deciding admin row so
|
||||||
|
the UI can render "granted by @ben" without a second round-trip.
|
||||||
|
"""
|
||||||
auth.require_admin(request)
|
auth.require_admin(request)
|
||||||
rows = db.conn().execute(
|
rows = db.conn().execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, gitea_login, display_name, email, role, muted,
|
SELECT u.id, u.gitea_login, u.display_name, u.email, u.role, u.muted,
|
||||||
created_at, last_seen_at
|
u.created_at, u.last_seen_at,
|
||||||
FROM users
|
u.permission_state, u.first_name, u.last_name,
|
||||||
ORDER BY role = 'owner' DESC, role = 'admin' DESC, display_name COLLATE NOCASE
|
u.beta_request_reason,
|
||||||
|
u.permission_decided_by, u.permission_decided_at,
|
||||||
|
d.gitea_login AS decided_by_login,
|
||||||
|
d.display_name AS decided_by_display
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN users d ON d.id = u.permission_decided_by
|
||||||
|
ORDER BY
|
||||||
|
CASE u.permission_state
|
||||||
|
WHEN 'pending' THEN 0
|
||||||
|
WHEN 'granted' THEN 1
|
||||||
|
WHEN 'revoked' THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
u.role = 'owner' DESC, u.role = 'admin' DESC,
|
||||||
|
COALESCE(u.last_seen_at, u.created_at) DESC,
|
||||||
|
u.display_name COLLATE NOCASE
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return {
|
return {
|
||||||
@@ -88,6 +126,13 @@ def make_router(config: Config) -> APIRouter:
|
|||||||
"muted": bool(r["muted"]),
|
"muted": bool(r["muted"]),
|
||||||
"created_at": r["created_at"],
|
"created_at": r["created_at"],
|
||||||
"last_seen_at": r["last_seen_at"],
|
"last_seen_at": r["last_seen_at"],
|
||||||
|
"permission_state": r["permission_state"] or "granted",
|
||||||
|
"first_name": r["first_name"] or "",
|
||||||
|
"last_name": r["last_name"] or "",
|
||||||
|
"beta_request_reason": r["beta_request_reason"] or "",
|
||||||
|
"permission_decided_at": r["permission_decided_at"],
|
||||||
|
"permission_decided_by_login": r["decided_by_login"],
|
||||||
|
"permission_decided_by_display": r["decided_by_display"],
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@@ -136,6 +181,74 @@ def make_router(config: Config) -> APIRouter:
|
|||||||
)
|
)
|
||||||
return {"ok": True, "role": body.role, "changed": True}
|
return {"ok": True, "role": body.role, "changed": True}
|
||||||
|
|
||||||
|
# ----- Permission state (§6.1, v0.9.0 roadmap item #7) -----
|
||||||
|
|
||||||
|
@router.post("/api/admin/users/{user_id}/permission")
|
||||||
|
async def set_permission(user_id: int, body: PermissionStateBody, request: Request) -> dict[str, Any]:
|
||||||
|
"""Flip a user's `permission_state` between pending/granted/revoked.
|
||||||
|
|
||||||
|
v0.8.0 wired the column shape but shipped no admin UI for it —
|
||||||
|
the grant gesture was a manual `UPDATE users` against the DB.
|
||||||
|
v0.9.0 (roadmap item #7) lands the admin user-management page;
|
||||||
|
this endpoint is its single write surface.
|
||||||
|
|
||||||
|
Audit shape: every flip writes a `permission_events` row with
|
||||||
|
event_kind in {'permission_granted', 'permission_revoked',
|
||||||
|
'permission_repended'} so §6.5's log carries the change. The
|
||||||
|
`permission_decided_by` / `permission_decided_at` columns on
|
||||||
|
the user row are co-stamped so the user listing can render
|
||||||
|
"granted by @ben at <date>" without a second join through
|
||||||
|
the audit table.
|
||||||
|
|
||||||
|
Refuses with 422 if the admin tries to flip their own row
|
||||||
|
(no self-grant / self-revoke; symmetric to set_mute's
|
||||||
|
self-mute refusal and set_role's self-downgrade refusal).
|
||||||
|
"""
|
||||||
|
viewer = auth.require_admin(request)
|
||||||
|
target = db.conn().execute(
|
||||||
|
"SELECT id, role, permission_state FROM users WHERE id = ?",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(404, "User not found")
|
||||||
|
if target["id"] == viewer.user_id:
|
||||||
|
raise HTTPException(422, "You cannot change your own permission state")
|
||||||
|
|
||||||
|
before = target["permission_state"] or "granted"
|
||||||
|
after = body.state
|
||||||
|
if before == after:
|
||||||
|
return {"ok": True, "permission_state": after, "changed": False}
|
||||||
|
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET permission_state = ?,
|
||||||
|
permission_decided_by = ?,
|
||||||
|
permission_decided_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(after, viewer.user_id, user_id),
|
||||||
|
)
|
||||||
|
event_kind = {
|
||||||
|
"granted": "permission_granted",
|
||||||
|
"revoked": "permission_revoked",
|
||||||
|
"pending": "permission_repended",
|
||||||
|
}[after]
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO permission_events
|
||||||
|
(actor_user_id, subject_user_id, event_kind, details)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
viewer.user_id,
|
||||||
|
user_id,
|
||||||
|
event_kind,
|
||||||
|
json.dumps({"before": before, "after": after}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {"ok": True, "permission_state": after, "changed": True}
|
||||||
|
|
||||||
# ----- Write-mute (§6.2) -----
|
# ----- Write-mute (§6.2) -----
|
||||||
|
|
||||||
@router.post("/api/admin/users/{user_id}/mute")
|
@router.post("/api/admin/users/{user_id}/mute")
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""User-facing docs source.
|
||||||
|
|
||||||
|
Mirrors `philosophy.py` shape. Serves `DOCS.md` from the repo root —
|
||||||
|
the framework's plain-prose user guide to roles, contribution flow,
|
||||||
|
and notification surfaces, distinct from the binding `SPEC.md`. Read
|
||||||
|
from disk on first call and cached in-process; the periodic
|
||||||
|
reconciler can call `refresh()` to pick up out-of-band edits.
|
||||||
|
|
||||||
|
`DOCS_PATH` overrides the default location if a deployment hosts the
|
||||||
|
file elsewhere (a meta-repo working-tree clone, a sync target, etc.).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_PATH = Path(__file__).resolve().parents[2] / "DOCS.md"
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_cache: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolved_path() -> Path:
|
||||||
|
override = os.environ.get("DOCS_PATH", "").strip()
|
||||||
|
if override:
|
||||||
|
return Path(override).expanduser().resolve()
|
||||||
|
return _DEFAULT_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def load(force: bool = False) -> dict:
|
||||||
|
"""Return the cached `{body, path, mtime}` payload, reading from disk
|
||||||
|
on first call or when `force=True`.
|
||||||
|
"""
|
||||||
|
global _cache
|
||||||
|
with _lock:
|
||||||
|
if _cache is not None and not force:
|
||||||
|
return _cache
|
||||||
|
path = _resolved_path()
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
mtime = path.stat().st_mtime
|
||||||
|
except FileNotFoundError:
|
||||||
|
log.warning("DOCS.md not found at %s — serving placeholder", path)
|
||||||
|
text = (
|
||||||
|
"# DOCS.md not found\n\n"
|
||||||
|
"The deployment is missing its user guide. Set "
|
||||||
|
"DOCS_PATH or place DOCS.md at the project root."
|
||||||
|
)
|
||||||
|
mtime = 0.0
|
||||||
|
_cache = {"body": text, "path": str(path), "mtime": mtime}
|
||||||
|
return _cache
|
||||||
|
|
||||||
|
|
||||||
|
def refresh() -> dict:
|
||||||
|
"""Force-reread from disk. Returns the new payload."""
|
||||||
|
return load(force=True)
|
||||||
@@ -139,6 +139,10 @@ _EVENT_TO_CATEGORY: dict[str, str] = {
|
|||||||
"graduation_complete": "personal-direct",
|
"graduation_complete": "personal-direct",
|
||||||
"super_draft_graduation_ready": "admin-actionable",
|
"super_draft_graduation_ready": "admin-actionable",
|
||||||
"claim_opened": "structural",
|
"claim_opened": "structural",
|
||||||
|
# v0.9.0: roadmap item #7. A fresh beta-access request lands as
|
||||||
|
# an admin-actionable signal so it consults `email_admin_actionable`
|
||||||
|
# and reaches owners/admins only.
|
||||||
|
"new_beta_request": "admin-actionable",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -285,6 +289,13 @@ def _deep_link(payload: dict, cfg: EmailConfig) -> str:
|
|||||||
slug = payload.get("rfc_slug")
|
slug = payload.get("rfc_slug")
|
||||||
pr = payload.get("pr_number")
|
pr = payload.get("pr_number")
|
||||||
branch = payload.get("branch_name")
|
branch = payload.get("branch_name")
|
||||||
|
event_kind = payload.get("event_kind")
|
||||||
|
# v0.9.0: framework-scoped admin signals link to the admin
|
||||||
|
# surface, not /rfc/... The `new_beta_request` event is the
|
||||||
|
# canonical example; future framework-scoped admin events
|
||||||
|
# may reuse the same branch.
|
||||||
|
if event_kind == "new_beta_request":
|
||||||
|
return f"{cfg.app_url}/admin/users"
|
||||||
if slug and pr:
|
if slug and pr:
|
||||||
return f"{cfg.app_url}/rfc/{slug}/pr/{pr}"
|
return f"{cfg.app_url}/rfc/{slug}/pr/{pr}"
|
||||||
if slug and branch:
|
if slug and branch:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from . import (
|
|||||||
email_otc,
|
email_otc,
|
||||||
hygiene,
|
hygiene,
|
||||||
otc,
|
otc,
|
||||||
|
passcode as passcode_mod,
|
||||||
providers as providers_mod,
|
providers as providers_mod,
|
||||||
webhooks,
|
webhooks,
|
||||||
)
|
)
|
||||||
@@ -44,6 +45,15 @@ class OtcVerifyBody(BaseModel):
|
|||||||
code: str = Field(min_length=1, max_length=16)
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -204,4 +214,72 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
"needs_profile": needs_profile,
|
"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": {
|
||||||
|
"id": result.user.user_id,
|
||||||
|
"display_name": result.user.display_name,
|
||||||
|
"email": result.user.email,
|
||||||
|
"role": result.user.role,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ log = logging.getLogger(__name__)
|
|||||||
CATEGORY_PERSONAL = "personal-direct"
|
CATEGORY_PERSONAL = "personal-direct"
|
||||||
CATEGORY_STRUCTURAL = "structural"
|
CATEGORY_STRUCTURAL = "structural"
|
||||||
CATEGORY_CHURN = "churn"
|
CATEGORY_CHURN = "churn"
|
||||||
|
CATEGORY_ADMIN_ACTIONABLE = "admin-actionable"
|
||||||
|
|
||||||
# Action kinds whose actor's first interaction with a slug triggers
|
# Action kinds whose actor's first interaction with a slug triggers
|
||||||
# auto-watch per §15.6. The substantive-gesture list in the spec is
|
# auto-watch per §15.6. The substantive-gesture list in the spec is
|
||||||
@@ -208,6 +209,67 @@ def fan_out_from_action(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fan_out_new_beta_request(
|
||||||
|
*,
|
||||||
|
requester_user_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""v0.9.0 (roadmap item #7): announce a fresh beta-access request to
|
||||||
|
every admin/owner.
|
||||||
|
|
||||||
|
Called from `POST /api/auth/me/beta-request` after the row's
|
||||||
|
first/last/why fields are populated. Fan-out shape mirrors the §15
|
||||||
|
chokepoint contract: one row per recipient, written via `_emit_one`
|
||||||
|
so the SSE broadcast + email dispatch run through the same surface
|
||||||
|
every other notification uses. The event has no rfc_slug (it is
|
||||||
|
framework-scoped, not RFC-scoped); the deep-link payload points
|
||||||
|
`/admin/users` instead of `/rfc/<slug>`.
|
||||||
|
|
||||||
|
Actor is the requester per §15.9 (the underlying user, never the
|
||||||
|
bot). Category is `admin-actionable` so the §15.4 email gate
|
||||||
|
consults `email_admin_actionable` (owners/admins-only by
|
||||||
|
construction) and the digest exclusion rules treat it identically
|
||||||
|
to other admin-actionable signals (graduation_ready et al).
|
||||||
|
|
||||||
|
Recipients are owners + admins minus the requester themselves
|
||||||
|
(a self-promotion shouldn't reach the requester's own inbox). The
|
||||||
|
requester is never in the role set in practice — the endpoint
|
||||||
|
refuses 'granted'/'revoked' callers and a fresh OTC user lands
|
||||||
|
`contributor`+`pending` — but we filter regardless so the call
|
||||||
|
is robust to future changes in the auth gate.
|
||||||
|
"""
|
||||||
|
requester = db.conn().execute(
|
||||||
|
"SELECT first_name, last_name, email, display_name FROM users WHERE id = ?",
|
||||||
|
(requester_user_id,),
|
||||||
|
).fetchone()
|
||||||
|
if requester is None:
|
||||||
|
return
|
||||||
|
first = (requester["first_name"] or "").strip()
|
||||||
|
last = (requester["last_name"] or "").strip()
|
||||||
|
email = requester["email"] or ""
|
||||||
|
display = requester["display_name"] or email or "a new user"
|
||||||
|
full_name = (f"{first} {last}").strip() or display
|
||||||
|
details = {
|
||||||
|
"requester_user_id": requester_user_id,
|
||||||
|
"requester_first_name": first,
|
||||||
|
"requester_last_name": last,
|
||||||
|
"requester_email": email,
|
||||||
|
"requester_display": full_name,
|
||||||
|
}
|
||||||
|
for recipient_id in _admin_user_ids():
|
||||||
|
if recipient_id == requester_user_id:
|
||||||
|
continue
|
||||||
|
_emit_one(
|
||||||
|
recipient_user_id=recipient_id,
|
||||||
|
event_kind="new_beta_request",
|
||||||
|
category=CATEGORY_ADMIN_ACTIONABLE,
|
||||||
|
actor_user_id=requester_user_id,
|
||||||
|
rfc_slug=None,
|
||||||
|
branch_name=None,
|
||||||
|
pr_number=None,
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def fan_out_chat_message(
|
def fan_out_chat_message(
|
||||||
*,
|
*,
|
||||||
actor_user_id: int,
|
actor_user_id: int,
|
||||||
@@ -707,6 +769,16 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str |
|
|||||||
return f"{actor} began graduating {title}."
|
return f"{actor} began graduating {title}."
|
||||||
if event_kind == "pr_conflict_with_main":
|
if event_kind == "pr_conflict_with_main":
|
||||||
return f"{actor} started a resolution branch on {title}."
|
return f"{actor} started a resolution branch on {title}."
|
||||||
|
if event_kind == "new_beta_request":
|
||||||
|
# v0.9.0: framework-scoped, not RFC-scoped. The actor (the
|
||||||
|
# requester) and the captured full name + email read as
|
||||||
|
# one self-contained sentence; the inbox row and the email
|
||||||
|
# body share this text per §15.4.
|
||||||
|
full_name = extras.get("requester_display") or actor
|
||||||
|
email_addr = extras.get("requester_email") or ""
|
||||||
|
if email_addr:
|
||||||
|
return f"New beta-access request from {full_name} ({email_addr})."
|
||||||
|
return f"New beta-access request from {full_name}."
|
||||||
return f"{event_kind} on {title}"
|
return f"{event_kind} on {title}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
"""§6.2 / v0.10.0: user-set passcodes after OTC (roadmap item #8).
|
||||||
|
|
||||||
|
After a successful OTC sign-in, a contributor may set a passcode and
|
||||||
|
use email + passcode for subsequent sign-ins. OTC remains the fallback
|
||||||
|
— a forgotten passcode is recovered by requesting a fresh OTC.
|
||||||
|
|
||||||
|
This module is the state machine behind the four `/auth/passcode/*`
|
||||||
|
endpoints (`set`, `clear`, `verify`, `check`). The endpoints in
|
||||||
|
`main.py` thin-wrap these helpers in the same shape the OTC module
|
||||||
|
uses (see `otc.py`).
|
||||||
|
|
||||||
|
Shape:
|
||||||
|
|
||||||
|
* `set_passcode(user_id, passcode)` — bcrypt-hash the passcode and
|
||||||
|
write it to `users.passcode_hash` + `users.passcode_set_at`.
|
||||||
|
Validation (length, denylist) happens here, not at the endpoint,
|
||||||
|
so the rule lives in one place. Replaces any prior passcode.
|
||||||
|
* `clear_passcode(user_id)` — null out `passcode_hash` and
|
||||||
|
`passcode_set_at`. The user is back to OTC-only.
|
||||||
|
* `verify_passcode(email, passcode)` — locate the user by email,
|
||||||
|
check lockout, compare via bcrypt, manage the failure counter,
|
||||||
|
and return a populated `SessionUser` on success.
|
||||||
|
* `passcode_status(email)` — does this email have a passcode set?
|
||||||
|
Used by the `/auth/passcode/check` endpoint that the Login.jsx
|
||||||
|
flow consults after the user types their email.
|
||||||
|
|
||||||
|
Lockout is a v1 shape: 5 consecutive failures sets
|
||||||
|
`passcode_locked_until` to `now + 15 minutes`, after which a verify
|
||||||
|
attempt that lands inside the window returns HTTP 423. The OTC path
|
||||||
|
is unaffected by the lockout — a user can request and verify a fresh
|
||||||
|
OTC to sign in while their passcode is locked out, and `verify_code`
|
||||||
|
in `otc.py` does not consult these columns.
|
||||||
|
|
||||||
|
The lockout window and the failure threshold are hard-coded here.
|
||||||
|
Tuning them via env vars (or moving to per-IP rate-limiting) is a
|
||||||
|
§19.2 candidate; see SPEC §19.2.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .auth import SessionUser
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tunables — intentionally hard-coded in v0.10.0 (see module docstring).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
LOCKOUT_AFTER_FAILED_ATTEMPTS = 5
|
||||||
|
LOCKOUT_DURATION_MINUTES = 15
|
||||||
|
|
||||||
|
PASSCODE_MIN_LENGTH = 4
|
||||||
|
PASSCODE_MAX_LENGTH = 20
|
||||||
|
|
||||||
|
# A small denylist of patterns we never want a passcode to be. The
|
||||||
|
# rule is "no obvious patterns"; the list is deliberately small —
|
||||||
|
# every entry here is a verbatim string match. A heavier check
|
||||||
|
# (sequential digits, single-character runs of length >= N, etc.)
|
||||||
|
# is a §19.2 candidate.
|
||||||
|
PASSCODE_DENYLIST: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"0000",
|
||||||
|
"1111",
|
||||||
|
"2222",
|
||||||
|
"3333",
|
||||||
|
"4444",
|
||||||
|
"5555",
|
||||||
|
"6666",
|
||||||
|
"7777",
|
||||||
|
"8888",
|
||||||
|
"9999",
|
||||||
|
"1234",
|
||||||
|
"12345",
|
||||||
|
"123456",
|
||||||
|
"1234567",
|
||||||
|
"12345678",
|
||||||
|
"123456789",
|
||||||
|
"1234567890",
|
||||||
|
"0123",
|
||||||
|
"01234",
|
||||||
|
"012345",
|
||||||
|
"0123456",
|
||||||
|
"01234567",
|
||||||
|
"012345678",
|
||||||
|
"0123456789",
|
||||||
|
"abcd",
|
||||||
|
"abcde",
|
||||||
|
"abcdef",
|
||||||
|
"qwer",
|
||||||
|
"qwerty",
|
||||||
|
"asdf",
|
||||||
|
"asdfg",
|
||||||
|
"asdfgh",
|
||||||
|
"aaaa",
|
||||||
|
"bbbb",
|
||||||
|
"cccc",
|
||||||
|
"password",
|
||||||
|
"letmein",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Validation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class PasscodeValidationError(Exception):
|
||||||
|
"""The proposed passcode failed validation. The endpoint surface
|
||||||
|
maps this to HTTP 422 with the message intact."""
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(passcode: str) -> str:
|
||||||
|
"""Return the normalized passcode (stripped) or raise.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
* 4-20 characters after stripping leading/trailing whitespace.
|
||||||
|
* Not on the small denylist of obvious patterns.
|
||||||
|
|
||||||
|
No character-class restriction beyond that — the spec says
|
||||||
|
"numeric PIN or short alphanumeric"; we don't refuse other
|
||||||
|
characters because the entropy isn't load-bearing (the per-account
|
||||||
|
lockout is what carries the security weight, mirroring the OTC
|
||||||
|
shape from v0.7.0).
|
||||||
|
"""
|
||||||
|
pc = (passcode or "").strip()
|
||||||
|
if not pc:
|
||||||
|
raise PasscodeValidationError("Passcode is required")
|
||||||
|
if len(pc) < PASSCODE_MIN_LENGTH:
|
||||||
|
raise PasscodeValidationError(
|
||||||
|
f"Passcode must be at least {PASSCODE_MIN_LENGTH} characters"
|
||||||
|
)
|
||||||
|
if len(pc) > PASSCODE_MAX_LENGTH:
|
||||||
|
raise PasscodeValidationError(
|
||||||
|
f"Passcode must be at most {PASSCODE_MAX_LENGTH} characters"
|
||||||
|
)
|
||||||
|
if pc.lower() in PASSCODE_DENYLIST:
|
||||||
|
raise PasscodeValidationError("Passcode is too common; pick something less obvious")
|
||||||
|
return pc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hashing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _hash(passcode: str) -> str:
|
||||||
|
return bcrypt.hashpw(passcode.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _check(passcode: str, passcode_hash: str) -> bool:
|
||||||
|
try:
|
||||||
|
return bcrypt.checkpw(passcode.encode("utf-8"), passcode_hash.encode("ascii"))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Set / clear
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def set_passcode(user_id: int, passcode: str) -> None:
|
||||||
|
"""Hash and store the passcode. Replaces any prior passcode on the
|
||||||
|
same row; clears the failure counter and lockout (a user setting a
|
||||||
|
fresh passcode is implicitly re-authenticating their account)."""
|
||||||
|
pc = _validate(passcode)
|
||||||
|
h = _hash(pc)
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET passcode_hash = ?,
|
||||||
|
passcode_set_at = datetime('now'),
|
||||||
|
passcode_failed_attempts = 0,
|
||||||
|
passcode_locked_until = NULL
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(h, user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_passcode(user_id: int) -> None:
|
||||||
|
"""Remove the passcode. The user is back to OTC-only on next sign-in."""
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET passcode_hash = NULL,
|
||||||
|
passcode_set_at = NULL,
|
||||||
|
passcode_failed_attempts = 0,
|
||||||
|
passcode_locked_until = NULL
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Check (status surface for the Login.jsx flow)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PasscodeStatus:
|
||||||
|
"""The shape `/auth/passcode/check` returns.
|
||||||
|
|
||||||
|
`has_passcode` is the only signal the frontend needs to decide
|
||||||
|
whether to show a passcode input or an OTC request step. We do
|
||||||
|
not leak the hash, the set-at timestamp, or the lockout state —
|
||||||
|
a probing client that wants to know "is this account locked
|
||||||
|
out" can attempt a verify and read the 423.
|
||||||
|
"""
|
||||||
|
has_passcode: bool
|
||||||
|
|
||||||
|
|
||||||
|
def passcode_status(email: str) -> PasscodeStatus:
|
||||||
|
email = (email or "").strip()
|
||||||
|
if not email or "@" not in email:
|
||||||
|
return PasscodeStatus(has_passcode=False)
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT passcode_hash FROM users WHERE email = ? COLLATE NOCASE",
|
||||||
|
(email,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return PasscodeStatus(has_passcode=False)
|
||||||
|
return PasscodeStatus(has_passcode=bool(row["passcode_hash"]))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Verify
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VerifyOutcome:
|
||||||
|
"""Result of a `verify_passcode` call.
|
||||||
|
|
||||||
|
`reason` distinguishes the failure modes the endpoint surfaces as
|
||||||
|
distinct HTTP shapes:
|
||||||
|
* 'ok' — populated `user`, HTTP 200.
|
||||||
|
* 'unknown' — no user with this email, HTTP 400 (generic).
|
||||||
|
* 'no_passcode' — user exists but never set a passcode, HTTP 400
|
||||||
|
(the frontend should fall back to OTC).
|
||||||
|
* 'locked' — user is currently in the lockout window, HTTP
|
||||||
|
423. `locked_until` carries the ISO-8601 stamp for the client.
|
||||||
|
* 'wrong' — passcode didn't match. HTTP 400. If the failure
|
||||||
|
crossed the lockout threshold the row is now locked; the
|
||||||
|
endpoint surfaces this as a fresh `locked` response on the
|
||||||
|
next attempt rather than collapsing the two states here.
|
||||||
|
"""
|
||||||
|
ok: bool
|
||||||
|
user: SessionUser | None
|
||||||
|
reason: str
|
||||||
|
locked_until: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def verify_passcode(email: str, passcode: str) -> VerifyOutcome:
|
||||||
|
email = (email or "").strip()
|
||||||
|
passcode = (passcode or "").strip()
|
||||||
|
if not email or not passcode:
|
||||||
|
return VerifyOutcome(ok=False, user=None, reason="unknown")
|
||||||
|
|
||||||
|
row = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role,
|
||||||
|
passcode_hash, passcode_failed_attempts, passcode_locked_until
|
||||||
|
FROM users
|
||||||
|
WHERE email = ? COLLATE NOCASE
|
||||||
|
""",
|
||||||
|
(email,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return VerifyOutcome(ok=False, user=None, reason="unknown")
|
||||||
|
if not row["passcode_hash"]:
|
||||||
|
return VerifyOutcome(ok=False, user=None, reason="no_passcode")
|
||||||
|
|
||||||
|
# Lockout check: if `passcode_locked_until` is populated and in the
|
||||||
|
# future, the verify is refused without touching the hash. Once the
|
||||||
|
# window has elapsed we let the verify proceed; the failed-attempts
|
||||||
|
# counter is also reset so the user gets a fresh 5-attempt budget.
|
||||||
|
locked_until = row["passcode_locked_until"]
|
||||||
|
if locked_until:
|
||||||
|
still_locked = db.conn().execute(
|
||||||
|
"SELECT datetime(?) > datetime('now') AS still_locked",
|
||||||
|
(locked_until,),
|
||||||
|
).fetchone()["still_locked"]
|
||||||
|
if still_locked:
|
||||||
|
return VerifyOutcome(
|
||||||
|
ok=False,
|
||||||
|
user=None,
|
||||||
|
reason="locked",
|
||||||
|
locked_until=locked_until,
|
||||||
|
)
|
||||||
|
# Lockout expired — clear the counter so the next failure starts
|
||||||
|
# from zero, and continue with the verify.
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET passcode_failed_attempts = 0,
|
||||||
|
passcode_locked_until = NULL
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(row["id"],),
|
||||||
|
)
|
||||||
|
|
||||||
|
if _check(passcode, row["passcode_hash"]):
|
||||||
|
# Success: clear the counter (a single success wipes the
|
||||||
|
# accumulated failures — the threshold tracks *consecutive*
|
||||||
|
# failures).
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET passcode_failed_attempts = 0,
|
||||||
|
passcode_locked_until = NULL,
|
||||||
|
last_seen_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(row["id"],),
|
||||||
|
)
|
||||||
|
return VerifyOutcome(
|
||||||
|
ok=True,
|
||||||
|
user=SessionUser(
|
||||||
|
user_id=row["id"],
|
||||||
|
gitea_id=row["gitea_id"] or 0,
|
||||||
|
gitea_login=row["gitea_login"] or "",
|
||||||
|
display_name=row["display_name"],
|
||||||
|
email=row["email"] or email,
|
||||||
|
avatar_url=row["avatar_url"] or "",
|
||||||
|
role=row["role"],
|
||||||
|
),
|
||||||
|
reason="ok",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Failure: increment the counter. If this push crosses the
|
||||||
|
# threshold, stamp the lockout. The next verify attempt against
|
||||||
|
# the same row returns 423 with the `locked_until` stamp.
|
||||||
|
next_count = (row["passcode_failed_attempts"] or 0) + 1
|
||||||
|
if next_count >= LOCKOUT_AFTER_FAILED_ATTEMPTS:
|
||||||
|
db.conn().execute(
|
||||||
|
f"""
|
||||||
|
UPDATE users
|
||||||
|
SET passcode_failed_attempts = ?,
|
||||||
|
passcode_locked_until = datetime('now', '+{LOCKOUT_DURATION_MINUTES} minutes')
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(next_count, row["id"]),
|
||||||
|
)
|
||||||
|
new_locked_until = db.conn().execute(
|
||||||
|
"SELECT passcode_locked_until FROM users WHERE id = ?",
|
||||||
|
(row["id"],),
|
||||||
|
).fetchone()["passcode_locked_until"]
|
||||||
|
return VerifyOutcome(
|
||||||
|
ok=False,
|
||||||
|
user=None,
|
||||||
|
reason="locked",
|
||||||
|
locked_until=new_locked_until,
|
||||||
|
)
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE users SET passcode_failed_attempts = ? WHERE id = ?",
|
||||||
|
(next_count, row["id"]),
|
||||||
|
)
|
||||||
|
return VerifyOutcome(ok=False, user=None, reason="wrong")
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
-- §6.2 / v0.10.0: user-set passcodes after OTC (roadmap item #8).
|
||||||
|
--
|
||||||
|
-- After a successful OTC sign-in, a contributor may set a passcode
|
||||||
|
-- (numeric PIN or short alphanumeric). Subsequent sign-ins on the same
|
||||||
|
-- account can use email + passcode instead of email + OTC. OTC remains
|
||||||
|
-- the structural fallback — a forgotten passcode is recovered by
|
||||||
|
-- requesting a fresh OTC and signing in via that path. Per-account
|
||||||
|
-- lockout after 5 consecutive verify failures redirects the user to
|
||||||
|
-- the OTC path for 15 minutes; the OTC path itself is unaffected by
|
||||||
|
-- the passcode lockout (a locked-out user can still receive a fresh
|
||||||
|
-- code and sign in).
|
||||||
|
--
|
||||||
|
-- The columns are additive to the `users` table from `012_otc.sql`.
|
||||||
|
-- v0.8.0's `permission_state` column (roadmap item #6) lands in the
|
||||||
|
-- driver's integration order ahead of this migration; we do not touch
|
||||||
|
-- that column here. v0.7.0's nullable-`gitea_id`/`gitea_login` shape
|
||||||
|
-- is preserved verbatim.
|
||||||
|
--
|
||||||
|
-- Storage shape:
|
||||||
|
--
|
||||||
|
-- * `passcode_hash` (nullable) — bcrypt hash of the passcode.
|
||||||
|
-- NULL means "no passcode set"; the user is OTC-only.
|
||||||
|
-- * `passcode_set_at` (nullable) — timestamp of the most recent
|
||||||
|
-- `passcode/set` call. Updated when a passcode is set or
|
||||||
|
-- replaced; cleared when the passcode is removed.
|
||||||
|
-- * `passcode_failed_attempts` — count of consecutive failed
|
||||||
|
-- verify attempts since the last successful verify (or since
|
||||||
|
-- the lockout cleared). Resets to 0 on success and on lockout
|
||||||
|
-- expiry. Defaults to 0 so existing rows post-migration are
|
||||||
|
-- not implicitly half-locked.
|
||||||
|
-- * `passcode_locked_until` (nullable) — if populated and the
|
||||||
|
-- timestamp is in the future, passcode verify is refused with
|
||||||
|
-- HTTP 423. Cleared on successful verify after the window
|
||||||
|
-- expires, or by the operator via direct DB intervention if
|
||||||
|
-- ever needed (no admin endpoint surfaces this in v1).
|
||||||
|
--
|
||||||
|
-- v0.10.0 introduces no new env vars. The lockout window (5 attempts,
|
||||||
|
-- 15 minutes) is hard-coded in `backend/app/passcode.py`; raising or
|
||||||
|
-- lowering it is a future-§19.2 candidate. Passcode hashing reuses
|
||||||
|
-- the bcrypt dependency added in v0.7.0 for OTC; no new secret is
|
||||||
|
-- required (the existing `SECRET_KEY` continues to sign sessions).
|
||||||
|
--
|
||||||
|
-- Note on SQLite: ALTER TABLE ... ADD COLUMN is supported, so this
|
||||||
|
-- migration does not need the rebuild dance that `012_otc.sql`
|
||||||
|
-- required. The runner wraps each file in a single BEGIN/COMMIT
|
||||||
|
-- block — see `backend/app/db.py` — so either every ADD COLUMN
|
||||||
|
-- here lands or none do.
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN passcode_hash TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN passcode_set_at TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN passcode_failed_attempts INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE users ADD COLUMN passcode_locked_until TEXT;
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
"""End-to-end integration tests for v0.9.0's admin user-management page
|
||||||
|
and new-beta-request notifications (roadmap item #7, §6.1 / §15).
|
||||||
|
|
||||||
|
The release lands two halves of the same surface:
|
||||||
|
|
||||||
|
* **Admin notification on new beta request.** When a pending user
|
||||||
|
submits `POST /api/auth/me/beta-request`, every owner/admin
|
||||||
|
receives a `new_beta_request` notification (the §15 substrate
|
||||||
|
insert lands the row; the §15.4 email path dispatches subject to
|
||||||
|
the recipient's `email_admin_actionable` toggle).
|
||||||
|
|
||||||
|
* **Admin user-management surface** at `/admin/users`. The
|
||||||
|
`GET /api/admin/users` listing carries every user with their
|
||||||
|
permission_state, profile fields, sign-up reason, and decision
|
||||||
|
audit. The new `POST /api/admin/users/<id>/permission` endpoint
|
||||||
|
flips the column and writes a `permission_events` row.
|
||||||
|
|
||||||
|
The tests prove:
|
||||||
|
|
||||||
|
* The first beta-request submission fans a `new_beta_request`
|
||||||
|
row out to every admin/owner (and not to the requester
|
||||||
|
themselves). The row carries the captured profile in
|
||||||
|
`payload.extras`.
|
||||||
|
* Re-submitting the form from the same pending user doesn't
|
||||||
|
re-fan (we only notify on the row's first complete state).
|
||||||
|
* `GET /api/admin/users` carries the v0.9.0 columns
|
||||||
|
(permission_state, first/last/reason, decided_by).
|
||||||
|
* `POST /api/admin/users/<id>/permission` flips the state,
|
||||||
|
stamps decided_by/at, and writes a `permission_events` row.
|
||||||
|
* The endpoint refuses self-flip (422) and refuses non-admin
|
||||||
|
callers (403).
|
||||||
|
* The endpoint accepts only the three valid states (422 on
|
||||||
|
anything else).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||||
|
FakeGitea,
|
||||||
|
app_with_fake_gitea,
|
||||||
|
provision_user_row,
|
||||||
|
sign_in_as,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_outbound():
|
||||||
|
from app import email as email_mod
|
||||||
|
email_mod.reset_sent_envelopes()
|
||||||
|
|
||||||
|
|
||||||
|
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
|
||||||
|
from app import email as email_mod
|
||||||
|
out = []
|
||||||
|
for env in email_mod.sent_envelopes():
|
||||||
|
if env.get("kind") != "otc":
|
||||||
|
continue
|
||||||
|
if to_address is not None and env["to"] != to_address:
|
||||||
|
continue
|
||||||
|
for line in env["body"].splitlines():
|
||||||
|
tok = line.strip()
|
||||||
|
if tok.isdigit() and len(tok) == 6:
|
||||||
|
out.append(tok)
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _provision_pending_user(client, email: str) -> int:
|
||||||
|
"""Sign in a fresh OTC user (lands `pending`) and return their user_id."""
|
||||||
|
from app import db
|
||||||
|
_reset_outbound()
|
||||||
|
client.post("/auth/otc/request", json={"email": email})
|
||||||
|
code = _outbound_otc_codes(email)[-1]
|
||||||
|
client.post("/auth/otc/verify", json={"email": email, "code": code})
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT id FROM users WHERE email = ? COLLATE NOCASE", (email,)
|
||||||
|
).fetchone()
|
||||||
|
return row["id"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Admin notification on beta-request submission
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_beta_request_submission_notifies_every_admin(app_with_fake_gitea):
|
||||||
|
"""First-time submission of a beta-request fans a notification out
|
||||||
|
to every owner and admin. The requester themselves never receives
|
||||||
|
a row (filtered out by user_id even if they happened to be in the
|
||||||
|
admin set, which they aren't in practice — fresh OTC users are
|
||||||
|
`contributor`+`pending`)."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
# Provision two admins and one owner so the fan-out has multiple
|
||||||
|
# targets. The OWNER_GITEA_LOGIN-derived ownership doesn't fire
|
||||||
|
# here (no OAuth round-trip in this path); we seed the role
|
||||||
|
# directly.
|
||||||
|
provision_user_row(user_id=10, login="ownerzero", role="owner")
|
||||||
|
provision_user_row(user_id=11, login="admin_one", role="admin")
|
||||||
|
provision_user_row(user_id=12, login="admin_two", role="admin")
|
||||||
|
provision_user_row(user_id=13, login="contrib_one", role="contributor")
|
||||||
|
|
||||||
|
# Sign in a fresh OTC user → permission_state='pending'.
|
||||||
|
requester_id = _provision_pending_user(client, "newbie@example.com")
|
||||||
|
|
||||||
|
# Capture-form submit.
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/me/beta-request",
|
||||||
|
json={
|
||||||
|
"first_name": "Newt",
|
||||||
|
"last_name": "Newcomer",
|
||||||
|
"beta_request_reason": "I want to write the Human RFC.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Every owner + admin gets a `new_beta_request` notification.
|
||||||
|
# The contributor (id=13) does not. The requester (whoever id
|
||||||
|
# they got) does not.
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT recipient_user_id, event_kind, actor_user_id, payload
|
||||||
|
FROM notifications
|
||||||
|
WHERE event_kind = 'new_beta_request'
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
recipients = sorted(r["recipient_user_id"] for r in rows)
|
||||||
|
assert recipients == [10, 11, 12], f"unexpected recipients: {recipients}"
|
||||||
|
# Actor is the requester (§15.9: never the bot).
|
||||||
|
for r in rows:
|
||||||
|
assert r["actor_user_id"] == requester_id
|
||||||
|
import json as _json
|
||||||
|
extras = _json.loads(r["payload"])
|
||||||
|
assert extras["requester_first_name"] == "Newt"
|
||||||
|
assert extras["requester_last_name"] == "Newcomer"
|
||||||
|
assert extras["requester_email"] == "newbie@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_beta_request_resubmit_does_not_re_notify(app_with_fake_gitea):
|
||||||
|
"""Once a user has completed the capture form, re-submitting it
|
||||||
|
(the endpoint is idempotent for pending users) must not re-fan a
|
||||||
|
fresh notification to every admin — that would carpet-bomb the
|
||||||
|
inbox on every typo correction."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=20, login="adminzero", role="admin")
|
||||||
|
_provision_pending_user(client, "carpet@example.com")
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"first_name": "Carpet",
|
||||||
|
"last_name": "Bomb",
|
||||||
|
"beta_request_reason": "first draft",
|
||||||
|
}
|
||||||
|
r1 = client.post("/api/auth/me/beta-request", json=body)
|
||||||
|
assert r1.status_code == 200
|
||||||
|
|
||||||
|
# Re-submit with edited reason — endpoint accepts (idempotent
|
||||||
|
# update), but the admin inbox stays at one row.
|
||||||
|
body2 = dict(body, beta_request_reason="cleaner final draft")
|
||||||
|
r2 = client.post("/api/auth/me/beta-request", json=body2)
|
||||||
|
assert r2.status_code == 200
|
||||||
|
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM notifications WHERE event_kind = 'new_beta_request'"
|
||||||
|
).fetchone()
|
||||||
|
assert rows["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_beta_request_notification_is_admin_actionable_category(app_with_fake_gitea):
|
||||||
|
"""The §15.4 category mapping must route `new_beta_request` to the
|
||||||
|
admin-actionable bucket so the email gate consults
|
||||||
|
`email_admin_actionable` (and skips for non-admin recipients).
|
||||||
|
"""
|
||||||
|
from app import email as email_mod
|
||||||
|
|
||||||
|
assert email_mod.category_for("new_beta_request", "structural") == "admin-actionable"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/admin/users — listing carries the v0.9.0 columns
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_users_listing_carries_permission_columns(app_with_fake_gitea):
|
||||||
|
"""The Users tab consumes this shape — confirm every required
|
||||||
|
column is on the response."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
# Seed an admin and a pending user with all the v0.8.0 columns
|
||||||
|
# populated. Direct-DB insert avoids the OTC dance (which would
|
||||||
|
# overwrite the cookie); the test above proves the capture
|
||||||
|
# pathway end-to-end and this one just exercises the listing
|
||||||
|
# surface's shape.
|
||||||
|
provision_user_row(user_id=30, login="ben", role="owner")
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (id, gitea_id, gitea_login, email,
|
||||||
|
display_name, avatar_url, role,
|
||||||
|
permission_state, first_name, last_name,
|
||||||
|
beta_request_reason)
|
||||||
|
VALUES (31, NULL, NULL, 'pendinguser@example.com',
|
||||||
|
'pendinguser', '', 'contributor',
|
||||||
|
'pending', 'Penn', 'Ding', 'I want in.')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=30, gitea_login="ben",
|
||||||
|
display_name="Ben", role="owner",
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.get("/api/admin/users")
|
||||||
|
assert r.status_code == 200
|
||||||
|
items = r.json()["items"]
|
||||||
|
assert isinstance(items, list)
|
||||||
|
pending = next(
|
||||||
|
(i for i in items if i["email"] == "pendinguser@example.com"), None,
|
||||||
|
)
|
||||||
|
assert pending is not None
|
||||||
|
assert pending["permission_state"] == "pending"
|
||||||
|
assert pending["first_name"] == "Penn"
|
||||||
|
assert pending["last_name"] == "Ding"
|
||||||
|
assert pending["beta_request_reason"] == "I want in."
|
||||||
|
assert pending["permission_decided_at"] is None
|
||||||
|
assert pending["permission_decided_by_login"] is None
|
||||||
|
# Pending bucket is listed first (sort order).
|
||||||
|
assert items[0]["permission_state"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/admin/users/<id>/permission — the flip endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_flip_grant_promotes_pending_to_granted(app_with_fake_gitea):
|
||||||
|
"""The end-to-end gesture: a fresh OTC user lands pending, an admin
|
||||||
|
flips them to granted via the endpoint, the row reflects the new
|
||||||
|
state + decided_by/at, and a `permission_events` audit row lands."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
# Pending user.
|
||||||
|
pending_id = _provision_pending_user(client, "flip@example.com")
|
||||||
|
|
||||||
|
# Admin acting on them.
|
||||||
|
provision_user_row(user_id=40, login="adminflipper", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=40, gitea_login="adminflipper",
|
||||||
|
display_name="Admin Flipper", role="admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
f"/api/admin/users/{pending_id}/permission",
|
||||||
|
json={"state": "granted"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["permission_state"] == "granted"
|
||||||
|
assert body["changed"] is True
|
||||||
|
|
||||||
|
# Row reflects the new state + decision stamp.
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT permission_state, permission_decided_by, permission_decided_at "
|
||||||
|
"FROM users WHERE id = ?",
|
||||||
|
(pending_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert row["permission_state"] == "granted"
|
||||||
|
assert row["permission_decided_by"] == 40
|
||||||
|
assert row["permission_decided_at"] is not None
|
||||||
|
|
||||||
|
# Audit row landed in permission_events.
|
||||||
|
events = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT actor_user_id, subject_user_id, event_kind
|
||||||
|
FROM permission_events
|
||||||
|
WHERE event_kind = 'permission_granted'
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0]["actor_user_id"] == 40
|
||||||
|
assert events[0]["subject_user_id"] == pending_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_flip_revoke_promotes_granted_to_revoked(app_with_fake_gitea):
|
||||||
|
"""Revoke is the symmetric gesture. Used when an account earned a
|
||||||
|
grant then later lost it (§6.1 / `revoked` state)."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=50, login="goner", role="contributor")
|
||||||
|
# Default permission_state is 'granted' via the column default.
|
||||||
|
provision_user_row(user_id=51, login="adminrevoker", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=51, gitea_login="adminrevoker",
|
||||||
|
display_name="Admin Revoker", role="admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users/50/permission",
|
||||||
|
json={"state": "revoked"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT permission_state FROM users WHERE id = 50"
|
||||||
|
).fetchone()
|
||||||
|
assert row["permission_state"] == "revoked"
|
||||||
|
|
||||||
|
events = db.conn().execute(
|
||||||
|
"SELECT event_kind FROM permission_events "
|
||||||
|
"WHERE event_kind = 'permission_revoked' AND subject_user_id = 50"
|
||||||
|
).fetchall()
|
||||||
|
assert len(events) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_flip_refuses_self(app_with_fake_gitea):
|
||||||
|
"""Symmetric to set_mute / set_role: an admin can't self-flip.
|
||||||
|
The state-change channel for one's own grant is somebody else's
|
||||||
|
hand."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=60, login="selfflipper", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=60, gitea_login="selfflipper",
|
||||||
|
display_name="Self Flipper", role="admin",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users/60/permission",
|
||||||
|
json={"state": "revoked"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_flip_refuses_non_admin(app_with_fake_gitea):
|
||||||
|
"""The endpoint is admin-only (§17 admin/* requires require_admin).
|
||||||
|
A contributor caller is refused 403; an anonymous caller 401."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=70, login="target", role="contributor")
|
||||||
|
provision_user_row(user_id=71, login="contrib", role="contributor")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=71, gitea_login="contrib",
|
||||||
|
display_name="Contrib", role="contributor",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users/70/permission",
|
||||||
|
json={"state": "granted"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users/70/permission",
|
||||||
|
json={"state": "granted"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_flip_refuses_invalid_state(app_with_fake_gitea):
|
||||||
|
"""Pydantic regex pattern refuses anything outside the three
|
||||||
|
canonical states with 422."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=80, login="targetx", role="contributor")
|
||||||
|
provision_user_row(user_id=81, login="adminx", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=81, gitea_login="adminx",
|
||||||
|
display_name="Admin X", role="admin",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users/80/permission",
|
||||||
|
json={"state": "banished"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_flip_no_op_when_state_already_matches(app_with_fake_gitea):
|
||||||
|
"""An admin flipping a granted user to granted gets 200 with
|
||||||
|
`changed: false` — no audit row, no decided_at update."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=90, login="alreadygranted", role="contributor")
|
||||||
|
provision_user_row(user_id=91, login="adminN", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=91, gitea_login="adminN",
|
||||||
|
display_name="Admin N", role="admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
before_events = db.conn().execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM permission_events"
|
||||||
|
).fetchone()["n"]
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users/90/permission",
|
||||||
|
json={"state": "granted"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["changed"] is False
|
||||||
|
|
||||||
|
after_events = db.conn().execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM permission_events"
|
||||||
|
).fetchone()["n"]
|
||||||
|
assert after_events == before_events
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
"""End-to-end integration tests for the v0.10.0 user-set passcode
|
||||||
|
vertical (§6.2, roadmap item #8).
|
||||||
|
|
||||||
|
After a successful OTC sign-in the user can set a passcode and use
|
||||||
|
email + passcode for subsequent sign-ins. OTC remains the structural
|
||||||
|
fallback — these tests prove:
|
||||||
|
|
||||||
|
* `/auth/passcode/set` requires an active session.
|
||||||
|
* `/auth/passcode/check` returns `has_passcode` without leaking the
|
||||||
|
hash, the set-at stamp, or the lockout state.
|
||||||
|
* Happy path: OTC sign-in → set passcode → sign out → email +
|
||||||
|
passcode signs in (no OTC roundtrip).
|
||||||
|
* Wrong passcode increments the failure counter without locking.
|
||||||
|
* Five consecutive failures lock the passcode path (HTTP 423) and
|
||||||
|
persist `passcode_locked_until` on the user row.
|
||||||
|
* The lockout expires after `passcode_locked_until`; a verify
|
||||||
|
attempt past the window succeeds again and clears the counter.
|
||||||
|
* The OTC path is unaffected by the passcode lockout — a user
|
||||||
|
whose passcode is locked can still request and verify a fresh
|
||||||
|
OTC to sign in.
|
||||||
|
* Clearing the passcode wipes the hash; subsequent verify refuses
|
||||||
|
with the no-passcode failure shape.
|
||||||
|
* Setting a new passcode replaces the prior one (and resets the
|
||||||
|
failure counter / lockout state).
|
||||||
|
* `passcode_set_at` updates on every set call.
|
||||||
|
* The validation denylist refuses obvious patterns (e.g. `0000`,
|
||||||
|
`1234`).
|
||||||
|
* Passcode length is enforced (4-20).
|
||||||
|
|
||||||
|
The fakes from `test_propose_vertical` give us a working app harness.
|
||||||
|
The OTC envelope buffer from `test_otc_vertical` is reused for the
|
||||||
|
OTC roundtrips this suite needs.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401
|
||||||
|
FakeGitea,
|
||||||
|
app_with_fake_gitea,
|
||||||
|
provision_user_row,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers — mirror the OTC suite's outbound-buffer helpers.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_outbound():
|
||||||
|
from app import email as email_mod
|
||||||
|
email_mod.reset_sent_envelopes()
|
||||||
|
|
||||||
|
|
||||||
|
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
|
||||||
|
from app import email as email_mod
|
||||||
|
out = []
|
||||||
|
for env in email_mod.sent_envelopes():
|
||||||
|
if env.get("kind") != "otc":
|
||||||
|
continue
|
||||||
|
if to_address is not None and env["to"] != to_address:
|
||||||
|
continue
|
||||||
|
for line in env["body"].splitlines():
|
||||||
|
tok = line.strip()
|
||||||
|
if tok.isdigit() and len(tok) == 6:
|
||||||
|
out.append(tok)
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _sign_in_via_otc(client, email: str) -> None:
|
||||||
|
"""Run an OTC request+verify so the client carries an authenticated
|
||||||
|
session. The cooldown is irrelevant on a fresh email; we don't
|
||||||
|
need to drop it."""
|
||||||
|
r = client.post("/auth/otc/request", json={"email": email})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
code = _outbound_otc_codes(email)[-1]
|
||||||
|
r = client.post("/auth/otc/verify", json={"email": email, "code": code})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Set passcode — auth-required, happy path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_passcode_requires_session(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_passcode_after_otc_landing_persists_hash(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "alice@example.com")
|
||||||
|
|
||||||
|
r = client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT passcode_hash, passcode_set_at FROM users WHERE email = ? COLLATE NOCASE",
|
||||||
|
("alice@example.com",),
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row["passcode_hash"] is not None
|
||||||
|
# Not the plaintext.
|
||||||
|
assert row["passcode_hash"] != "secret123"
|
||||||
|
assert row["passcode_set_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Check endpoint — leak-free shape
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_endpoint_returns_false_for_unknown_email(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/auth/passcode/check", params={"email": "nobody@example.com"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"has_passcode": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_endpoint_returns_false_for_user_without_passcode(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "bob@example.com")
|
||||||
|
|
||||||
|
r = client.get("/auth/passcode/check", params={"email": "bob@example.com"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"has_passcode": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_endpoint_returns_true_after_set(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "carol@example.com")
|
||||||
|
client.post("/auth/passcode/set", json={"passcode": "letmein9"})
|
||||||
|
|
||||||
|
# Drop the session so the check is read in the anonymous shape.
|
||||||
|
client.cookies.clear()
|
||||||
|
r = client.get("/auth/passcode/check", params={"email": "carol@example.com"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"has_passcode": True}
|
||||||
|
# The response carries ONLY the boolean — no hash, no stamp.
|
||||||
|
assert set(r.json().keys()) == {"has_passcode"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Verify path — happy path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_passcode_signs_in_user(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "dave@example.com")
|
||||||
|
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/auth/passcode/verify",
|
||||||
|
json={"email": "dave@example.com", "passcode": "secret123"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
me = client.get("/api/auth/me").json()
|
||||||
|
assert me["authenticated"] is True
|
||||||
|
assert me["user"]["email"] == "dave@example.com"
|
||||||
|
assert me["user"]["has_passcode"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Verify path — failure modes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_passcode_wrong_increments_counter_without_locking(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "erin@example.com")
|
||||||
|
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
# Three bad attempts — under the lockout threshold.
|
||||||
|
for _ in range(3):
|
||||||
|
r = client.post(
|
||||||
|
"/auth/passcode/verify",
|
||||||
|
json={"email": "erin@example.com", "passcode": "wrongwrong"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?",
|
||||||
|
("erin@example.com",),
|
||||||
|
).fetchone()
|
||||||
|
assert row["passcode_failed_attempts"] == 3
|
||||||
|
assert row["passcode_locked_until"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_passcode_locks_after_five_failures(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "frank@example.com")
|
||||||
|
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
# Five bad attempts — the last crosses the threshold and the
|
||||||
|
# response shape flips to 423.
|
||||||
|
statuses = []
|
||||||
|
for _ in range(5):
|
||||||
|
r = client.post(
|
||||||
|
"/auth/passcode/verify",
|
||||||
|
json={"email": "frank@example.com", "passcode": "wrongwrong"},
|
||||||
|
)
|
||||||
|
statuses.append(r.status_code)
|
||||||
|
# First four are 400, the fifth (threshold-crossing) is 423.
|
||||||
|
assert statuses == [400, 400, 400, 400, 423]
|
||||||
|
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?",
|
||||||
|
("frank@example.com",),
|
||||||
|
).fetchone()
|
||||||
|
assert row["passcode_failed_attempts"] >= 5
|
||||||
|
assert row["passcode_locked_until"] is not None
|
||||||
|
|
||||||
|
# Sixth attempt — still locked, still 423, even with the correct
|
||||||
|
# passcode (lockout overrides the verify).
|
||||||
|
r = client.post(
|
||||||
|
"/auth/passcode/verify",
|
||||||
|
json={"email": "frank@example.com", "passcode": "secret123"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 423
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_passcode_lockout_expires(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "gina@example.com")
|
||||||
|
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
for _ in range(5):
|
||||||
|
client.post(
|
||||||
|
"/auth/passcode/verify",
|
||||||
|
json={"email": "gina@example.com", "passcode": "wrongwrong"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backdate the lockout to the past so the next attempt clears it.
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET passcode_locked_until = datetime('now', '-1 minute')
|
||||||
|
WHERE email = ?
|
||||||
|
""",
|
||||||
|
("gina@example.com",),
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/auth/passcode/verify",
|
||||||
|
json={"email": "gina@example.com", "passcode": "secret123"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Lockout cleared, counter reset.
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?",
|
||||||
|
("gina@example.com",),
|
||||||
|
).fetchone()
|
||||||
|
assert row["passcode_failed_attempts"] == 0
|
||||||
|
assert row["passcode_locked_until"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_path_unaffected_by_passcode_lockout(app_with_fake_gitea, monkeypatch):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
# Drop the OTC cooldown so the second request lands without a 429.
|
||||||
|
# The cooldown is re-read from env on every `request_code` call so
|
||||||
|
# this takes effect mid-process.
|
||||||
|
monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0")
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "harvey@example.com")
|
||||||
|
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
# Lock the passcode path.
|
||||||
|
for _ in range(5):
|
||||||
|
client.post(
|
||||||
|
"/auth/passcode/verify",
|
||||||
|
json={"email": "harvey@example.com", "passcode": "wrongwrong"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# The OTC path is unaffected by the passcode lockout: the user
|
||||||
|
# can still request and verify a fresh code to sign in.
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "harvey@example.com"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
code = _outbound_otc_codes("harvey@example.com")[-1]
|
||||||
|
r = client.post("/auth/otc/verify", json={"email": "harvey@example.com", "code": code})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
# The user is now signed in via OTC even though the passcode
|
||||||
|
# path is locked. The /api/auth/me payload reflects this.
|
||||||
|
me = client.get("/api/auth/me").json()
|
||||||
|
assert me["authenticated"] is True
|
||||||
|
assert me["user"]["email"] == "harvey@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Clear + replace
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_passcode_wipes_the_hash(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "ivy@example.com")
|
||||||
|
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||||
|
|
||||||
|
r = client.delete("/auth/passcode")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT passcode_hash, passcode_set_at FROM users WHERE email = ?",
|
||||||
|
("ivy@example.com",),
|
||||||
|
).fetchone()
|
||||||
|
assert row["passcode_hash"] is None
|
||||||
|
assert row["passcode_set_at"] is None
|
||||||
|
|
||||||
|
# Verify against the cleared passcode refuses (no-passcode shape
|
||||||
|
# collapses to a generic 400).
|
||||||
|
client.cookies.clear()
|
||||||
|
r = client.post(
|
||||||
|
"/auth/passcode/verify",
|
||||||
|
json={"email": "ivy@example.com", "passcode": "secret123"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_setting_new_passcode_replaces_old(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "jane@example.com")
|
||||||
|
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||||
|
# Replace.
|
||||||
|
r = client.post("/auth/passcode/set", json={"passcode": "newsecret9"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
# Old passcode refuses.
|
||||||
|
r = client.post(
|
||||||
|
"/auth/passcode/verify",
|
||||||
|
json={"email": "jane@example.com", "passcode": "secret123"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
# New passcode signs in.
|
||||||
|
r = client.post(
|
||||||
|
"/auth/passcode/verify",
|
||||||
|
json={"email": "jane@example.com", "passcode": "newsecret9"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_setting_new_passcode_resets_lockout(app_with_fake_gitea, monkeypatch):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0")
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "kate@example.com")
|
||||||
|
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||||
|
|
||||||
|
# Lock the passcode path with bad attempts (drop session first).
|
||||||
|
client.cookies.clear()
|
||||||
|
for _ in range(5):
|
||||||
|
client.post(
|
||||||
|
"/auth/passcode/verify",
|
||||||
|
json={"email": "kate@example.com", "passcode": "wrongwrong"},
|
||||||
|
)
|
||||||
|
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT passcode_locked_until FROM users WHERE email = ?",
|
||||||
|
("kate@example.com",),
|
||||||
|
).fetchone()
|
||||||
|
assert row["passcode_locked_until"] is not None
|
||||||
|
|
||||||
|
# Sign back in via OTC and reset the passcode.
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "kate@example.com"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
code = _outbound_otc_codes("kate@example.com")[-1]
|
||||||
|
r = client.post("/auth/otc/verify", json={"email": "kate@example.com", "code": code})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
r = client.post("/auth/passcode/set", json={"passcode": "freshcode9"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
# Lockout cleared on set.
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT passcode_locked_until, passcode_failed_attempts FROM users WHERE email = ?",
|
||||||
|
("kate@example.com",),
|
||||||
|
).fetchone()
|
||||||
|
assert row["passcode_locked_until"] is None
|
||||||
|
assert row["passcode_failed_attempts"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_passcode_set_at_updates_on_each_set(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
import time
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "luke@example.com")
|
||||||
|
|
||||||
|
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||||
|
first_stamp = db.conn().execute(
|
||||||
|
"SELECT passcode_set_at FROM users WHERE email = ?",
|
||||||
|
("luke@example.com",),
|
||||||
|
).fetchone()["passcode_set_at"]
|
||||||
|
assert first_stamp is not None
|
||||||
|
|
||||||
|
# SQLite's datetime('now') has second precision; sleep so the
|
||||||
|
# stamp visibly advances on the next set.
|
||||||
|
time.sleep(1.1)
|
||||||
|
|
||||||
|
client.post("/auth/passcode/set", json={"passcode": "newcode99"})
|
||||||
|
second_stamp = db.conn().execute(
|
||||||
|
"SELECT passcode_set_at FROM users WHERE email = ?",
|
||||||
|
("luke@example.com",),
|
||||||
|
).fetchone()["passcode_set_at"]
|
||||||
|
assert second_stamp is not None
|
||||||
|
assert second_stamp >= first_stamp
|
||||||
|
# Lexicographic compare on ISO-8601 datetime strings works for
|
||||||
|
# the SQLite shape.
|
||||||
|
assert second_stamp > first_stamp
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Validation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_passcode_refuses_too_short(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "mia@example.com")
|
||||||
|
r = client.post("/auth/passcode/set", json={"passcode": "abc"})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_passcode_refuses_denylist_pattern(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "nick@example.com")
|
||||||
|
for bad in ["0000", "1234", "aaaa", "qwerty", "password"]:
|
||||||
|
r = client.post("/auth/passcode/set", json={"passcode": bad})
|
||||||
|
assert r.status_code == 422, f"expected 422 for {bad!r}, got {r.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth me payload
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_me_carries_has_passcode_flag(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "olga@example.com")
|
||||||
|
|
||||||
|
me = client.get("/api/auth/me").json()
|
||||||
|
assert me["user"]["has_passcode"] is False
|
||||||
|
assert me["user"]["passcode_set_at"] is None
|
||||||
|
|
||||||
|
client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||||
|
me = client.get("/api/auth/me").json()
|
||||||
|
assert me["user"]["has_passcode"] is True
|
||||||
|
assert me["user"]["passcode_set_at"] is not None
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1889,6 +1889,54 @@
|
|||||||
display: inline-flex; align-items: center; gap: 6px;
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
font-size: 13px; cursor: pointer;
|
font-size: 13px; cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v0.9.0 — admin user-management surface (roadmap item #7). */
|
||||||
|
.admin-filter-chips {
|
||||||
|
display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.admin-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
background: #fff; border: 1px solid #d1d5db; border-radius: 999px;
|
||||||
|
padding: 4px 12px; font-size: 12px; color: #374151; cursor: pointer;
|
||||||
|
}
|
||||||
|
.admin-chip:hover { background: #f9fafb; }
|
||||||
|
.admin-chip.active {
|
||||||
|
background: #111; color: #fff; border-color: #111;
|
||||||
|
}
|
||||||
|
.admin-chip-count {
|
||||||
|
font-size: 11px; opacity: 0.7;
|
||||||
|
}
|
||||||
|
.admin-users-table td { vertical-align: top; padding-top: 10px; padding-bottom: 10px; }
|
||||||
|
.permission-cell { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.permission-actions { display: flex; gap: 6px; }
|
||||||
|
.permission-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
padding: 2px 8px; border-radius: 999px;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
.permission-badge-pending {
|
||||||
|
background: #fef3c7; color: #92400e;
|
||||||
|
}
|
||||||
|
.permission-badge-granted {
|
||||||
|
background: #dcfce7; color: #166534;
|
||||||
|
}
|
||||||
|
.permission-badge-revoked {
|
||||||
|
background: #fee2e2; color: #991b1b;
|
||||||
|
}
|
||||||
|
.permission-decided { font-size: 11px; }
|
||||||
|
.user-row-reason td {
|
||||||
|
background: #fffbeb; border-top: none !important;
|
||||||
|
padding: 0 16px 12px !important;
|
||||||
|
}
|
||||||
|
.user-reason-block {
|
||||||
|
border-left: 3px solid #f59e0b;
|
||||||
|
padding: 8px 12px; font-size: 13px;
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
.user-reason-block strong { display: block; margin-bottom: 4px; color: #92400e; }
|
||||||
|
.user-reason-block p { margin: 0; white-space: pre-wrap; color: #374151; }
|
||||||
.grad-queue { list-style: none; padding: 0; margin: 8px 0 24px; }
|
.grad-queue { list-style: none; padding: 0; margin: 8px 0 24px; }
|
||||||
.grad-queue li { padding: 8px 0; border-bottom: 1px solid #f3f4f6; }
|
.grad-queue li { padding: 8px 0; border-bottom: 1px solid #f3f4f6; }
|
||||||
.grad-queue-link { color: #111; text-decoration: none; font-size: 14px; }
|
.grad-queue-link { color: #111; text-decoration: none; font-size: 14px; }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Landing from './components/Landing.jsx'
|
|||||||
import Login from './components/Login.jsx'
|
import Login from './components/Login.jsx'
|
||||||
import BetaPending from './components/BetaPending.jsx'
|
import BetaPending from './components/BetaPending.jsx'
|
||||||
import Philosophy from './components/Philosophy.jsx'
|
import Philosophy from './components/Philosophy.jsx'
|
||||||
|
import Docs from './components/Docs.jsx'
|
||||||
import NotificationSettings from './components/NotificationSettings.jsx'
|
import NotificationSettings from './components/NotificationSettings.jsx'
|
||||||
import Admin from './components/Admin.jsx'
|
import Admin from './components/Admin.jsx'
|
||||||
import ToastHost, { showToast } from './components/ToastHost.jsx'
|
import ToastHost, { showToast } from './components/ToastHost.jsx'
|
||||||
@@ -111,6 +112,9 @@ export default function App() {
|
|||||||
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
||||||
About
|
About
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/docs" className="header-about" title="User guide">
|
||||||
|
Docs
|
||||||
|
</Link>
|
||||||
{viewer && (
|
{viewer && (
|
||||||
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
|
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
|
||||||
Settings
|
Settings
|
||||||
@@ -153,6 +157,7 @@ export default function App() {
|
|||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
|
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
|
||||||
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
||||||
|
<Route path="/docs" element={<DocsWithSidebar viewer={viewer} />} />
|
||||||
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
|
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
|
||||||
Available to anonymous and authenticated viewers alike. */}
|
Available to anonymous and authenticated viewers alike. */}
|
||||||
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
|
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
|
||||||
@@ -221,6 +226,14 @@ function PhilosophyWithSidebar({ viewer }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DocsWithSidebar({ viewer }) {
|
||||||
|
return (
|
||||||
|
<main className="chrome-pane">
|
||||||
|
<Docs authenticated={!!viewer} />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function NotificationSettingsWithSidebar({ viewer }) {
|
function NotificationSettingsWithSidebar({ viewer }) {
|
||||||
return (
|
return (
|
||||||
<main className="chrome-pane">
|
<main className="chrome-pane">
|
||||||
|
|||||||
@@ -65,6 +65,49 @@ export async function submitBetaRequest({ first_name, last_name, beta_request_re
|
|||||||
return jsonOrThrow(res)
|
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() {
|
export async function listRFCs() {
|
||||||
return jsonOrThrow(await fetch('/api/rfcs'))
|
return jsonOrThrow(await fetch('/api/rfcs'))
|
||||||
}
|
}
|
||||||
@@ -589,6 +632,10 @@ export async function getPhilosophy() {
|
|||||||
return jsonOrThrow(await fetch('/api/philosophy'))
|
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
|
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
|
||||||
// typeahead).
|
// typeahead).
|
||||||
@@ -614,6 +661,19 @@ export async function setUserMute(userId, muted) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.9.0 — roadmap item #7. Flip a user's permission_state between
|
||||||
|
// 'pending', 'granted', and 'revoked'. The Users tab on the admin
|
||||||
|
// page wires Grant / Revoke buttons against this endpoint; the
|
||||||
|
// returned `changed` flag is false when the requested state already
|
||||||
|
// matched the row.
|
||||||
|
export async function setUserPermission(userId, state) {
|
||||||
|
return jsonOrThrow(await fetch(`/api/admin/users/${userId}/permission`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ state }),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export async function listAuditLog({ actionKind, actorUserId, rfcSlug, beforeId, limit } = {}) {
|
export async function listAuditLog({ actionKind, actorUserId, rfcSlug, beforeId, limit } = {}) {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (actionKind) params.set('action_kind', actionKind)
|
if (actionKind) params.set('action_kind', actionKind)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
listAdminUsers,
|
listAdminUsers,
|
||||||
setUserRole,
|
setUserRole,
|
||||||
setUserMute,
|
setUserMute,
|
||||||
|
setUserPermission,
|
||||||
listAuditLog,
|
listAuditLog,
|
||||||
listPermissionEvents,
|
listPermissionEvents,
|
||||||
listGraduationQueue,
|
listGraduationQueue,
|
||||||
@@ -68,12 +69,26 @@ export default function Admin({ viewer }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Users + role + write-mute (§6.1 / §6.2) ────────────────────────────────
|
// ── Users + role + write-mute + permission grant/revoke (§6.1 / §6.2) ──────
|
||||||
|
//
|
||||||
|
// v0.9.0 (roadmap item #7) lands the user-management surface. The table
|
||||||
|
// shows every user with their permission_state, sign-up reason (when
|
||||||
|
// pending), role, write-mute, and Grant / Revoke controls. State filter
|
||||||
|
// chips above the table narrow to one bucket — the "Pending" chip is the
|
||||||
|
// admin's daily inbox shape.
|
||||||
|
|
||||||
|
const STATE_CHIPS = [
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'pending', label: 'Pending' },
|
||||||
|
{ value: 'granted', label: 'Granted' },
|
||||||
|
{ value: 'revoked', label: 'Revoked' },
|
||||||
|
]
|
||||||
|
|
||||||
function UsersTab() {
|
function UsersTab() {
|
||||||
const [users, setUsers] = useState(null)
|
const [users, setUsers] = useState(null)
|
||||||
const [busy, setBusy] = useState({})
|
const [busy, setBusy] = useState({})
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
const [stateFilter, setStateFilter] = useState('all')
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -113,42 +128,122 @@ function UsersTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function flipPermission(userId, state) {
|
||||||
|
setBusy(b => ({ ...b, [userId]: true }))
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await setUserPermission(userId, state)
|
||||||
|
// Refresh the full row so permission_decided_{at,by_*} update too.
|
||||||
|
await refresh()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setBusy(b => ({ ...b, [userId]: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const c = { all: 0, pending: 0, granted: 0, revoked: 0 }
|
||||||
|
if (users) {
|
||||||
|
c.all = users.length
|
||||||
|
for (const u of users) {
|
||||||
|
const s = u.permission_state || 'granted'
|
||||||
|
if (s in c) c[s] += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}, [users])
|
||||||
|
|
||||||
if (users == null) return <p className="muted">Loading users…</p>
|
if (users == null) return <p className="muted">Loading users…</p>
|
||||||
|
|
||||||
|
const filtered = stateFilter === 'all'
|
||||||
|
? users
|
||||||
|
: users.filter(u => (u.permission_state || 'granted') === stateFilter)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-tab">
|
<div className="admin-tab">
|
||||||
<header className="admin-tab-header">
|
<header className="admin-tab-header">
|
||||||
<h2>Users</h2>
|
<h2>Users</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Role changes write to <code>permission_events</code>. The §6.2
|
The pending bucket is the beta-access review queue (§6.1 /
|
||||||
write-mute applies to contributors only — promote to admin to
|
v0.8.0). Grant or revoke writes to <code>permission_events</code>
|
||||||
remove a user's ability to write without silencing them.
|
and stamps <code>permission_decided_by</code> +{' '}
|
||||||
|
<code>permission_decided_at</code>. Role and write-mute controls
|
||||||
|
retain their v0.7.0 semantics — promote to admin to remove a
|
||||||
|
user's ability to write without silencing them.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
{error && <p className="settings-note warning">{error}</p>}
|
{error && <p className="settings-note warning">{error}</p>}
|
||||||
<table className="admin-table">
|
|
||||||
|
<div className="admin-filter-chips">
|
||||||
|
{STATE_CHIPS.map(chip => (
|
||||||
|
<button
|
||||||
|
key={chip.value}
|
||||||
|
type="button"
|
||||||
|
className={`admin-chip${stateFilter === chip.value ? ' active' : ''}`}
|
||||||
|
onClick={() => setStateFilter(chip.value)}
|
||||||
|
>
|
||||||
|
{chip.label} <span className="admin-chip-count">{counts[chip.value] ?? 0}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="muted">No users in this bucket.</p>
|
||||||
|
) : (
|
||||||
|
<table className="admin-table admin-users-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>User</th>
|
<th>User</th>
|
||||||
|
<th>State</th>
|
||||||
<th>Role</th>
|
<th>Role</th>
|
||||||
<th>Write-muted</th>
|
<th>Write-muted</th>
|
||||||
|
<th>Signed up</th>
|
||||||
<th>Last seen</th>
|
<th>Last seen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map(u => (
|
{filtered.map(u => (
|
||||||
<tr key={u.id}>
|
<UserRow
|
||||||
|
key={u.id}
|
||||||
|
user={u}
|
||||||
|
busy={!!busy[u.id]}
|
||||||
|
onChangeRole={role => changeRole(u.id, role)}
|
||||||
|
onToggleMute={muted => toggleMute(u.id, muted)}
|
||||||
|
onFlipPermission={state => flipPermission(u.id, state)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }) {
|
||||||
|
const state = u.permission_state || 'granted'
|
||||||
|
const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
|
||||||
|
const handle = u.gitea_login ? `@${u.gitea_login}` : (u.email || u.display_name)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div className="user-cell">
|
<div className="user-cell">
|
||||||
<span className="user-handle">@{u.gitea_login}</span>
|
<span className="user-handle">{handle}</span>
|
||||||
<span className="muted">{u.display_name}</span>
|
<span className="muted">
|
||||||
|
{fullName || u.display_name}
|
||||||
|
{u.email ? ` · ${u.email}` : ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<PermissionCell user={u} busy={busy} onFlipPermission={onFlipPermission} />
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<select
|
<select
|
||||||
value={u.role}
|
value={u.role}
|
||||||
onChange={e => changeRole(u.id, e.target.value)}
|
onChange={e => onChangeRole(e.target.value)}
|
||||||
disabled={!!busy[u.id]}
|
disabled={busy}
|
||||||
>
|
>
|
||||||
<option value="contributor">Contributor</option>
|
<option value="contributor">Contributor</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
@@ -161,8 +256,8 @@ function UsersTab() {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!!u.muted}
|
checked={!!u.muted}
|
||||||
onChange={e => toggleMute(u.id, e.target.checked)}
|
onChange={e => onToggleMute(e.target.checked)}
|
||||||
disabled={!!busy[u.id]}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
{u.muted ? 'Muted' : 'Active'}
|
{u.muted ? 'Muted' : 'Active'}
|
||||||
</label>
|
</label>
|
||||||
@@ -170,11 +265,56 @@ function UsersTab() {
|
|||||||
<span className="muted">N/A</span>
|
<span className="muted">N/A</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="muted">{u.last_seen_at}</td>
|
<td className="muted">{u.created_at || '—'}</td>
|
||||||
|
<td className="muted">{u.last_seen_at || '—'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
{state === 'pending' && u.beta_request_reason ? (
|
||||||
</tbody>
|
<tr className="user-row-reason">
|
||||||
</table>
|
<td colSpan={6}>
|
||||||
|
<div className="user-reason-block">
|
||||||
|
<strong>Why they want access:</strong>
|
||||||
|
<p>{u.beta_request_reason}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionCell({ user: u, busy, onFlipPermission }) {
|
||||||
|
const state = u.permission_state || 'granted'
|
||||||
|
const decidedSuffix = u.permission_decided_at
|
||||||
|
? ` · by ${u.permission_decided_by_login ? '@' + u.permission_decided_by_login : '—'} at ${u.permission_decided_at}`
|
||||||
|
: ''
|
||||||
|
return (
|
||||||
|
<div className="permission-cell">
|
||||||
|
<span className={`permission-badge permission-badge-${state}`}>{state}</span>
|
||||||
|
<div className="permission-actions">
|
||||||
|
{state !== 'granted' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-link-quiet"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onFlipPermission('granted')}
|
||||||
|
>Grant</button>
|
||||||
|
)}
|
||||||
|
{state === 'granted' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-link-quiet"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Revoke access for ${u.display_name || u.email}?`)) {
|
||||||
|
onFlipPermission('revoked')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>Revoke</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{decidedSuffix && (
|
||||||
|
<div className="permission-decided muted">{decidedSuffix.replace(/^ · /, '')}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,11 @@ export default function BetaPending({ viewer }) {
|
|||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
Thanks for telling us a bit about yourself. An admin will
|
Thanks for telling us a bit about yourself. The deployment's
|
||||||
review your request and get back to you as soon as we can.
|
admins are notified by email as soon as a request lands;
|
||||||
|
we don't commit to a fixed SLA — turnaround depends on
|
||||||
|
operator availability — and the deployment operator is
|
||||||
|
the right person to ask if a wait runs long.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
While you wait, the catalog on the left lists every super-draft
|
While you wait, the catalog on the left lists every super-draft
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// `/docs` — the user-facing guide.
|
||||||
|
//
|
||||||
|
// Sibling of Philosophy.jsx: same chrome, same data path, different
|
||||||
|
// source file. Renders DOCS.md verbatim with light chrome around it.
|
||||||
|
// Reachable anonymously, same as `/philosophy`, so a visitor can read
|
||||||
|
// the guide before deciding to sign in.
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||||
|
import { getDocs } from '../api.js'
|
||||||
|
|
||||||
|
export default function Docs({ authenticated }) {
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
getDocs()
|
||||||
|
.then(r => { if (active) setBody(r.body || '') })
|
||||||
|
.catch(e => { if (active) setError(e.message || String(e)) })
|
||||||
|
.finally(() => { if (active) setLoading(false) })
|
||||||
|
return () => { active = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="philosophy-page">
|
||||||
|
<header className="philosophy-header">
|
||||||
|
<button
|
||||||
|
className="philosophy-back"
|
||||||
|
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<span className="philosophy-title">User guide</span>
|
||||||
|
{!authenticated && (
|
||||||
|
<Link className="philosophy-signin" to="/">Home</Link>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<article className="philosophy-body">
|
||||||
|
{loading && <p className="muted">Loading…</p>}
|
||||||
|
{error && <p className="error">Could not load the guide: {error}</p>}
|
||||||
|
{!loading && !error && (
|
||||||
|
<MarkdownPreview content={body} />
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,43 +1,90 @@
|
|||||||
// Login.jsx — v0.7.0's primary sign-in surface (§6.2), extended by
|
// Login.jsx — the composed sign-in surface (§6.2) after the v0.10.0
|
||||||
// v0.8.0 (§6.1 / §14.1, roadmap item #6) with the first-OTC profile
|
// (passcodes, roadmap item #8) rebase onto v0.8.0 (beta-access-request
|
||||||
// capture step.
|
// capture, §6.1 / §14.1, roadmap item #6). v0.7.0 (roadmap item #5)
|
||||||
|
// established the email + OTC scaffolding both releases extended.
|
||||||
//
|
//
|
||||||
// Three-step (the third is conditional):
|
// Four-to-six-step flow (most users see three; the longest path is
|
||||||
// 1. Enter email → POST /auth/otc/request → on 200, advance.
|
// pending-user with no passcode, who never sees the passcode steps):
|
||||||
// On 429 (rate-limit), surface a "wait a moment" hint and keep
|
|
||||||
// the user on step 1.
|
|
||||||
// 2. Enter the six-digit code from the email → POST /auth/otc/verify
|
|
||||||
// → on 200, the response body carries `needs_profile`:
|
|
||||||
// * needs_profile=false (returning user, OAuth-era grandfather,
|
|
||||||
// or already-captured pending user): redirect to "/".
|
|
||||||
// * needs_profile=true (fresh OTC sign-in, no profile fields
|
|
||||||
// yet): advance to step 3.
|
|
||||||
// Cmd/Ctrl+Enter on the code field is the keyboard shortcut.
|
|
||||||
// 3. First name, last name, and "why I should be included in the
|
|
||||||
// beta" → POST /api/auth/me/beta-request → redirect to
|
|
||||||
// /beta-pending. The user's row stays `permission_state='pending'`
|
|
||||||
// until an admin grants access.
|
|
||||||
//
|
//
|
||||||
// Server-side, /auth/otc/request returns 202 uniformly so abuse paths
|
// 1. 'email' Enter email → GET /auth/passcode/check.
|
||||||
// (e.g. distributed allowlist-probing) don't leak the recognized-email
|
// * has_passcode=true → step 'passcode'.
|
||||||
// set. This surface never distinguishes "we couldn't reach you" from
|
// * has_passcode=false → POST /auth/otc/request,
|
||||||
// "we don't know you" — it just advances to step 2. If a request was
|
// step 'code'.
|
||||||
// rate-limited, the user sees a 429 hint and stays on step 1.
|
// 429 on either dispatch surfaces a "wait a
|
||||||
|
// moment" hint and keeps the user on step 1.
|
||||||
//
|
//
|
||||||
// The legacy Gitea OAuth callback remains at /auth/login → /auth/callback
|
// 2a. 'passcode' Enter passcode → POST /auth/passcode/verify.
|
||||||
// during the migration; we surface a "Sign in with Gitea" link as a
|
// * 200 → redirect to "/".
|
||||||
// fallback in the footer so users with active OAuth sessions or older
|
// * 423 (lockout, 5 consecutive failures) →
|
||||||
// invite emails still have a path.
|
// 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.
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
import { requestOtc, verifyOtc, submitBetaRequest } from '../api'
|
import {
|
||||||
|
requestOtc,
|
||||||
|
verifyOtc,
|
||||||
|
submitBetaRequest,
|
||||||
|
checkPasscode,
|
||||||
|
verifyPasscode,
|
||||||
|
setPasscode as apiSetPasscode,
|
||||||
|
} from '../api'
|
||||||
|
|
||||||
export default function Login() {
|
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 [step, setStep] = useState('email')
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [code, setCode] = useState('')
|
const [code, setCode] = useState('')
|
||||||
// v0.8.0 — step 3 capture fields.
|
const [passcode, setPasscode] = useState('')
|
||||||
|
const [newPasscode, setNewPasscode] = useState('')
|
||||||
|
// v0.8.0 — capture-profile fields.
|
||||||
const [firstName, setFirstName] = useState('')
|
const [firstName, setFirstName] = useState('')
|
||||||
const [lastName, setLastName] = useState('')
|
const [lastName, setLastName] = useState('')
|
||||||
const [reason, setReason] = useState('')
|
const [reason, setReason] = useState('')
|
||||||
@@ -45,13 +92,17 @@ export default function Login() {
|
|||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const emailRef = useRef(null)
|
const emailRef = useRef(null)
|
||||||
const codeRef = useRef(null)
|
const codeRef = useRef(null)
|
||||||
|
const passcodeRef = useRef(null)
|
||||||
|
const newPasscodeRef = useRef(null)
|
||||||
const firstNameRef = useRef(null)
|
const firstNameRef = useRef(null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step === 'email') emailRef.current?.focus()
|
if (step === 'email') emailRef.current?.focus()
|
||||||
else if (step === 'code') codeRef.current?.focus()
|
else if (step === 'code') codeRef.current?.focus()
|
||||||
else if (step === 'profile') firstNameRef.current?.focus()
|
else if (step === 'passcode') passcodeRef.current?.focus()
|
||||||
|
else if (step === 'capture-profile') firstNameRef.current?.focus()
|
||||||
|
else if (step === 'set-passcode') newPasscodeRef.current?.focus()
|
||||||
}, [step])
|
}, [step])
|
||||||
|
|
||||||
async function submitEmail(e) {
|
async function submitEmail(e) {
|
||||||
@@ -63,20 +114,72 @@ export default function Login() {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
setStatus('')
|
setStatus('')
|
||||||
try {
|
try {
|
||||||
|
const { has_passcode } = await checkPasscode(email.trim())
|
||||||
|
if (has_passcode) {
|
||||||
|
setStep('passcode')
|
||||||
|
setStatus('')
|
||||||
|
} else {
|
||||||
await requestOtc(email.trim())
|
await requestOtc(email.trim())
|
||||||
setStep('code')
|
setStep('code')
|
||||||
setStatus('Check your inbox — a six-digit code is on the way.')
|
setStatus('Check your inbox — a six-digit code is on the way.')
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 429) {
|
if (err.status === 429) {
|
||||||
setStatus('Slow down — wait a minute before requesting another code.')
|
setStatus('Slow down — wait a minute before requesting another code.')
|
||||||
} else {
|
} else {
|
||||||
setStatus(err.message || 'Could not request a code. Try again.')
|
setStatus(err.message || 'Could not start sign-in. Try again.')
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
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 {
|
||||||
|
await requestOtc(email.trim())
|
||||||
|
setStep('code')
|
||||||
|
setStatus(
|
||||||
|
'Too many failed attempts. We sent a one-time code to your email — use it to sign in.',
|
||||||
|
)
|
||||||
|
} catch (e2) {
|
||||||
|
if (e2.status === 429) {
|
||||||
|
setStep('code')
|
||||||
|
setStatus(
|
||||||
|
'Too many failed attempts. Wait a minute, then request a one-time code to sign in.',
|
||||||
|
)
|
||||||
|
} 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) {
|
async function submitCode(e) {
|
||||||
if (e) e.preventDefault()
|
if (e) e.preventDefault()
|
||||||
if (!code.trim() || code.trim().length !== 6) {
|
if (!code.trim() || code.trim().length !== 6) {
|
||||||
@@ -86,22 +189,39 @@ export default function Login() {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
setStatus('')
|
setStatus('')
|
||||||
try {
|
try {
|
||||||
const result = await verifyOtc(email.trim(), code.trim())
|
await verifyOtc(email.trim(), code.trim())
|
||||||
// v0.8.0 — a fresh OTC user lands in `permission_state='pending'`
|
// OTC verified — the server has signed in the user. Fetch the
|
||||||
// with no profile fields. The verify response now carries a
|
// canonical /api/auth/me to decide where to land:
|
||||||
// `needs_profile` flag the server stamped from the row state;
|
// * needs_profile → §6.1 capture (then /beta-pending).
|
||||||
// surface the capture form here instead of jumping straight to
|
// * no passcode → §6.2 offer-passcode (then /).
|
||||||
// "/". The fallback path (no flag, e.g. an older backend
|
// * otherwise → /.
|
||||||
// before the migration ran) jumps to "/" as before.
|
// needs_profile wins over has_passcode: a pending user can't yet
|
||||||
if (result?.needs_profile) {
|
// do anything that benefits from faster sign-in, so we don't
|
||||||
setStep('profile')
|
// 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('')
|
setStatus('')
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Reload so App.jsx's getMe() picks up the fresh session. We
|
if (me?.has_passcode === false) {
|
||||||
// navigate to "/" via a hard load so any cached "anonymous"
|
setStep('offer-passcode')
|
||||||
// view state in memory is dropped cleanly.
|
setStatus('')
|
||||||
|
setBusy(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Either /me returned the granted-with-passcode shape, or the
|
||||||
|
// call failed but the session cookie is set — fall through to
|
||||||
|
// a hard reload so App.jsx re-fetches and renders accordingly.
|
||||||
window.location.assign('/')
|
window.location.assign('/')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('That code is invalid or expired. Try again, or request a new code.')
|
setStatus('That code is invalid or expired. Try again, or request a new code.')
|
||||||
@@ -126,10 +246,10 @@ export default function Login() {
|
|||||||
last_name: ln,
|
last_name: ln,
|
||||||
beta_request_reason: why,
|
beta_request_reason: why,
|
||||||
})
|
})
|
||||||
// The user is still `permission_state='pending'`; bounce them
|
// Hard-load so App.jsx re-fetches /api/auth/me and picks up
|
||||||
// to /beta-pending so the next thing they see is the
|
// the captured fields. The user stays permission_state='pending'
|
||||||
// "your request is in review" page. Hard-load so App.jsx
|
// until an admin grants access — the next thing they should
|
||||||
// re-fetches /api/auth/me and picks up the captured fields.
|
// see is the "your request is in review" page.
|
||||||
window.location.assign('/beta-pending')
|
window.location.assign('/beta-pending')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(err.message || 'Could not submit your request. Try again.')
|
setStatus(err.message || 'Could not submit your request. Try again.')
|
||||||
@@ -137,6 +257,26 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitNewPasscode(e) {
|
||||||
|
if (e) e.preventDefault()
|
||||||
|
const pc = newPasscode.trim()
|
||||||
|
if (pc.length < 4) {
|
||||||
|
setStatus('Passcode must be at least 4 characters.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setStatus('')
|
||||||
|
try {
|
||||||
|
await apiSetPasscode(pc)
|
||||||
|
window.location.assign('/')
|
||||||
|
} catch (err) {
|
||||||
|
// 422 carries the validation message verbatim (denylist /
|
||||||
|
// length); surface it as-is so the user knows what to change.
|
||||||
|
setStatus(err.message || 'Could not set passcode. Try a different one.')
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onCodeKey(e) {
|
function onCodeKey(e) {
|
||||||
// §6.2 ergonomic: Cmd/Ctrl+Enter submits from the code field.
|
// §6.2 ergonomic: Cmd/Ctrl+Enter submits from the code field.
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||||
@@ -144,12 +284,59 @@ 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() {
|
function backToEmail() {
|
||||||
setStep('email')
|
setStep('email')
|
||||||
setCode('')
|
setCode('')
|
||||||
|
setPasscode('')
|
||||||
setStatus('')
|
setStatus('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fallbackToOtc() {
|
||||||
|
// Manual "Use a code instead" from the passcode step. Same shape
|
||||||
|
// as the email-step OTC dispatch.
|
||||||
|
setBusy(true)
|
||||||
|
setStatus('')
|
||||||
|
try {
|
||||||
|
await requestOtc(email.trim())
|
||||||
|
setPasscode('')
|
||||||
|
setStep('code')
|
||||||
|
setStatus('Check your inbox — a six-digit code is on the way.')
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 429) {
|
||||||
|
setStatus('Slow down — wait a minute before requesting another code.')
|
||||||
|
} else {
|
||||||
|
setStatus(err.message || 'Could not request a code. Try again.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipPasscodeOffer() {
|
||||||
|
window.location.assign('/')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="otc-login">
|
<div className="otc-login">
|
||||||
<div className="otc-login-inner">
|
<div className="otc-login-inner">
|
||||||
@@ -157,7 +344,8 @@ export default function Login() {
|
|||||||
{step === 'email' && (
|
{step === 'email' && (
|
||||||
<form onSubmit={submitEmail}>
|
<form onSubmit={submitEmail}>
|
||||||
<p className="otc-hint">
|
<p className="otc-hint">
|
||||||
Enter your email. We'll send you a one-time code.
|
Enter your email. If you've set a passcode, you'll enter that
|
||||||
|
next; otherwise we'll send a one-time code.
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
ref={emailRef}
|
ref={emailRef}
|
||||||
@@ -170,10 +358,49 @@ export default function Login() {
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
<button type="submit" disabled={busy || !email.trim()}>
|
<button type="submit" disabled={busy || !email.trim()}>
|
||||||
{busy ? 'Sending…' : 'Send code'}
|
{busy ? 'Checking…' : 'Continue'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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}
|
||||||
|
/>
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
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' && (
|
{step === 'code' && (
|
||||||
<form onSubmit={submitCode}>
|
<form onSubmit={submitCode}>
|
||||||
<p className="otc-hint">
|
<p className="otc-hint">
|
||||||
@@ -211,7 +438,7 @@ export default function Login() {
|
|||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
{step === 'profile' && (
|
{step === 'capture-profile' && (
|
||||||
<form onSubmit={submitProfile}>
|
<form onSubmit={submitProfile}>
|
||||||
<p className="otc-hint">
|
<p className="otc-hint">
|
||||||
You're signed in. {import.meta.env.VITE_APP_NAME} is in private
|
You're signed in. {import.meta.env.VITE_APP_NAME} is in private
|
||||||
@@ -245,6 +472,7 @@ export default function Login() {
|
|||||||
<textarea
|
<textarea
|
||||||
value={reason}
|
value={reason}
|
||||||
onChange={e => setReason(e.target.value)}
|
onChange={e => setReason(e.target.value)}
|
||||||
|
onKeyDown={onReasonKey}
|
||||||
required
|
required
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
rows={5}
|
rows={5}
|
||||||
@@ -259,10 +487,71 @@ export default function Login() {
|
|||||||
{busy ? 'Submitting…' : 'Submit request'}
|
{busy ? 'Submitting…' : 'Submit request'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
)}
|
)}
|
||||||
{status && <p className="otc-status">{status}</p>}
|
{status && <p className="otc-status">{status}</p>}
|
||||||
{step !== 'profile' && (
|
{step !== 'capture-profile' && (
|
||||||
<p className="otc-fallback">
|
<p className="otc-fallback">
|
||||||
<Link to="/philosophy">Read the philosophy →</Link>
|
<Link to="/philosophy">Read the philosophy →</Link>
|
||||||
<span className="otc-fallback-sep">·</span>
|
<span className="otc-fallback-sep">·</span>
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ import {
|
|||||||
muteUser,
|
muteUser,
|
||||||
searchUsers,
|
searchUsers,
|
||||||
getCookieConsent,
|
getCookieConsent,
|
||||||
|
getMe,
|
||||||
|
setPasscode,
|
||||||
|
clearPasscode,
|
||||||
} from '../api.js'
|
} from '../api.js'
|
||||||
import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js'
|
import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js'
|
||||||
|
|
||||||
@@ -50,11 +53,167 @@ export default function NotificationSettings({ viewer }) {
|
|||||||
<QuietHoursSection />
|
<QuietHoursSection />
|
||||||
<WatchesSection />
|
<WatchesSection />
|
||||||
<MutesSection viewer={viewer} />
|
<MutesSection viewer={viewer} />
|
||||||
|
<SignInSection />
|
||||||
<PrivacyCookiesSection />
|
<PrivacyCookiesSection />
|
||||||
</div>
|
</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) ────────────
|
// ── §14.5 cookie / privacy consent (v0.13.0 / roadmap item #11) ────────────
|
||||||
|
|
||||||
function PrivacyCookiesSection() {
|
function PrivacyCookiesSection() {
|
||||||
|
|||||||
Reference in New Issue
Block a user