Compare commits

..

4 Commits

Author SHA1 Message Date
Ben Stull ca8ba69acb Release 0.8.0: open beta-access request flow (first/last/why)
Replaces the v0.3.0 / v0.7.0 allowed_emails admission gate with an
admin-grant flow (roadmap item #6, SPEC §6.1 / §6.2 / §14.1 / §17).
Any valid email can sign in via OTC; a fresh user lands in
permission_state='pending' with a captured first/last/why profile,
and an admin grant flips them to 'granted' before write endpoints
accept them. Grandfathered users pass through the migration with
the column default 'granted' so existing contributors are unaffected.
The allowed_emails table stays in the schema as a fast-path bypass
pending v0.9.0's admin user-management page (item #7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:25:59 -07:00
Ben Stull 8aa65014b4 Release 0.13.0: cookie/privacy consent banner + policy pages (instrumentation prep)
Roadmap item #11. Ships the non-modal bottom-of-page cookie consent
banner, the default /privacy and /cookies policy pages, the
`cookie_consent` table + two §17 endpoints for server-side persistence,
the localStorage fallback for anonymous viewers, the /settings
"Privacy & cookies" tab for revisiting the choice, and the
`frontend/src/lib/consent.js` helper that roadmap item #13's analytics
SDK (v0.15.0) will gate against. No analytics SDK ships in this release
— the consent infrastructure goes in first so the gate is already in
place. Adds SPEC §14.5 / §14.6, lists two new endpoints in §17, names
the new table in §5, and surfaces four §19.2 candidates (content-repo
file vs env-var policy, GPC / DNT headers, i18n, item-#13 dependency).
Two new optional env vars (`VITE_PRIVACY_POLICY_URL`,
`VITE_COOKIES_POLICY_URL`) — defaults render the framework's stub
pages.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:03:18 -07:00
Ben Stull 21743a08b1 Release 0.6.0: anonymous-write gates audit + hardening
Sweep-the-edges hardening release (roadmap item #4). Audits every
write-shaped backend endpoint to confirm each one enforces an explicit
auth.require_contributor (or stricter) gate before doing state-changing
work; adds a regression test net (test_anon_offlimits_vertical.py, 12
tests, 66 assertions) so future endpoints can't quietly ship without a
gate. The only behaviour change: GET /api/rfcs/<slug>/graduate/progress
now requires auth.require_user since the step detail (repo name, PR
number, rollback steps) isn't part of the v0.3.0 anonymous-read contract.
No schema, no env, no dependency changes; operator action is rebuild +
restart per CHANGELOG §0.6.0 upgrade steps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:47:49 -07:00
25 changed files with 3305 additions and 52 deletions
+426 -2
View File
@@ -23,6 +23,192 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond
that.
## 0.8.0 — 2026-05-28
**Minor — schema migration required; admission semantics shift.**
This release replaces the v0.3.0 / v0.7.0 `allowed_emails` admission
gate with an admin-grant flow (roadmap item #6, SPEC §6.1 / §6.2 /
§14.1 / §17). Anyone with a valid email can sign in via the v0.7.0
OTC flow; the OTC request endpoint no longer consults the
allowlist. A fresh user lands in `permission_state='pending'` until
an admin grants access. The first-OTC sign-in captures first name,
last name, and a free-text "why I should be included in the beta"
via a new `POST /api/auth/me/beta-request` endpoint; the captured
fields populate the same `users` row alongside the OAuth-era
columns.
A pending user has the same read access an anonymous viewer has —
the catalog, RFC bodies, the philosophy page, and every public
conversation are reachable. Every write-shaped endpoint
(`auth.require_contributor` floor) refuses pending users with 403.
The frontend renders a thin "Your beta access request is in
review" banner on every page and re-purposes the v0.3.0
`/beta-pending` page as the post-capture landing surface.
Grandfathered behavior: every `users` row at migration time
carries `permission_state='granted'` via the column default, so
existing contributors are unaffected. The OAuth fallback at
`/auth/callback` still consults the v0.3.0 allowlist (legacy
path); the OTC flow does not.
The `allowed_emails` table stays in the schema as a fast-path
bypass — the v0.3.0 admin UI continues to manage it, but the OTC
request handler no longer reads it. v0.9.0 (roadmap item #7) is
expected to ship the admin user-management page that replaces the
allowlist surface entirely; until then, admin grants are done by
direct DB `UPDATE`.
### Upgrade steps (from 0.13.0)
1. **MUST** restart the backend so migration `014_beta_access.sql`
runs. The migration adds `permission_state` (default `'granted'`,
so existing rows pass through unaffected), `first_name`,
`last_name`, `beta_request_reason`, `permission_decided_by`,
and `permission_decided_at` to the `users` table, plus an index
on `permission_state` for the pending queue. The migration is
ALTER-TABLE-based (no table rebuild) — every foreign key and
existing row passes through untouched.
2. **MUST** rebuild the frontend. The `Login.jsx` surface now
runs a conditional third step (the capture form) on fresh OTC
sign-ins; `BetaPending.jsx` carries the new "your request is
in review" copy; `App.jsx` renders a thin pending-access
banner. The build embeds the new `/api/auth/me/beta-request`
client call.
3. **SHOULD** announce the new admission flow to existing users.
Wording suggestion: "We've replaced our email-allowlist gate
with an admin-review flow. Existing users are unaffected;
new visitors sign in with their email, tell us a bit about
themselves, and an admin reviews their request before
discussion and contribution unlock." Existing sessions
remain valid.
4. **SHOULD** plan the admin grant mechanism. v0.8.0 does not
ship a UI for the grant — v0.9.0 (roadmap item #7) will. For
the v0.8.0 window, an admin grants access via direct DB
gesture:
```sql
UPDATE users
SET permission_state = 'granted',
permission_decided_by = <admin_user_id>,
permission_decided_at = datetime('now')
WHERE email = '<approved>';
```
The pending queue lives in `SELECT * FROM users WHERE
permission_state = 'pending' ORDER BY created_at`.
5. **MUST** decide whether to drain the `allowed_emails` table.
The OTC request handler no longer consults it; populated
rows are inert at the request surface. Three operator
choices, all valid:
* **Leave as-is** (the framework's default behavior — the
v0.3.0 admin UI continues to work, the rows stay as a
fast-path bypass record). Recommended if you anticipate
v0.9.0's user-management page folding the allowlist UI
into its surface.
* **Drain via the existing admin UI** (`/admin/allowlist`)
— one row at a time, no data loss elsewhere.
* **Bulk-drain via DB** — `DELETE FROM allowed_emails;`
drops every row; the table stays.
6. **MAY** announce write access individually to grandfathered
users you want to keep at `'granted'`. The default-`'granted'`
migration means no action is required for them; this step
exists only if you want to send a "you're still in" message.
### Added
- **`backend/migrations/014_beta_access.sql`** — adds
`permission_state` (CHECK in `('pending', 'granted', 'revoked')`,
default `'granted'`), `first_name`, `last_name`,
`beta_request_reason`, `permission_decided_by` (FK to users,
ON DELETE SET NULL), `permission_decided_at` to the `users`
table. Plus `idx_users_permission_state` for the pending
queue.
- **`POST /api/auth/me/beta-request`** — body
`{first_name, last_name, beta_request_reason}` (all required;
bounds 120 / 120 / 4000). Writes the fields to the signed-in
user's row and leaves `permission_state='pending'`. Refuses
HTTP 409 for already-granted / revoked users; refuses HTTP
401 for anonymous callers.
- **`needs_profile` flag** on the `/auth/otc/verify` response.
`true` iff the user is `permission_state='pending'` AND
carries no profile fields yet (a fresh OTC sign-in). The
Login.jsx surface uses the flag to gate the capture step.
- **`permission_state` field** on the `/api/auth/me` response,
plus `first_name`, `last_name`, `beta_request_reason`, and
the same `needs_profile` flag.
- **First-OTC profile capture step** in `Login.jsx`. Third
step in the sign-in surface, gated by the verify response's
`needs_profile` flag.
- **`/beta-pending` repurpose** in `BetaPending.jsx`. The
page now reads as "your request is in review" when the
viewer is pending; the v0.3.0 "private beta" framing
remains as the anonymous-viewer fallback.
- **Thin pending-access banner** at the top of every page
for `permission_state='pending'` viewers (other than
`/beta-pending` itself).
- **SPEC `§6` opening / `§6.1` / `§6.2` / `§14.1` / `§17` /
`§19.2`** corrections per §19.3 rule-2 — the admission
shift, the orthogonality of permission_state vs role / muted
/ notification-mutes, the new endpoints, and the
newly-surfaced §19.2 candidates (admin user-management page,
allowlist deprecation, admin notification on new request).
- **`backend/tests/test_beta_access_vertical.py`** — 9 new
tests covering: a fresh OTC user lands pending with empty
profile; the capture endpoint populates the fields and
keeps state pending; the capture endpoint refuses
anonymous / granted / revoked callers; a pending user is
refused write endpoints; an admin grant promotes pending →
granted; a grandfathered user is unaffected by the
migration; the OTC request endpoint accepts any email
regardless of allowlist state; the `allowed_emails` table
is still present in the schema.
### Changed
- **`backend/app/auth.py#require_contributor`** widens its
gate to refuse `permission_state != 'granted'` with HTTP
403. The §6.1 contributor capabilities (propose, branch,
PR, chat, claim) all funnel through this dependency, so
the widening covers them transitively. `SessionUser` now
carries `permission_state` (default `'granted'` for the
dataclass-default fallback path).
- **`backend/app/otc.py#request_code`** drops the allowlist
check from the OTC request flow. The `RequestOutcome`
shape loses the `'allowlist'` reason (replaced by
`'sent'` / `'cooldown'` / `'invalid'`).
- **`backend/app/otc.py#provision_or_link_user`** sets
`permission_state='pending'` explicitly on a fresh row.
Grandfathered (link-by-email) users pass through with
their existing column value.
- **`backend/app/auth.py#provision_user`** (OAuth fallback)
now sets `permission_state='granted'` explicitly on a
fresh row. The OAuth callback still consults the
`is_allowed_sign_in` allowlist check (the legacy fallback
path retains its v0.3.0 admission shape during the OAuth
migration window).
- **`backend/tests/test_otc_vertical.py`** — the
`test_otc_request_silently_drops_when_email_not_on_allowlist`
test (asserted the v0.7.0 allowlist gate) is replaced by
`test_otc_request_admits_emails_regardless_of_allowlist_population`
which asserts the v0.8.0 open-request contract. The
on-list test stays as a regression net for the
rate-limit / outbound-buffer plumbing.
- **`SPEC.md`** §6 opening, §6.1, §6.2, §14.1, §17, §19.2
per §19.3 rule-2.
- **`VERSION`** → `0.8.0`. `frontend/package.json#version` and
the lockfile mirror.
### Deferred to later releases
- **Admin user-management page** at `/admin/users` (item #7,
v0.9.0) — replaces the manual DB `UPDATE` gesture.
- **Allowlist UI deprecation** (also v0.9.0) — once the
admin user-management page lands, the `/admin/allowlist`
surface and the `allowed_emails` table both retire.
- **Admin email notification on new beta request** (item #7
again, v0.9.0).
- **Revoke gesture in the UI** — the `permission_state='revoked'`
state is wired in the schema and the auth gate; v0.9.0 ships
the admin UI that flips the column.
## 0.13.0 — 2026-05-28
**Minor — schema migration required; new optional env vars.** This
@@ -65,9 +251,12 @@ consent infrastructure is wired so item #13 (v0.15.0) can read from
- `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** `012_cookie_consent.sql` — new
- **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
@@ -102,7 +291,7 @@ consent infrastructure is wired so item #13 (v0.15.0) can read from
- 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 `012_cookie_consent.sql`. The
- 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
@@ -121,6 +310,241 @@ consent infrastructure is wired so item #13 (v0.15.0) can read from
(because their `cookie_consent` row does not yet exist); their
current sessions remain valid.
## 0.7.0 — 2026-05-28
**Minor — schema migration required; new auth path is additive.**
This release lands email + one-time-code sign-in (roadmap item #5,
SPEC §6.2) as the primary human-auth gesture. Users sign in by
typing their email, receiving a six-digit code via email, and
entering it. The Gitea OAuth callback (`/auth/callback`) remains
functional during migration — the new UI no longer points at it
primarily, but a "Sign in with Gitea (fallback)" link survives on
the new login surface so users with active OAuth sessions or older
invite paths still have a way in. A future release retires the
OAuth path entirely once every active user has signed in at least
once via OTC.
The migration path for existing users: first OTC sign-in matches by
`users.email` (case-insensitive) to the OAuth-era row and reuses
that row's `id` and `gitea_id`. New users provisioned via OTC carry
`gitea_id = NULL` and `gitea_login = NULL`. The `gitea_id` linker
remains the canonical handle for grandfathered users; `email`
becomes the identity key for everything provisioned after v0.7.0.
The Gitea **bot** user + token are still required (server-side git
operations — repo reads, PR creation — still flow through it). Only
the operator-facing sign-in surface moves.
### Upgrade steps (from 0.6.0)
1. **MUST** restart the backend so migration `012_otc.sql` runs.
The migration rebuilds the `users` table (SQLite cannot ALTER
COLUMN); existing rows pass through unchanged, but the new
schema relaxes `gitea_id` / `gitea_login` to nullable (with
partial unique indexes that ignore NULL) and adds a partial
unique index on `email`. A new `otc_codes` table is created.
2. **MUST** confirm the SMTP overlay is set (`SMTP_HOST`,
`SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_STARTTLS`,
`EMAIL_FROM`, `EMAIL_FROM_NAME`). The OHM overlay already
carries these as of v0.5.0; deployments without them fall back
to logging the code to stdout (dev-only path — production
visitors will not receive their codes).
3. **SHOULD** announce the new email-based sign-in to existing
users. Wording suggestion: "You can now sign in by entering
your email and a one-time code we'll send you. Your old
account is linked automatically the first time you sign in."
4. **MAY** keep the existing OAuth callback as a fallback path.
The new login UI surfaces a small "Sign in with Gitea
(fallback)" link beneath the primary email/code form; a
deployment that prefers to hide it can override the Login
component in a future framework release that exposes the link
behind a feature flag. For v0.7.0, the link is hard-coded.
### New environment variables (all optional with defaults)
- `OTC_TTL_MINUTES` (default `10`) — how long a one-time code is
valid after issuance. Re-requesting invalidates the prior code
immediately regardless of TTL.
- `OTC_REQUEST_COOLDOWN_SECONDS` (default `60`) — per-email cooldown
between successive `/auth/otc/request` calls. The endpoint returns
HTTP 429 when the cooldown blocks a request (the loud-failure
shape; the abuse path is visible rather than swallowed).
No new secrets are required. The existing `SECRET_KEY` continues to
sign session cookies; OTC codes are bcrypt-hashed at rest using a
per-row salt the library generates.
### Added
- **`POST /auth/otc/request`** — body `{email}`. Generates a six-digit
code, stores its bcrypt hash with an expiry, and dispatches a plain
text email via the existing SMTP layer. Returns HTTP 200 (`{ok:true}`)
uniformly so allowlist state is not leaked. Returns HTTP 429 when
the per-email cooldown blocks the request.
- **`POST /auth/otc/verify`** — body `{email, code}`. Validates the
bcrypt hash against the most-recent unconsumed non-expired row, marks
the row consumed, provisions or links the `users` row by email, and
stores the session cookie. Returns HTTP 200 on success, HTTP 400 on
any failure (expired, consumed, wrong, unknown).
- **`backend/migrations/012_otc.sql`** — creates `otc_codes` and
rebuilds `users` with nullable `gitea_id` / `gitea_login` plus a
partial unique index on `email`.
- **`backend/app/otc.py`** — the OTC request/verify state machine and
the `provision_or_link_user` linker.
- **`backend/app/email_otc.py`** — outbound OTC mail composition. Reuses
the SMTP plumbing from `email.py` (`EmailConfig.from_env()`) and the
test buffer (`_SENT`) but writes its own envelope (no unsubscribe
footer, no quiet-hours hold — OTC mail carries a credential and
ignores notification preferences).
- **`frontend/src/components/Login.jsx`** — two-step sign-in surface
at `/login`. Step 1: enter email → request code. Step 2: enter
six-digit code → verify. Cmd/Ctrl+Enter on the code field submits.
The previous header "Sign in" link and the `Welcome` component's
inline link now route to `/login` instead of jumping straight to
the Gitea OAuth dance.
- **SPEC `§6.1` / `§6.2` / `§14.1` / `§17` / `§19.2`** corrections per
§19.3 rule-2 — see below.
- **`backend/tests/test_otc_vertical.py`** — 11 new tests covering
the happy path, expired/consumed/wrong codes, the per-email rate
limit (and its per-email isolation), the allowlist gate, the
migration link to OAuth-era users, fresh provisioning, and the
prior-code-invalidation behavior on re-request.
### Changed
- **`backend/app/auth.py#current_user`** — coerces NULL `gitea_id` and
NULL `gitea_login` to `0` / `""` so the `SessionUser` shape stays
stable for OTC-only users. The DB remains the source of truth for
"is this user OAuth-linked" (`gitea_id IS NOT NULL`).
- **`backend/requirements.txt`** — adds `bcrypt>=4.2` for OTC code
hashing. Pure-Python wheels are available on every platform the
deployment matrix targets; `pip install -r backend/requirements.txt`
picks it up.
- **`frontend/src/App.jsx`** — adds the `/login` route and replaces
the header "Sign in" `<a href="/auth/login">` with `<Link to="/login">`.
The `Welcome` component's inline sign-in link follows suit.
- **`frontend/src/components/Landing.jsx`** — the `/welcome` page's
primary action moves from "Sign in with Gitea" to "Sign in" pointing
at `/login`.
### Deferred to later releases
Per the v0.7.0 scope discipline (the foundation for items #6, #8, #9,
#10), several adjacent capabilities are intentionally not in this
release and surface as §19.2 candidates:
- **First-OTC profile capture** (first name, last name, "why") — item
#6, expected v0.8.0.
- **Open beta-access request flow** replacing the allowlist gate —
also item #6, v0.8.0.
- **Passcodes** (a long-term reauth token alternative) — item #8,
expected v0.10.0.
- **Device-trust 30-day skip** — item #9, expected v0.11.0.
- **Cloudflare Turnstile** on `/auth/otc/request` — item #10,
expected v0.12.0.
- **Removing the Gitea OAuth `/auth/callback` route entirely** — a
later release after every active user has signed in via OTC.
## 0.6.0 — 2026-05-28
**Minor — no operator action required.** A sweep-the-edges hardening
release (roadmap item #4, "anon discuss + contribute off-limits") that
audits every write-shaped backend endpoint and asserts each one
enforces an explicit `auth.require_contributor` (or stricter) gate
before doing any state-changing work. v0.3.0 hid write affordances
behind a sign-in CTA on the frontend; v0.5.0 added the PR-less
discussion surface with its own write gate; v0.6.0 sweeps the rest
and adds a regression test net so future endpoints can't quietly
ship without a gate. No schema migration, no env-var changes, no
new dependencies. The only behavioural change is one tightening: the
`GET /api/rfcs/<slug>/graduate/progress` SSE now requires
`auth.require_user` since it surfaces operator-visible step detail
(repo name, PR number, rollback steps) not part of the v0.3.0
anonymous-read contract for catalog/RFC bodies.
### Changed
- **`backend/app/api_graduation.py`** — `GET /graduate/progress`
now calls `auth.require_user(request)` as its first line.
Anonymous callers receive 401 instead of being able to subscribe
to a graduation's SSE. The floor is `require_user` (not
`require_contributor`) so a write-muted operator can still observe
the progress of a graduation they kicked off before being muted.
- **SPEC `§6.1`** (`SPEC.md`) — the Anonymous role's bullet now
documents the v0.6.0 audit: every write-shaped endpoint in §17
enforces an explicit gate; anonymous writes refuse 401. The list
of audited write families is recorded in-line.
- **SPEC `§10.10`** — the discussion-vs-contribution section now
records that the v0.5.0 write gates have a regression test net
(`test_anon_offlimits_vertical.py`) added in v0.6.0.
- **SPEC `§17`** — the `GET /api/rfcs/<slug>/graduate/progress`
bullet now documents the `require_user` gate added in v0.6.0,
with the rationale.
### Added (tests)
- **`backend/tests/test_anon_offlimits_vertical.py`** — twelve new
tests asserting that every write-shaped endpoint surveyed in the
v0.6.0 audit refuses anonymous callers with 401, and that the five
anonymous-read surfaces (health, philosophy, auth/me, catalog,
RFC view, discussion threads, proposals) stay reachable. Sixty-six
assertions in total, covering: propose; proposal merge / decline /
withdraw; branch promote-to-branch / start-edit-branch / metadata /
manual-flush / visibility / grants (POST + DELETE) / threads (POST)
/ messages (POST) / resolve / chat-seen / changes (accept / decline
/ reask) / chat-stream; super-draft start-edit-branch + metadata;
PR pr-draft / open / seen / review / merge / withdraw /
description / resolution-branch; discussion thread create + message
post + resolve; admin role / mute / allowlist (POST + DELETE) plus
the admin reads; notification preferences / quiet-hours / watch /
mark-read / user-mute (POST + DELETE); funder credentials (POST +
DELETE) + consent (POST + DELETE); graduation kickoff + claim +
progress SSE; PR review page anonymous-readable.
### Anonymous-writeable allowlist
Two endpoints are intentionally anonymous-by-design. They are not
audit findings; they are documented here so the contract is
explicit:
- `GET /auth/login` and `GET /auth/callback` — the OAuth
round-trip. Anonymous-by-design because they ARE the sign-in
entrypoint.
- `POST /api/webhooks/gitea` and `POST /api/webhooks/email-bounce`
— anonymous in the session sense but authenticated by HMAC shared
secret (`GITEA_WEBHOOK_SECRET` and `WEBHOOK_EMAIL_BOUNCE_SECRET`
respectively). The webhook receiver is the wrong place for an
authenticated session; the shared-secret shape is correct.
### §19.2 candidates surfaced
- None unique to this release. The two pre-existing candidates the
audit touched — anonymous-read polish for the discussion surface
(carried from v0.5.0) and the operator-visibility floor on
graduation progress — were settled here as `require_user` on
`/graduate/progress` rather than deferred.
### Upgrade steps (from 0.5.0)
1. The deployment **MUST** rebuild the frontend (`npm install &&
npm run build`) so the frontend bundle's reported version matches
the backend's. No new env vars; existing `frontend/.env` is
sufficient.
2. The deployment **MUST** restart the backend so the new
`require_user` gate on `/graduate/progress` is enforced. No
schema migration runs.
3. The deployment **MAY** announce the audit completion to
operators: every write-shaped backend endpoint now enforces an
explicit gate, and the `test_anon_offlimits_vertical.py` test
net asserts the contract on every CI run. The audited surfaces
are listed in the `Added (tests)` section above and in `SPEC.md`
§6.1.
4. The deployment **MUST NOT** assume any new envelope behaviour:
v0.6.0 is purely a hardening release. No schema, no env, no
dependency changes; the operator's role is reduced to rebuild +
restart.
## 0.5.0 — 2026-05-27
**Minor — no operator action required.** This release wires the
+274 -11
View File
@@ -356,17 +356,60 @@ merge with no data movement.
Authorization is owned by the app. Gitea sees only the bot account.
Authentication, as of v0.7.0, is by email + one-time-code: a visitor
enters their email address, receives a six-digit code via SMTP, and
exchanges the code for a session. The Gitea OAuth callback that
v0.1 used as the human sign-in path remains as a migration fallback
during the v0.7.0 window — `users.gitea_id` is preserved on existing
rows so a grandfathered user signing in via either path resolves to
the same row — but the primary surface points at OTC. `users.email`
is the identity key for everything provisioned after v0.7.0;
`users.gitea_id` is the grandfathering linker (nullable, partial-
unique). The Gitea bot user + token are still required for server-
side git operations (repo reads, PR creation); only the operator-
facing sign-in surface moved.
Admission, as of v0.8.0, is by admin grant. v0.7.0 carried the
v0.3.0 `allowed_emails` table forward as the admission gate at the
OTC request surface — emails not on the list got a silent drop.
v0.8.0 (roadmap item #6) reverses that: any valid email receives an
OTC, the fresh `users` row lands in `permission_state='pending'`,
and an admin grant flips the column to `'granted'` before write
endpoints accept the user. The capture-fields step (first name,
last name, free-text "why I should be included in the beta") feeds
the admin's triage queue. The `allowed_emails` table stays in the
schema as a fast-path bypass — the v0.3.0 admin UI still manages
it — but the OTC request path no longer consults it. v0.9.0's
admin user-management page replaces the allowlist UI and ships the
pending-queue triage surface.
### 6.1 Four roles, each a strict superset of the one below
1. **Anonymous.** Can read public RFCs (the meta repo's main branch,
every RFC repo's main branch), read any branch whose `read_public`
is true, read any PR. Cannot chat, propose, create branches, or
open PRs.
2. **Contributor.** Default role for any authenticated account.
Everything anonymous can do, plus: propose new RFCs (open a PR
against the meta repo), create branches on any RFC repo, open PRs
from branches they have contribute access to, chat on anything
they can read, claim ownership of unclaimed super-drafts.
open PRs. v0.6.0 (roadmap item #4) closed the audit: every
write-shaped endpoint surveyed in §17 enforces an explicit
`auth.require_contributor` (or stricter) gate before doing any
state-changing work; anonymous writes refuse 401. The explicit
audit covers propose, branch create, branch threads, PR-less
discussion threads + messages, PR open / merge / withdraw,
funder credentials + consent, admin allowlist add, graduation
kickoff + claim. Anonymous reads on every catalog and RFC-body
surface remain open per the v0.3.0 contract.
2. **Contributor.** Default role for any authenticated account. A
first OTC sign-in by a previously unknown email provisions a row
at this role; v0.8.0 replaced the v0.3.0 / v0.7.0 allowlist gate
with an admin-grant flow (roadmap item #6, see opening of §6).
The contributor capabilities below — propose, branch, PR, chat,
claim — are gated by `users.permission_state='granted'` as well
as by the role. A pending contributor (the post-OTC waiting
state) has the same read access as anonymous and zero write
capability until an admin grants. Everything anonymous can do,
plus: propose new RFCs (open a PR against the meta repo), create
branches on any RFC repo, open PRs from branches they have
contribute access to, chat on anything they can read, claim
ownership of unclaimed super-drafts.
3. **Admin.** Everything contributor can do, plus: act on any RFC
(merge PRs on behalf of arbiters, graduate super-drafts, set
branch visibility on anyone's behalf, downgrade or restore
@@ -384,6 +427,14 @@ subject to the standard 30/90 hygiene rules (§12). Restoring is the
reverse action. Every mute and restore is logged in
`permission_events`.
The write-mute is keyed on `users.id` and is auth-path-agnostic: a
contributor muted under the v0.1 OAuth-era flow stays muted after
the v0.7.0 email/OTC migration, since the same row is reused via the
email-match linker. Identity in this section means the `users.id`
column; the v0.7.0 identity-key shift (`gitea_id``email`) is
about which column carries the unique constraint for new
provisioning, not about which column the permission gates read.
This write-mute is structurally distinct from the two notification
mutes introduced in §15.8 — the per-RFC notification mute (the
`muted` state on the `watches` row, §15.6) and the per-user
@@ -397,6 +448,38 @@ triage what they can't act on, and the restore lands cleanly); a
self-DND'd contributor's own gestures continue to fire signals to
others normally.
v0.8.0 adds a fourth structurally-distinct field on the same row:
`users.permission_state` (the admission gate the v0.8.0 release
ships, see opening of §6 and §6.1). The four — role, muted,
permission_state, the notification mutes — are orthogonal and the
gate semantics compose:
* `role` answers "what scope of action is this user authorized to
perform if they're admitted at all?" (anonymous / contributor /
admin / owner).
* `muted` answers "is this contributor write-restricted by an
admin gesture against their existing grant?" (a sanctions
primitive — owner/admin imposed).
* `permission_state` answers "is this user admitted to the beta
at all?" (the v0.8.0 admin-grant gate — 'pending' / 'granted' /
'revoked'). The default for grandfathered rows at migration time
is `'granted'`; OTC freshly provisions `'pending'`.
* The notification mutes answer "does this user want to receive
signals about a particular RFC or from a particular other
user?" (self-imposed preference).
The four never gate each other. A pending user with `role=owner`
(impossible by construction in v0.8.0 — fresh OTC always provisions
role=contributor — but the orthogonality holds at the column level)
would still refuse write endpoints because the admission gate
runs first; a granted contributor whose row is also muted refuses
writes via the mute gate; a granted contributor with notification
mutes set still passes the contributor gate and writes normally.
v0.6.0's anon-write audit (item #4) is the structural floor for all
four — every write-shaped endpoint funnels through
`auth.require_contributor`, which checks all three of {authenticated,
not muted, permission_state='granted'} in order.
### 6.3 Per-RFC delegated authority
An RFC's `owners:` and `arbiters:` (from the meta-repo entry's
@@ -1676,8 +1759,12 @@ them was the failure mode of generic-PR-comments-as-only-conversation.
Reads on the discussion surface follow §14 / the v0.3.0 anonymous-read
contract: anyone can see the conversation. Writes require contributor
role per §6.1 (v0.5.0 implements the gate; v0.6.0 — item #4 — hardens
adjacent surfaces to match). The notification routing reuses the
role per §6.1: v0.5.0 implemented the gate on the three discussion
write paths (POST threads, POST messages, POST resolve); v0.6.0 (item
#4) audited the adjacent surfaces and added the matching test net
(`test_anon_offlimits_vertical.py`) so a regression on any write
endpoint is caught immediately. The gates use `auth.require_contributor`
as the canonical helper. The notification routing reuses the
existing `chat_message_in_participated_thread` /
`chat_reply_to_my_message` event kinds with `branch_name=null` on the
fan-out row; the §15.7 reconciler and §15 inbox prose render
@@ -1941,14 +2028,31 @@ and its public face.
The app's root URL, accessed by an unauthenticated visitor, renders a
landing page consisting of the title, the subtitle, and the short-form
deck from the top of `PHILOSOPHY.md` (see §2). Beneath the deck, a
single primary action: "Sign in with Gitea." Beneath that, a secondary
link: "Read the full philosophy" → `/philosophy`.
single primary action: "Sign in" → the email + one-time-code surface
at `/login` (per §6.2). Beneath that, a secondary link: "Read the
full philosophy" → `/philosophy`. The v0.1 landing said "Sign in
with Gitea"; v0.7.0's email/OTC surface replaced that as the primary
gesture, with a small "Sign in with Gitea (fallback)" link surviving
on `/login` itself for the migration window.
This is the front door. It sets expectation before the user encounters
the mechanics, so the mechanics (super-drafts, graduation, public
arguments, AI participation in chat) read as load-bearing rather than
novel.
v0.8.0 (roadmap item #6) added a third sign-in step the surface
runs conditionally — on the first OTC sign-in by a previously
unknown email, the verify response carries `needs_profile=true`
and the surface prompts for first name, last name, and a free-text
"why I should be included in the beta" before bouncing the user to
`/beta-pending`. The page displays a "your request is in review"
message keyed on `users.permission_state='pending'` (repurposed
from the v0.3.0 post-OAuth-rejection surface). Anonymous viewers
and pending viewers see the same read surfaces; only the write
affordances differ. A persistent thin "Your beta access is in
review" banner shows on every page (other than `/beta-pending`
itself) until an admin grants access.
### 14.2 The `/philosophy` route
Authenticated and anonymous visitors alike can reach `/philosophy`,
@@ -2582,6 +2686,48 @@ The follow-up session will refine this. A minimal starting set:
returned `version` matches the tag the operator just deployed,
catching the failure mode where a restart did not pick up the
new code.
- `POST /auth/otc/request` — unauthenticated. Body carries `email`.
Generates a six-digit code, stores its bcrypt hash with an expiry
(`OTC_TTL_MINUTES`, default 10), and dispatches a plain-text email
via the SMTP layer. Returns HTTP 200 (`{ok:true}`) uniformly.
Returns HTTP 429 when the per-email cooldown
(`OTC_REQUEST_COOLDOWN_SECONDS`, default 60) blocks back-to-back
requests — the loud-failure shape for the abuse path. A re-request
invalidates the prior unused code for the same email so only one
code is outstanding at a time. v0.7.0 also dropped requests
silently if the email wasn't on the `allowed_emails` list (the
v0.3.0 admission gate); v0.8.0 (item #6) removed that check —
admission moved to `permission_state` on the freshly-provisioned
`users` row, asserted at the contributor gate. Per §19.2's
expected next session, this endpoint is the lead-up to the
Cloudflare-Turnstile abuse-mitigation overlay.
- `POST /auth/otc/verify` — unauthenticated. Body carries `email` and
`code`. Validates the bcrypt hash against the most-recent unconsumed
non-expired row for the email, marks the row consumed, provisions
or links the `users` row by email (per §6.2's migration path —
match by `users.email` case-insensitive, otherwise insert a fresh
contributor row with `gitea_id = NULL` and
`permission_state='pending'`), and stores the session cookie.
Returns HTTP 200 on success; the response body carries
`{ok, user, needs_profile}` where `needs_profile=true` iff the
user is `permission_state='pending'` AND the row has no
first_name / last_name / beta_request_reason yet (a fresh OTC
sign-in). The `needs_profile` flag drives the Login.jsx surface's
step-3 capture form. HTTP 400 on any failure (expired, consumed,
wrong, unknown). The failure modes collapse to a single generic
message so a probing client cannot distinguish "you got the
wrong code" from "we don't know this email" — the operator logs
carry the distinction.
- `POST /api/auth/me/beta-request` — authenticated. Body carries
`first_name`, `last_name`, `beta_request_reason` (all required;
bounded at 120 / 120 / 4000 chars). Writes the fields to the
signed-in user's row and leaves `permission_state='pending'`.
Idempotent for the same already-pending user (a re-submit
updates the row so the admin sees the latest text). Refuses
with HTTP 409 if the user is already `'granted'` or `'revoked'`.
v0.8.0 — the first-OTC profile-capture endpoint (roadmap item
#6). v0.9.0's admin user-management page consumes this column
set to render the request queue.
- `GET /api/rfcs` — list entries with state, id, title, slug, repo,
owners, last_active_at, has_open_prs, starred-by-me. Supports
search, sort, filter chips, and the `unclaimed` predicate.
@@ -2637,7 +2783,13 @@ The follow-up session will refine this. A minimal starting set:
trailing `rollback` step's events if any earlier step fails. The
Graduate dialog opens this stream on confirm and renders the step
stack from the events. The stream closes on success or on
rollback completion.
rollback completion. Requires `auth.require_user` per v0.6.0
(item #4): the step detail (repo name, PR number, rollback steps)
is operator-visible state and isn't part of the v0.3.0
anonymous-read contract for catalog and RFC bodies. The floor is
`require_user` (not `require_contributor`) so a write-muted
operator can still observe a graduation they kicked off before
being muted.
- `GET /api/rfcs/<slug>/blocking-prs` — list open meta-repo PRs
against `rfcs/<slug>.md` per §13.2's precondition popover. Returns
PR number, title, author, last-activity timestamp, and the
@@ -3551,6 +3703,117 @@ the new §15 (Notifications, in full), and §17 (the notification
endpoints — list, mark-read, stream, watch mutation, preferences,
quiet-hours, per-user mute, unsubscribe, bounce webhook).
First-OTC profile capture (formerly a v0.7.0 candidate) is settled
and folded into §6.1 (the contributor role now requires
`permission_state='granted'`), §6.2 (the orthogonality of
permission_state vs role / muted / notification-mutes), §14.1
(the landing page's v0.8.0 first-OTC capture step), and §17
(the `POST /api/auth/me/beta-request` endpoint and the verify
endpoint's new `needs_profile` flag). The structural decision
landed as: capture is a third step on the `/login` surface
gated by `verify_response.needs_profile=true`; pending users
land on `/beta-pending` after submitting and see a thin banner
on every other page until an admin grants. v0.8.0 (roadmap item
#6) shipped the work.
Candidates surfaced during v0.8.0 (open beta-access request flow,
§6.1 / §14.1, item #6):
- **Admin user-management page** (`/admin/users`). *Surfaced by
v0.8.0 — the release ships the pending-state column but no
admin UI to triage it.* For v0.8.0, the admin gesture is an
out-of-band `UPDATE users SET permission_state='granted' WHERE
email=?`. v0.9.0 (roadmap item #7) is expected to ship the
triage queue: a list of `permission_state='pending'` rows
sorted by `created_at`, each showing the captured first /
last / why fields, with Grant and Revoke buttons that stamp
`permission_decided_by` and `permission_decided_at` (schema
slots already in place per `migrations/014_beta_access.sql`).
The page composes naturally with the existing `/admin/allowlist`
surface — both are admission-control gestures — so v0.9.0 may
fold the allowlist UI into this page (see next candidate).
Decision points: do grants / revokes also fire email
notifications to the user (probably yes — the notifications
layer from v0.6.0 has the personal-direct channel for it); is
there a "decline with reason" gesture that surfaces in the
user's view (probably yes — symmetric with §9.3's
proposal-decline shape); does the page support bulk grants
(probably no for v0.9.0 — the queue volume is operator-scale,
not user-scale). Earns its session as the v0.9.0 design pass.
- **Allowlist deprecation.** *Surfaced by v0.8.0 — the
`allowed_emails` table stays in the schema but the OTC
request path no longer consults it.* v0.8.0 left the table
and the `/admin/allowlist` UI in place as a fast-path bypass
for deployments that want to pre-mark known-good emails (the
v0.8.0 contract is that those emails still go through the
pending-grant flow; the table itself is no longer a gate). A
future release retires both — probably v0.9.0 alongside the
admin user-management page, since the two surfaces are
functionally redundant once the pending queue lands.
Decision points: drop the table outright (a schema migration)
or leave it as a non-functional surface and remove only the
UI (a frontend-only change); how to handle existing
`allowed_emails` rows at the cutover (probably: walk them
into the pending queue with `permission_state='granted'` for
any matching `users` row, leave unmatched rows as a no-op
since v0.8.0 doesn't consult them anymore). Earns its
session as a sub-topic of the v0.9.0 admin user-management
pass.
- **Admin notification on new beta request.** *Surfaced by
v0.8.0 — the capture endpoint writes to the row but does
not signal admins.* v0.9.0 candidate (item #7 again): when a
`POST /api/auth/me/beta-request` lands, fire an `admin-actionable`
notification (per §15.4's category set) to every owner / admin
so the queue doesn't go stale. The §15 infrastructure already
supports the category; the open question is whether the
notification is per-request (one email per submission) or
digested (a daily summary). Earns its session alongside the
admin user-management page.
- **Removing the Gitea OAuth fallback.** *Surfaced by v0.7.0.*
v0.7.0 keeps `/auth/callback` functional and links to it as a
"Sign in with Gitea (fallback)" affordance on the new `/login`
surface, so users with active OAuth sessions or older invite
paths still have a way in during the migration window. A later
release retires the route entirely. Decision points: how do we
know "every active user has signed in via OTC at least once"
(probably: a `users.otc_first_signed_in_at` timestamp added in
v0.7.x and a query that confirms 100% population), how do we
handle users who never come back (probably: silently leave them
with stale rows; OAuth callback returning 404 is a sufficient
message), and whether the `/auth/login` and `/auth/callback`
routes get a tombstone redirect to `/login` or just 404. Earns
its session once the OTC adoption curve flattens.
- **Device trust (30-day skip).** *Surfaced by v0.7.0 — the
signed-in cookie already lasts 30 days via SessionMiddleware,
but every sign-in still requires a fresh OTC.* The roadmap
item-#9 candidate adds a "trust this device" affordance on the
verify step that issues a longer-lived rotating token, so
returning visitors on the same device skip the OTC step. The
shape question is whether the trust is a signed cookie distinct
from the session, a row in a `device_trust` table keyed by a
random device-id, or a property of the session itself; and
whether the trust survives password-equivalent events (none
exist yet — passcodes are item #8 / v0.10.0) or only survives
explicit logout. Earns its session as the v0.11.0 design pass.
- **Cloudflare Turnstile (or equivalent) on `/auth/otc/request`.**
*Surfaced by v0.7.0 — the endpoint is now the new abuse hot
path.* Per-email cooldown stops the trivial loop; what it
doesn't stop is a distributed scrape that fans out across a
large invitee list to harvest the "this email is admitted vs.
this email is not" signal indirectly (timing differences, SMTP
bounce-rate observation). The roadmap item-#10 candidate gates
the request endpoint behind a one-step browser-side challenge
before the bcrypt hash + SMTP send. Open questions: which
provider (Turnstile is the default since it's free and
privacy-respecting; hCaptcha and reCAPTCHA are also viable);
how the deployment configures it (`TURNSTILE_SITE_KEY` +
`TURNSTILE_SECRET_KEY` env vars, gated by `if
config.turnstile_site_key:` at the handler so existing
deployments don't break); whether the verify endpoint also
gets a challenge (probably yes for parity); and how the test
harness mocks the challenge. Earns its session as the v0.12.0
design pass.
Candidates surfaced during v0.13.0 (cookie / privacy consent, §14.5
and §14.6):
+1 -1
View File
@@ -1 +1 @@
0.13.0
0.8.0
+11
View File
@@ -81,3 +81,14 @@ WEBHOOK_EMAIL_BOUNCE_SECRET=
# Production default is hourly; tests override to seconds via the same
# env var.
HYGIENE_TICK_SECONDS=3600
# --- v0.7.0: email + one-time-code sign-in (§6.2) ---
# How long a one-time code stays valid after issuance. Re-requesting
# invalidates the prior code immediately regardless of TTL.
OTC_TTL_MINUTES=10
# Per-email cooldown between successive /auth/otc/request calls. The
# endpoint returns HTTP 429 when the cooldown blocks a request (the
# loud-failure shape so the abuse path is visible). Set to 0 to
# disable the cooldown — useful for tests but never in production.
OTC_REQUEST_COOLDOWN_SECONDS=60
+92
View File
@@ -55,6 +55,17 @@ class FunderCredentialBody(BaseModel):
api_key: str = Field(min_length=1, max_length=2048)
class BetaRequestBody(BaseModel):
# v0.8.0 — captured on the first OTC sign-in. All three fields are
# required so the admin queue has a coherent triage shape.
# The bounds match the v0.7.0 OTC body (320 chars for email-ish
# headers; 4000 for the free-text reason — the same upper bound
# DeclineBody uses elsewhere in this file).
first_name: str = Field(min_length=1, max_length=120)
last_name: str = Field(min_length=1, max_length=120)
beta_request_reason: str = Field(min_length=1, max_length=4000)
def make_router(
config: Config,
gitea: Gitea,
@@ -120,6 +131,28 @@ def make_router(
user = auth.current_user(request)
if user is None:
return {"authenticated": False, "user": None}
# v0.8.0: surface `permission_state` plus the capture-flow
# readiness signal (`needs_profile`). The frontend gates
# the /beta-pending page and the inline banner off these
# fields, and decides whether to prompt for the first/last/why
# capture on first OTC sign-in.
row = db.conn().execute(
"SELECT first_name, last_name, beta_request_reason FROM users WHERE id = ?",
(user.user_id,),
).fetchone()
first_name = (row["first_name"] if row else None) or ""
last_name = (row["last_name"] if row else None) or ""
beta_request_reason = (row["beta_request_reason"] if row else None) or ""
# "Needs profile" iff the user is pending AND hasn't yet
# filed their beta-request capture. Granted users never see
# the capture prompt; pending users who already filed see
# the /beta-pending page without the capture form.
needs_profile = (
user.permission_state == "pending"
and not first_name
and not last_name
and not beta_request_reason
)
return {
"authenticated": True,
"user": {
@@ -129,9 +162,68 @@ def make_router(
"email": user.email,
"avatar_url": user.avatar_url,
"role": user.role,
"permission_state": user.permission_state,
"first_name": first_name,
"last_name": last_name,
"beta_request_reason": beta_request_reason,
"needs_profile": needs_profile,
},
}
# ---------------------------------------------------------------
# v0.8.0: /api/auth/me/beta-request — first-OTC profile capture
# (roadmap item #6). Lands first name, last name, and the free-
# text "why I should be included in the beta" on the signed-in
# user's row. Idempotent for the same already-pending user;
# refuses to overwrite a row that's already granted (so a
# bored already-granted user can't accidentally re-submit the
# form and clobber the admin's audit trail). Uses
# `require_user` rather than `require_contributor` because
# `require_contributor` already enforces `permission_state =
# 'granted'` and would refuse a pending user; the whole point
# of this endpoint is to register the request _from_ a pending
# user.
# ---------------------------------------------------------------
@router.post("/api/auth/me/beta-request")
async def submit_beta_request(body: BetaRequestBody, request: Request) -> dict[str, Any]:
user = auth.require_user(request)
row = db.conn().execute(
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE id = ?",
(user.user_id,),
).fetchone()
if row is None:
# Defensive — the session pointed at a deleted row.
raise HTTPException(404, "User not found")
# Granted users have no business filing a beta request.
# 'revoked' likewise — the request flow is for fresh users
# only. Both shapes refuse with 409 (conflict) so the client
# can distinguish "you already have access" from
# "your access was revoked".
if row["permission_state"] == "granted":
raise HTTPException(409, "Your account is already granted access")
if row["permission_state"] == "revoked":
raise HTTPException(409, "Your account's access has been revoked")
# Re-submission from a pending user updates the row — the
# admin sees the latest text rather than a stale draft.
# The state stays 'pending'; only an admin can flip it.
db.conn().execute(
"""
UPDATE users
SET first_name = ?,
last_name = ?,
beta_request_reason = ?
WHERE id = ?
""",
(
body.first_name.strip(),
body.last_name.strip(),
body.beta_request_reason.strip(),
user.user_id,
),
)
return {"ok": True}
# ---------------------------------------------------------------
# §7: the catalog
# ---------------------------------------------------------------
+10 -1
View File
@@ -520,7 +520,16 @@ def make_router(
@router.get("/api/rfcs/{slug}/graduate/progress")
async def graduate_progress(slug: str, request: Request):
del request
# v0.6.0 (item #4): the progress SSE surfaces admin-internal step
# detail (repo name, PR number, rollback steps) that isn't part of
# the v0.3.0 anonymous-read contract for catalog/RFC bodies. The
# corresponding POST /graduate is gated to RFC owners/arbiters and
# app admins/owners via `_can_graduate`; the read SSE shares that
# operator-visible surface, so it requires at least an
# authenticated viewer. We keep the floor at require_user (not
# require_contributor) so a write-muted operator can still observe
# the progress of a graduation they kicked off before being muted.
auth.require_user(request)
state = _get_active(slug)
if state is None:
raise HTTPException(404, "No graduation in flight for this slug")
+68 -6
View File
@@ -30,6 +30,12 @@ class SessionUser:
email: str
avatar_url: str
role: str
# v0.8.0 / §6.1 — admission gate. Three states: 'pending' (waiting
# for an admin grant), 'granted' (active contributor), 'revoked'
# (was granted, later removed). Existing rows at migration time
# default to 'granted' so grandfathered users are unaffected; OTC
# provisions fresh users with 'pending' (see `app/otc.py`).
permission_state: str = "granted"
def as_actor(self) -> Actor:
return Actor(
@@ -90,6 +96,13 @@ def allowlist_is_active() -> bool:
def is_allowed_sign_in(profile: dict[str, Any]) -> bool:
"""Decide whether a freshly-completed OAuth profile may sign in.
v0.8.0 (item #6) replaces the allowlist gate with an admin-grant
flow at the OTC `/request` surface, but the Gitea OAuth callback
in `main.py` still consults this helper so the fallback path
keeps the v0.3.0 admission shape during the OAuth migration
window. The eventual removal of the OAuth callback (§19.2)
retires this function alongside it.
Three accept paths:
1. The allowlist is empty (gate off).
2. The Gitea profile's email is in `allowed_emails` (case-insensitive).
@@ -132,17 +145,27 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
existing = c.execute("SELECT * FROM users WHERE gitea_id = ?", (gitea_id,)).fetchone()
if existing is None:
role = "owner" if config.owner_gitea_login and login == config.owner_gitea_login else "contributor"
# v0.8.0: a fresh OAuth-provisioned user is also subject to
# the admin-grant flow. The OAuth fallback only fires for
# users who pass `is_allowed_sign_in` (so they're already on
# the legacy allowlist or are grandfathered by gitea_id);
# 'granted' is the right default here since the allowlist
# check is itself the admin gesture. A future release that
# retires the OAuth callback (§19.2) collapses both paths
# under the same gate.
cur = c.execute(
"""
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
VALUES (?, ?, ?, ?, ?, ?, 'granted')
""",
(gitea_id, login, email, display, avatar, role),
)
user_id = cur.lastrowid
permission_state = "granted"
else:
user_id = existing["id"]
role = existing["role"]
permission_state = existing["permission_state"] or "granted"
c.execute(
"""
UPDATE users
@@ -160,6 +183,7 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
email=email,
avatar_url=avatar,
role=role,
permission_state=permission_state,
)
@@ -178,6 +202,12 @@ def store_session(request: Request, user: SessionUser) -> None:
"email": user.email,
"avatar_url": user.avatar_url,
"role": user.role,
# v0.8.0: persist the admission state on the cookie payload so
# the post-cookie audit doesn't second-guess the row. The DB
# is re-read on every `current_user` call regardless (so an
# admin grant takes effect on the next request); this field
# is purely structural redundancy for the cookie shape.
"permission_state": user.permission_state,
}
@@ -188,19 +218,31 @@ def current_user(request: Request) -> SessionUser | None:
# Re-read the role from the database every request so role changes
# take effect on the next API call without forcing a logout.
row = db.conn().execute(
"SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role FROM users WHERE id = ?",
"SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state FROM users WHERE id = ?",
(raw["user_id"],),
).fetchone()
if row is None:
return None
# v0.7.0: OTC-provisioned users have NULL gitea_id / gitea_login.
# Coerce nulls to the SessionUser's typed defaults so downstream
# code (Actor, _on_behalf_trailer) reads a stable shape regardless
# of which sign-in path the row came from. The DB remains the
# source of truth for "is this an OAuth-linked user" (gitea_id IS
# NOT NULL); the in-memory SessionUser is the per-request handle.
# v0.8.0: permission_state comes off the row directly. A NULL
# column value (shouldn't happen under the migration's
# NOT NULL DEFAULT, but be defensive) reads as 'granted' so the
# gate fails open for grandfathered surfaces rather than locking
# everyone out on a malformed row.
return SessionUser(
user_id=row["id"],
gitea_id=row["gitea_id"],
gitea_login=row["gitea_login"],
gitea_id=row["gitea_id"] or 0,
gitea_login=row["gitea_login"] or "",
display_name=row["display_name"],
email=row["email"] or "",
avatar_url=row["avatar_url"] or "",
role=row["role"],
permission_state=row["permission_state"] or "granted",
)
@@ -212,11 +254,31 @@ def require_user(request: Request) -> SessionUser:
def require_contributor(request: Request) -> SessionUser:
"""§6.1: authenticated, not write-muted."""
"""§6.1: authenticated, not write-muted, and granted by an admin.
v0.8.0 (item #6) widens this gate. A fresh OTC sign-in lands in
`permission_state='pending'`; the user can read everything an
anonymous viewer can read, but every write-shaped endpoint that
funnels through this dependency now refuses with 403 until an
admin grants them. The `pending` blast radius is the same as
anonymous (item #4 / v0.6.0 already audited the anon-write
refusal at every write site), so this widening is structurally
a relabel the same surfaces that already refused 401 to
anonymous now also refuse 403 to pending.
"""
user = require_user(request)
row = db.conn().execute("SELECT muted FROM users WHERE id = ?", (user.user_id,)).fetchone()
if row and row["muted"]:
raise HTTPException(status_code=403, detail="Your account is muted")
if user.permission_state != "granted":
# 'pending' is the post-OTC waiting state; 'revoked' is the
# admin-undid-the-grant state. Both refuse with the same 403
# shape; the client distinguishes via `/api/auth/me` which
# carries `permission_state` in the response.
raise HTTPException(
status_code=403,
detail="Your beta access request is in review",
)
return user
+96
View File
@@ -0,0 +1,96 @@
"""Outbound OTC email — a thin wrapper over the existing SMTP layer.
The §15.4 notification mailer in `email.py` is purpose-built for
inbox-driven mail (unsubscribe footers, quiet-hours holds, bundling).
OTC mail is structurally different: it carries a credential, has no
inbox row behind it, and ignores user-preferences (a contributor
who's opted out of every notification still needs to receive the
code they explicitly requested).
So this module reuses `EmailConfig.from_env()` for the SMTP plumbing
and the From identity, but writes its own envelope. In dev (no
SMTP_HOST set), the envelope is logged at INFO level and pushed to
the same `_SENT` buffer the notification mailer uses, so the
integration tests can assert on the outbound shape without standing
up an SMTP server.
The send is synchronous. The `/auth/otc/request` endpoint always
returns 202 regardless of send outcome the user-facing surface
doesn't know whether the SMTP relay was reachable, since revealing
that would let an attacker probe for valid emails on a tight loop.
"""
from __future__ import annotations
import logging
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
from .email import EmailConfig, _SENT
log = logging.getLogger(__name__)
def send_otc_email(to_address: str, code: str) -> bool:
"""Compose and send the one-time-code email. Returns True on the
happy path; False on SMTP failure. The notifier-side buffer
`_SENT` is appended either way so tests can assert on content.
The subject and body intentionally avoid branding strings that
belong to a deployment only `EMAIL_FROM_NAME` (operator-supplied
via env) lands in the From line. The body names the code, the
TTL, and a single instruction line. No tracking pixel, no
deep-link query, no embedded JS plain text only."""
cfg = EmailConfig.from_env()
subject = f"Your sign-in code for {cfg.from_name}"
body = _body(code, cfg)
envelope = {
"to": to_address,
"from": formataddr((cfg.from_name, cfg.from_address)),
"subject": subject,
"body": body,
"kind": "otc",
}
_SENT.append(envelope)
if not cfg.enabled:
log.info("otc email disabled (EMAIL_ENABLED=0): to=%s", to_address)
return True
if not cfg.smtp_host:
# Dev fallback: surface the code at INFO so the operator can
# complete a sign-in flow without an SMTP relay. In production
# SMTP_HOST is always set per OHM's overlay.
log.info("otc email (stdout fallback): to=%s code=%s", to_address, code)
return True
try:
msg = EmailMessage()
msg["From"] = envelope["from"]
msg["To"] = to_address
msg["Subject"] = subject
msg.set_content(body)
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
try:
if cfg.smtp_starttls:
smtp.starttls()
if cfg.smtp_user:
smtp.login(cfg.smtp_user, cfg.smtp_password)
smtp.send_message(msg)
finally:
smtp.quit()
return True
except Exception:
log.exception("otc email send failed: to=%s", to_address)
return False
def _body(code: str, cfg: EmailConfig) -> str:
return (
f"Your sign-in code is:\n\n"
f" {code}\n\n"
f"Enter this code in the sign-in screen to finish signing in.\n"
f"The code expires in 10 minutes. If you did not request this,\n"
f"you can safely ignore this email — no account was created.\n\n"
f"---\n"
f"{cfg.from_name} · {cfg.app_url}\n"
)
+83 -1
View File
@@ -12,9 +12,21 @@ from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI, HTTPException, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, Field
from starlette.middleware.sessions import SessionMiddleware
from . import api as api_routes, auth, cache, db, digest, hygiene, providers as providers_mod, webhooks
from . import (
api as api_routes,
auth,
cache,
db,
digest,
email_otc,
hygiene,
otc,
providers as providers_mod,
webhooks,
)
from .bot import Bot
from .config import load_config
from .gitea import Gitea
@@ -23,6 +35,15 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
log = logging.getLogger("rfc_app")
class OtcRequestBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
class OtcVerifyBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
code: str = Field(min_length=1, max_length=16)
@asynccontextmanager
async def lifespan(app: FastAPI):
config = load_config()
@@ -122,4 +143,65 @@ def _oauth_router(config) -> APIRouter:
request.session.clear()
return RedirectResponse("/")
# ---------------------------------------------------------------
# v0.7.0: email + one-time-code sign-in (§6.2).
#
# Replaces the OAuth gesture as the primary human-auth path. The
# /auth/callback handler above remains functional as a fallback;
# the new UI no longer surfaces it. A future release retires the
# OAuth path entirely once every active user has signed in at
# least once via OTC.
# ---------------------------------------------------------------
@router.post("/auth/otc/request")
async def otc_request(body: OtcRequestBody):
outcome = otc.request_code(body.email)
if outcome.reason == "cooldown":
# Loud failure per the rate-limit primitive — the abuse
# surface should be visible to clients hammering /request.
raise HTTPException(429, "Wait before requesting another code")
if outcome.sent and outcome.code is not None:
email_otc.send_otc_email(body.email.strip(), outcome.code)
# 202 regardless of allowlist/invalid — don't leak which
# emails are recognized.
return {"ok": True}
@router.post("/auth/otc/verify")
async def otc_verify(body: OtcVerifyBody, request: Request):
result = otc.verify_code(body.email, body.code)
if not result.ok or result.user is None:
raise HTTPException(400, "Invalid or expired code")
auth.store_session(request, result.user)
# v0.8.0: surface `needs_profile` so the Login.jsx surface can
# decide whether to advance to the first/last/why capture step
# or jump straight to "/". `needs_profile=true` iff the user
# is `permission_state='pending'` AND the row has no profile
# fields yet — a fresh OTC user. Grandfathered users
# (`permission_state='granted'`) and pending users who already
# captured their fields both read as false.
row = db.conn().execute(
"SELECT first_name, last_name, beta_request_reason FROM users WHERE id = ?",
(result.user.user_id,),
).fetchone()
first_name = (row["first_name"] if row else None) or ""
last_name = (row["last_name"] if row else None) or ""
beta_request_reason = (row["beta_request_reason"] if row else None) or ""
needs_profile = (
result.user.permission_state == "pending"
and not first_name
and not last_name
and not beta_request_reason
)
return {
"ok": True,
"user": {
"id": result.user.user_id,
"display_name": result.user.display_name,
"email": result.user.email,
"role": result.user.role,
"permission_state": result.user.permission_state,
},
"needs_profile": needs_profile,
}
return router
+336
View File
@@ -0,0 +1,336 @@
"""§6.2 / v0.7.0 / v0.8.0: email + one-time-code sign-in.
Replaces the Gitea OAuth gesture as the primary human-auth path. The
Gitea bot user + token are still needed for server-side git
operations (repo reads, PR creation); only the operator-facing
sign-in surface moves through this module.
The shape:
* `request_code(email)` generates a 6-digit decimal code,
hashes it (bcrypt), stores the hash + expiry in `otc_codes`,
and dispatches a plain-text email via `email_otc.send`. It
invalidates any prior unused codes for the same email so a
re-request keeps the surface to one outstanding code per
address. The TTL comes from `OTC_TTL_MINUTES` (default 10).
A per-email cooldown (`OTC_REQUEST_COOLDOWN_SECONDS`, default
60) refuses back-to-back requests inside the window.
* `verify_code(email, code)` walks the most recent unconsumed
non-expired row for the email, checks the bcrypt hash, marks
the row consumed, and returns the linked or freshly-provisioned
user row.
* `provision_or_link_user(email)` is the migration path: if a
`users` row already carries `email` (case-insensitive), it is
reused `gitea_id` is left alone so a grandfathered OAuth-era
user keeps the linker intact. Otherwise a fresh contributor
row is provisioned with `gitea_id = NULL`, `gitea_login = NULL`,
and `permission_state = 'pending'` (v0.8.0 see below).
The endpoints in `main.py` thin-wrap this module.
v0.8.0 (roadmap item #6) replaces the v0.3.0 `allowed_emails` gate at
the request surface. The request handler used to silently drop OTC
requests for emails not on the allowlist; now any valid email
receives a code. The admission gate moves to `permission_state` on
the freshly-provisioned `users` row: a fresh user lands in 'pending'
and waits for an admin grant before write endpoints accept them.
Read surfaces stay open (the same blast radius v0.6.0 / item #4
already audited for anonymous viewers).
The `allowed_emails` table itself stays in the schema as a
fast-path bypass the admin UI from v0.3.0 continues to manage it,
and a future release (v0.9.0's admin user-management page) collapses
the two admission surfaces into one. The OTC request path no
longer consults the table.
"""
from __future__ import annotations
import logging
import os
import secrets
from dataclasses import dataclass
import bcrypt
from . import db
from .auth import SessionUser
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tunables — env-driven with defaults so v0.7.0 needs no new secrets.
# ---------------------------------------------------------------------------
def _ttl_minutes() -> int:
raw = os.environ.get("OTC_TTL_MINUTES", "").strip()
if not raw:
return 10
try:
return max(1, int(raw))
except ValueError:
return 10
def _cooldown_seconds() -> int:
raw = os.environ.get("OTC_REQUEST_COOLDOWN_SECONDS", "").strip()
if not raw:
return 60
try:
return max(0, int(raw))
except ValueError:
return 60
# ---------------------------------------------------------------------------
# Code generation + hashing
# ---------------------------------------------------------------------------
def _new_code() -> str:
"""Six decimal digits. `secrets.randbelow` is CSPRNG-backed so the
code resists guessing even at the small (10^6) keyspace. The TTL
+ rate-limit are what carry the security weight the entropy of a
six-digit code by itself is intentionally human-readable."""
return f"{secrets.randbelow(1_000_000):06d}"
def _hash_code(code: str) -> str:
"""bcrypt over the code bytes. The hash is stored at rest; the code
itself only travels in the outbound email and the inbound verify
body."""
return bcrypt.hashpw(code.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
def _check_code(code: str, code_hash: str) -> bool:
try:
return bcrypt.checkpw(code.encode("utf-8"), code_hash.encode("ascii"))
except (ValueError, TypeError):
return False
# ---------------------------------------------------------------------------
# Request path
#
# v0.8.0: the allowlist gate from v0.7.0 / v0.3.0 is removed here. Any
# valid email receives a code; the admission gate moved to
# `permission_state` on the freshly-provisioned `users` row (see
# `provision_or_link_user`). The `allowed_emails` table stays in the
# schema (admin UI from v0.3.0 still manages it); v0.9.0's admin
# user-management page will collapse the two surfaces.
# ---------------------------------------------------------------------------
@dataclass
class RequestOutcome:
"""The outcome of a `request_code` call.
`code` is None whenever no code was generated the cooldown
window blocked the request or the email was syntactically
invalid. The caller (the API endpoint) does not surface the
invalid-email shape to the user; it returns 202 either way.
The cooldown shape surfaces as a loud 429 per the v0.7.0
contract.
"""
sent: bool
code: str | None
reason: str # 'sent' | 'cooldown' | 'invalid'
def request_code(email: str) -> RequestOutcome:
email = (email or "").strip()
if not email or "@" not in email:
return RequestOutcome(sent=False, code=None, reason="invalid")
# Cooldown: refuse if a code was issued for this email in the last
# COOLDOWN_SECONDS. We surface it as a distinct outcome so the
# endpoint can return 429 — the spec calls this out as a "loud
# failure" so the abuse path is visible rather than swallowed.
cooldown = _cooldown_seconds()
if cooldown > 0:
row = db.conn().execute(
f"""
SELECT 1 FROM otc_codes
WHERE email = ?
AND datetime(created_at, '+{cooldown} seconds') > datetime('now')
LIMIT 1
""",
(email,),
).fetchone()
if row is not None:
return RequestOutcome(sent=False, code=None, reason="cooldown")
# Invalidate prior unused codes for this email. A re-request is
# always for the most recent code; older codes are dead.
db.conn().execute(
"""
UPDATE otc_codes
SET consumed_at = datetime('now')
WHERE email = ?
AND consumed_at IS NULL
""",
(email,),
)
code = _new_code()
code_hash = _hash_code(code)
ttl = _ttl_minutes()
db.conn().execute(
f"""
INSERT INTO otc_codes (email, code_hash, expires_at)
VALUES (?, ?, datetime('now', '+{ttl} minutes'))
""",
(email, code_hash),
)
return RequestOutcome(sent=True, code=code, reason="sent")
# ---------------------------------------------------------------------------
# Verify path
# ---------------------------------------------------------------------------
@dataclass
class VerifyOutcome:
"""Result of a `verify_code` call.
`user` is populated only on success. `reason` distinguishes the
failure modes the UI can render 'expired', 'consumed', 'wrong',
'unknown' (no outstanding code at all). The endpoint maps the
failure modes to a single 400 with a generic message; the reason
is logged for the operator.
"""
ok: bool
user: SessionUser | None
reason: str
def verify_code(email: str, code: str) -> VerifyOutcome:
email = (email or "").strip()
code = (code or "").strip()
if not email or not code:
return VerifyOutcome(ok=False, user=None, reason="invalid")
rows = db.conn().execute(
"""
SELECT id, code_hash, expires_at, consumed_at
FROM otc_codes
WHERE email = ?
ORDER BY id DESC
LIMIT 5
""",
(email,),
).fetchall()
if not rows:
return VerifyOutcome(ok=False, user=None, reason="unknown")
# Walk the recent rows so a user who pasted an older code still
# gets a sensible error — without this, the most-recent-row check
# would mask "you entered yesterday's code" as "wrong code".
matched = None
for row in rows:
if _check_code(code, row["code_hash"]):
matched = row
break
if matched is None:
return VerifyOutcome(ok=False, user=None, reason="wrong")
if matched["consumed_at"] is not None:
return VerifyOutcome(ok=False, user=None, reason="consumed")
expired = db.conn().execute(
"SELECT datetime(?) < datetime('now') AS expired",
(matched["expires_at"],),
).fetchone()["expired"]
if expired:
return VerifyOutcome(ok=False, user=None, reason="expired")
# Stamp consumed before provisioning so a parallel verify of the
# same row can't double-sign-in.
db.conn().execute(
"UPDATE otc_codes SET consumed_at = datetime('now') WHERE id = ?",
(matched["id"],),
)
user = provision_or_link_user(email)
return VerifyOutcome(ok=True, user=user, reason="ok")
# ---------------------------------------------------------------------------
# Provisioning — the migration path from OAuth identity to email identity.
# ---------------------------------------------------------------------------
def provision_or_link_user(email: str) -> SessionUser:
"""Link the OTC sign-in to a `users` row.
Match order:
1. An existing row whose email equals (case-insensitive) the
requested email the OAuth-era user is grandfathered in via
this path. `gitea_id` is preserved so a future OAuth round
trip still resolves the same row. `permission_state` is
read off the row as-is grandfathered users come through
migration with 'granted' (the column default), so their
contributor capabilities are unaffected.
2. Otherwise: a fresh contributor row with `gitea_id = NULL`,
`gitea_login = NULL`, and `permission_state = 'pending'`
(v0.8.0). The display name defaults to the local part of
the email (everything before the `@`); a separate
`POST /auth/me/beta-request` call lands first name / last
name / "why I want access" on the same row.
The §6.1 owner-zero bootstrap still applies: if the email matches
the configured `OWNER_GITEA_LOGIN`-derived owner identity, the row
is provisioned with role='owner'. v0.7.0 keeps that field as the
Gitea login (so existing deployments don't break); a future
release may add a parallel `OWNER_EMAIL` env if the OAuth route is
dropped entirely.
"""
email = email.strip()
existing = db.conn().execute(
"SELECT * FROM users WHERE email = ? COLLATE NOCASE",
(email,),
).fetchone()
if existing is not None:
db.conn().execute(
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
(existing["id"],),
)
return SessionUser(
user_id=existing["id"],
gitea_id=existing["gitea_id"] or 0,
gitea_login=existing["gitea_login"] or "",
display_name=existing["display_name"],
email=existing["email"] or email,
avatar_url=existing["avatar_url"] or "",
role=existing["role"],
permission_state=existing["permission_state"] or "granted",
)
display = email.split("@", 1)[0] or email
# v0.8.0: 'pending' is the explicit insert value; the migration
# default of 'granted' is what passes grandfathered users
# through. A fresh OTC user lands in 'pending' regardless of
# what the migration default says, so the gate engages reliably
# even if a future migration changes the default.
cur = db.conn().execute(
"""
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
VALUES (NULL, NULL, ?, ?, '', 'contributor', 'pending')
""",
(email, display),
)
user_id = cur.lastrowid
return SessionUser(
user_id=user_id,
gitea_id=0,
gitea_login="",
display_name=display,
email=email,
avatar_url="",
role="contributor",
permission_state="pending",
)
+105
View File
@@ -0,0 +1,105 @@
-- §6.2 / v0.7.0: email + one-time-code sign-in.
--
-- Replaces the Gitea OAuth gesture as the primary human-auth path.
-- The Gitea bot user + token are still needed for server-side git
-- operations (repo reads, PR creation); only the operator-facing
-- sign-in surface moves. The /auth/callback OAuth route remains
-- functional during migration as a fallback, scheduled for removal
-- in a future release once every active user has signed in via OTC
-- at least once.
--
-- A row in `otc_codes` represents an outstanding 6-digit code that
-- was emailed to `email`. Codes are stored hashed (bcrypt) rather
-- than plaintext, so a database compromise does not expose the
-- in-flight code. TTL is enforced by `expires_at`. Each `verify`
-- success stamps `consumed_at` and refuses every later attempt
-- against the same row.
--
-- The §6.2 identity model under v0.7.0:
--
-- * `users.email` is the primary identity key for new sign-ins.
-- * `users.gitea_id` stays populated for users grandfathered in
-- via the OAuth-era flow; new users have `gitea_id = NULL`.
-- The unique-constraint on `gitea_id` is relaxed (in v0.5.0 it
-- was `INTEGER UNIQUE NOT NULL`) to permit the NULL.
-- * `users.email` becomes a (case-insensitive) unique key. An
-- existing OAuth user whose Gitea profile carried an email is
-- linked on first OTC sign-in; if no row matches, a fresh
-- contributor row is provisioned.
--
-- New env vars (v0.7.0):
-- * `OTC_TTL_MINUTES` (default 10): how long a code stays valid.
-- * `OTC_REQUEST_COOLDOWN_SECONDS` (default 60): per-email rate
-- limit between successive `/auth/otc/request` calls.
CREATE TABLE otc_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL COLLATE NOCASE,
code_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
consumed_at TEXT
);
CREATE INDEX idx_otc_codes_email ON otc_codes (email, consumed_at, expires_at);
-- Relax `users.gitea_id` from `INTEGER UNIQUE NOT NULL` to a nullable
-- column with a partial unique index that ignores nulls. SQLite does
-- not support ALTER COLUMN, so we rebuild the table.
--
-- A few defensive notes:
-- * Every foreign key into `users(id)` continues to resolve — `id`
-- is the same INTEGER PRIMARY KEY in the rebuilt table.
-- * `email` is now declared NOCASE so a `WHERE email = ?` match
-- is case-insensitive without changing every read site. The
-- prior column accepted any text; existing rows pass through
-- unchanged.
-- * `gitea_login` likewise relaxes from NOT NULL to nullable, so
-- users provisioned by OTC alone don't carry a synthetic login.
CREATE TABLE users_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
gitea_id INTEGER,
gitea_login TEXT,
email TEXT COLLATE NOCASE,
display_name TEXT NOT NULL,
avatar_url TEXT,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'contributor')),
muted INTEGER NOT NULL DEFAULT 0,
email_personal_direct INTEGER NOT NULL DEFAULT 1,
email_watched_structural INTEGER NOT NULL DEFAULT 0,
email_admin_actionable INTEGER NOT NULL DEFAULT 1,
email_opt_out_all INTEGER NOT NULL DEFAULT 0,
digest_cadence TEXT NOT NULL DEFAULT 'weekly' CHECK (digest_cadence IN ('off', 'weekly', 'daily')),
notification_quiet_hours_start TEXT,
notification_quiet_hours_end TEXT,
notification_quiet_hours_timezone TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO users_new (
id, gitea_id, gitea_login, email, display_name, avatar_url, role,
muted, email_personal_direct, email_watched_structural,
email_admin_actionable, email_opt_out_all, digest_cadence,
notification_quiet_hours_start, notification_quiet_hours_end,
notification_quiet_hours_timezone, created_at, last_seen_at
)
SELECT
id, gitea_id, gitea_login, email, display_name, avatar_url, role,
muted, email_personal_direct, email_watched_structural,
email_admin_actionable, email_opt_out_all, digest_cadence,
notification_quiet_hours_start, notification_quiet_hours_end,
notification_quiet_hours_timezone, created_at, last_seen_at
FROM users;
DROP TABLE users;
ALTER TABLE users_new RENAME TO users;
CREATE INDEX idx_users_role ON users (role);
-- Partial unique indexes so NULLs are permitted but populated values
-- collide. Gitea linkage stays unique per gitea_id; OTC-era identity
-- is keyed on email (case-insensitive via NOCASE on the column).
CREATE UNIQUE INDEX idx_users_gitea_id ON users (gitea_id) WHERE gitea_id IS NOT NULL;
CREATE UNIQUE INDEX idx_users_gitea_login ON users (gitea_login) WHERE gitea_login IS NOT NULL;
CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE email IS NOT NULL AND email != '';
+76
View File
@@ -0,0 +1,76 @@
-- §6.1 / §6.2 / §14.1 / v0.8.0: open beta-access request flow (roadmap item #6).
--
-- This release replaces v0.3.0's `allowed_emails` allowlist as the
-- admission control. Anyone with a valid email can sign in via the
-- v0.7.0 OTC flow; a fresh user lands in `permission_state='pending'`
-- until an admin grants access. The first-OTC flow captures three
-- profile fields (first name, last name, free-text "why I should be
-- included in the beta") that the admin sees when triaging the
-- request queue. The `allowed_emails` table stays in the schema as a
-- fast-path bypass — populated rows are still readable by the
-- existing admin UI; the OTC `/request` handler no longer consults
-- it. v0.9.0's admin user-management page will replace the
-- allowlist UI entirely.
--
-- Schema additions:
--
-- * `permission_state` — three-state CHECK: 'pending' | 'granted' |
-- 'revoked'. Default 'granted' so every row at migration time
-- passes through unaffected; only newly provisioned OTC users
-- land in 'pending' (the OTC verify path sets the column
-- explicitly on a fresh row, per `app/otc.py`). 'revoked' is the
-- admin gesture for an account that earned a grant then later
-- lost it; v0.8.0 doesn't surface a revoke UI, but the schema
-- slot is here so v0.9.0's admin user-management page can flip
-- the column without another migration.
--
-- * `first_name`, `last_name` — nullable TEXT. Captured on the
-- first OTC sign-in via `POST /auth/me/beta-request`. Existing
-- rows (OAuth-era users, OTC users provisioned in v0.7.0) carry
-- NULL through the migration; the admin queue treats an
-- unpopulated capture as "auto-grandfathered" since the row's
-- `permission_state` is already 'granted'.
--
-- * `beta_request_reason` — nullable TEXT. The free-text "why I
-- should be included" from the capture form. Bounded to ~4000
-- chars at the endpoint layer (no DB-level constraint —
-- SQLite's TEXT is unbounded).
--
-- * `permission_decided_by` — nullable INTEGER. The `users.id` of
-- the admin who flipped `permission_state` from 'pending' to
-- 'granted' (or 'granted' to 'revoked'). NULL for grandfathered
-- rows (they were never decided — they passed through at
-- migration). ON DELETE SET NULL because losing the admin row
-- should not cascade-delete the user whose access they granted.
--
-- * `permission_decided_at` — nullable TEXT timestamp (ISO 8601,
-- same shape as the existing `created_at` / `last_seen_at`).
-- Co-populated with `permission_decided_by` on each decision.
--
-- Grandfathered-row invariant:
--
-- Every row that exists at migration time has
-- `permission_state='granted'` and `permission_decided_by=NULL`
-- (the column default + NULL preservation). v0.8.0's auth gate
-- reads `permission_state='granted'` as the admission check, so
-- no existing user is locked out by the upgrade. v0.7.0's OTC
-- path is patched in the same release to set
-- `permission_state='pending'` explicitly on a fresh row, so the
-- gate engages only for users provisioned after the upgrade.
ALTER TABLE users ADD COLUMN permission_state TEXT NOT NULL DEFAULT 'granted'
CHECK (permission_state IN ('pending', 'granted', 'revoked'));
ALTER TABLE users ADD COLUMN first_name TEXT;
ALTER TABLE users ADD COLUMN last_name TEXT;
ALTER TABLE users ADD COLUMN beta_request_reason TEXT;
ALTER TABLE users ADD COLUMN permission_decided_by INTEGER
REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE users ADD COLUMN permission_decided_at TEXT;
-- Index for the v0.9.0 admin queue: list pending requests ordered by
-- when the user's row was created (the implicit "request received at"
-- timestamp, since v0.8.0 sets pending at the same moment as the row
-- itself is inserted via the OTC verify path).
CREATE INDEX idx_users_permission_state ON users (permission_state);
+1
View File
@@ -8,3 +8,4 @@ anthropic>=0.39
google-generativeai>=0.8
openai>=1.50
PyYAML>=6.0
bcrypt>=4.2
@@ -0,0 +1,476 @@
"""v0.6.0 (roadmap item #4) — "anon discuss + contribute off-limits"
vertical.
A sweep-the-edges hardening release. The v0.3.0 release hid the write
affordances from anonymous viewers; v0.5.0 added the PR-less discussion
surface with its own write gate. v0.6.0 audits both: every write-shaped
endpoint refuses anonymous callers with 401 (or 403 when the role check
runs after the auth check), and every anonymous-read surface stays
reachable.
This test is the regression net for the audit. It walks each module's
representative write endpoint as an anonymous client and asserts the
401/403, then walks the same surfaces' representative read endpoints
as anonymous and asserts the 200. The intent is breadth over depth:
one assertion per write endpoint family is enough to catch a
regression where someone strips the `auth.require_contributor` line.
Endpoints covered (one or two from each module):
- api.py: propose, decline (admin), withdraw,
funder credentials POST/DELETE, funder consent
POST/DELETE
- api_branches.py: promote-to-branch, start-edit-branch, metadata,
manual-flush, visibility, grants POST/DELETE,
threads POST, thread messages POST, resolve,
chat-seen, change accept/decline/reask
- api_prs.py: pr-draft, open-pr, seen, review, merge, withdraw,
description, resolution-branch
- api_discussion.py: thread create, message post, resolve
- api_admin.py: role POST, mute POST, allowlist POST/DELETE
- api_notifications.py: prefs POST, watch POST, mark-read POST,
quiet-hours POST, user-mute POST/DELETE
- api_graduation.py: graduate POST, claim POST, progress GET
The §15.7 reads (`/api/notifications`, `/api/watches`,
`/api/users/me/*`) are per-user surfaces they require an
authenticated viewer by definition; an anonymous 401 on those reads is
shape-correct, not a regression. The test does not assert reads on
those.
"""
from __future__ import annotations
import pytest
# Reuse the fixture / session / fake-Gitea harness from Slice 1.
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_anonymous_can_read_every_public_surface(app_with_fake_gitea):
"""Per §14 / the v0.3.0 anonymous-read contract: the catalog, the
RFC view, the PR-less discussion surface, the philosophy page, and
the health probe must remain reachable for unauthenticated viewers.
This is the read side of the item #4 contract — the read surfaces
must NOT regress to require auth as the write gates tighten.
"""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# No session cookie — viewer is anonymous.
client.cookies.clear()
# The five read surfaces an anonymous viewer must reach.
assert client.get("/api/health").status_code == 200
assert client.get("/api/philosophy").status_code == 200
assert client.get("/api/auth/me").status_code == 200
assert client.get("/api/rfcs").status_code == 200
assert client.get("/api/rfcs/ohm").status_code == 200
assert client.get("/api/rfcs/ohm/main").status_code == 200
assert client.get("/api/rfcs/ohm/discussion/threads").status_code == 200
assert client.get("/api/proposals").status_code == 200
def test_anonymous_propose_refused(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
r = client.post(
"/api/rfcs/propose",
json={"title": "X", "slug": "x", "pitch": "p", "tags": []},
)
assert r.status_code == 401
def test_anonymous_proposal_admin_paths_refused(app_with_fake_gitea):
"""The admin-gated proposal actions — merge, decline — must refuse
anonymous callers with 401 (the auth check runs before the role
check; both refusals are correct, but 401 is the structural signal
"no session at all")."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
# PR number doesn't need to exist — the gate runs first.
assert client.post("/api/proposals/1/merge").status_code == 401
assert (
client.post("/api/proposals/1/decline", json={"comment": "no"}).status_code
== 401
)
assert client.post("/api/proposals/1/withdraw").status_code == 401
def test_anonymous_branch_writes_refused_on_active_rfc(app_with_fake_gitea):
"""Branch-scoped writes on an active RFC: promote-to-branch,
manual-flush, visibility, grants, threads create, message post,
resolve, chat-seen, change accept/decline/reask. All must 401 for
anonymous callers."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
client.cookies.clear()
# Branch-scoped writes — slug + branch values are placeholders;
# the auth gate runs before any state lookup.
slug = "ohm"
branch = "feature-x"
assert (
client.post(
f"/api/rfcs/{slug}/branches/main/promote-to-branch",
json={},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/manual-flush",
json={"new_content": "hi", "paragraph_count": 1},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/visibility",
json={"read_public": False},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/grants",
json={"grantee_gitea_login": "alice"},
).status_code == 401
)
assert (
client.delete(
f"/api/rfcs/{slug}/branches/{branch}/grants/alice",
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/threads",
json={"thread_kind": "chat", "anchor_kind": "whole-doc"},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/threads/1/messages",
json={"text": "hi"},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/threads/1/resolve",
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/chat-seen",
json={"last_seen_message_id": 1},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/changes/1/accept",
json={"proposed": "x"},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/changes/1/decline",
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/changes/1/reask",
).status_code == 401
)
# Chat stream — POST shaped, same auth gate.
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/threads/1/chat",
json={"text": "hi"},
).status_code == 401
)
def test_anonymous_super_draft_writes_refused(app_with_fake_gitea):
"""Super-draft-scoped writes: start-edit-branch and metadata. The
PR open / merge paths share the gate via api_prs.py see the
PR-flow test below for those."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
assert (
client.post(
"/api/rfcs/anything/start-edit-branch", json={}
).status_code == 401
)
assert (
client.post(
"/api/rfcs/anything/metadata", json={"title": "x"}
).status_code == 401
)
def test_anonymous_pr_flow_writes_refused(app_with_fake_gitea):
"""All §10 PR-flow writes — open, merge, withdraw, description,
review, seen, pr-draft, resolution-branch must 401 for anonymous."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
client.cookies.clear()
slug, branch, pr = "ohm", "feature-x", 1
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/pr-draft"
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/open-pr",
json={"title": "t", "description": "d"},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/seen",
json={"last_seen_message_id": 1},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/review",
json={"text": "x", "anchor_payload": {}},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/merge"
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/withdraw"
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/description",
json={"title": "t", "description": "d"},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/resolution-branch"
).status_code == 401
)
def test_anonymous_discussion_writes_refused(app_with_fake_gitea):
"""The v0.5.0 PR-less discussion surface — write gates must hold.
This duplicates the assertion in `test_discussion_vertical.py` and
keeps it here too as the canonical home for the item #4 audit."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
client.cookies.clear()
assert (
client.post(
"/api/rfcs/ohm/discussion/threads",
json={"message": "drive-by"},
).status_code == 401
)
assert (
client.post(
"/api/rfcs/ohm/discussion/threads/1/messages",
json={"text": "drive-by"},
).status_code == 401
)
assert (
client.post(
"/api/rfcs/ohm/discussion/threads/1/resolve"
).status_code == 401
)
def test_anonymous_admin_writes_refused(app_with_fake_gitea):
"""Admin surfaces — role, mute, allowlist — refuse anonymous.
The auth check runs before the require_admin role check, so the
response is 401."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
assert (
client.post(
"/api/admin/users/1/role", json={"role": "admin"}
).status_code == 401
)
assert (
client.post(
"/api/admin/users/1/mute", json={"muted": True}
).status_code == 401
)
assert (
client.post(
"/api/admin/allowlist", json={"email": "x@y.z"}
).status_code == 401
)
assert (
client.delete("/api/admin/allowlist/x@y.z").status_code == 401
)
# Admin reads also gated.
assert client.get("/api/admin/users").status_code == 401
assert client.get("/api/admin/audit").status_code == 401
assert client.get("/api/admin/permission-events").status_code == 401
assert client.get("/api/admin/graduation-queue").status_code == 401
assert client.get("/api/admin/allowlist").status_code == 401
def test_anonymous_notification_writes_refused(app_with_fake_gitea):
"""Notification preference / watch / mark-read / user-mute writes —
all per-user surfaces, all require an authenticated viewer."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
client.cookies.clear()
assert (
client.post(
"/api/users/me/notification-preferences",
json={"email_personal_direct": False},
).status_code == 401
)
assert (
client.post(
"/api/users/me/quiet-hours",
json={"start": None, "end": None, "timezone": None},
).status_code == 401
)
assert (
client.post("/api/rfcs/ohm/watch", json={"state": "watching"}).status_code
== 401
)
assert client.post("/api/notifications/1/read").status_code == 401
assert (
client.post("/api/notifications/read", json={}).status_code == 401
)
assert client.post("/api/users/1/notification-mute").status_code == 401
assert client.delete("/api/users/1/notification-mute").status_code == 401
def test_anonymous_funder_writes_refused(app_with_fake_gitea):
"""§6.7 funder credential + consent writes — registering a key,
consenting to fund all refuse anonymous callers."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
client.cookies.clear()
assert (
client.post(
"/api/users/me/funder/credentials",
json={"provider": "anthropic", "api_key": "sk-test"},
).status_code == 401
)
assert (
client.delete(
"/api/users/me/funder/credentials/anthropic"
).status_code == 401
)
assert (
client.post("/api/rfcs/ohm/funder/consent").status_code == 401
)
assert (
client.delete("/api/rfcs/ohm/funder/consent").status_code == 401
)
def test_anonymous_graduation_writes_refused(app_with_fake_gitea):
"""§13 graduation: the POST kickoff and POST claim both refuse
anonymous. The progress SSE was gated to require_user in v0.6.0
(item #4) since it surfaces admin-internal step detail."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
assert (
client.post(
"/api/rfcs/anything/graduate",
json={
"rfc_id": "RFC-0001",
"repo_name": "rfc-0001-x",
"owners": ["alice"],
},
).status_code == 401
)
assert client.post("/api/rfcs/anything/claim").status_code == 401
# v0.6.0 tightening: progress SSE now requires require_user.
# No graduation is in flight, but the auth check runs first.
assert (
client.get("/api/rfcs/anything/graduate/progress").status_code == 401
)
def test_anonymous_can_read_published_pr_view(app_with_fake_gitea):
"""The PR review page is §11.3 universal-public — once a PR is
open, anonymous viewers can read it. This guards against a
regression where the read endpoint accidentally grows an auth
gate."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# Seed an open PR row directly — the cache shape is enough for
# the read endpoint; the live Gitea fetch falls back gracefully.
db.conn().execute(
"""
INSERT INTO cached_prs
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
opened_by, opened_at, head_branch, base_branch, head_sha)
VALUES ('ohm', 'rfc_branch', 'wiggleverse/rfc-0001-ohm', 7, 't', 'd',
'open', 'alice', datetime('now'), 'feature-x', 'main', 'sha7')
"""
)
client.cookies.clear()
# Anonymous read on an open PR: should be 200. The endpoint may
# surface a partial response (the FakeGitea won't have the head
# branch's RFC.md, so branch_body falls back to empty) but the
# auth gate must let the read through.
r = client.get("/api/rfcs/ohm/prs/7")
assert r.status_code == 200
body = r.json()
assert body["capabilities"]["is_anonymous"] is True
assert body["capabilities"]["can_merge"] is False
assert body["capabilities"]["can_post_review"] is False
+390
View File
@@ -0,0 +1,390 @@
"""End-to-end integration tests for v0.8.0's open beta-access request
flow (§6.1 / §14.1, roadmap item #6).
The release replaces v0.3.0's `allowed_emails` allowlist as the
admission gate. Any valid email can sign in via the v0.7.0 OTC flow;
a fresh user lands in `permission_state='pending'` until an admin
grants access. The first-OTC flow captures first name, last name,
and a free-text "why I should be included in the beta" via a new
`POST /api/auth/me/beta-request` endpoint.
The tests prove:
* A fresh OTC user lands `permission_state='pending'` with empty
profile fields, and the verify-response carries `needs_profile=true`.
* `POST /api/auth/me/beta-request` populates the three fields and
leaves the row in `pending`.
* A pending user is refused write endpoints (representative
samples: propose RFC, post discussion thread). The refusal is
403 (not 401 they're authenticated, just not granted).
* An admin-grant flow promotes pending granted. v0.8.0 doesn't
ship an admin UI for this (deferred to item #7 / v0.9.0), so
the test flips the column directly via DB and asserts that
`require_contributor` now admits the user.
* A grandfathered user (existing row pre-migration, default
`permission_state='granted'`) is unaffected write endpoints
accept them.
* The `/auth/otc/request` endpoint accepts any email the
v0.7.0 allowlist gate is gone from this path. The `allowed_emails`
table stays in the schema; the admin UI from v0.3.0 continues to
manage it for the fast-path bypass deployments may use.
"""
from __future__ import annotations
import pytest
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
def _reset_outbound():
from app import email as email_mod
email_mod.reset_sent_envelopes()
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
"""Pluck the code line from every OTC envelope in the test buffer."""
from app import email as email_mod
out = []
for env in email_mod.sent_envelopes():
if env.get("kind") != "otc":
continue
if to_address is not None and env["to"] != to_address:
continue
for line in env["body"].splitlines():
tok = line.strip()
if tok.isdigit() and len(tok) == 6:
out.append(tok)
break
return out
# ---------------------------------------------------------------------------
# Fresh OTC sign-in lands pending with empty fields
# ---------------------------------------------------------------------------
def test_fresh_otc_user_lands_pending_with_empty_profile(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Request + verify the OTC.
r = client.post("/auth/otc/request", json={"email": "newcomer@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("newcomer@example.com")[-1]
r = client.post("/auth/otc/verify", json={"email": "newcomer@example.com", "code": code})
assert r.status_code == 200, r.text
body = r.json()
# The verify response carries the new fields v0.8.0 added.
assert body["needs_profile"] is True
assert body["user"]["permission_state"] == "pending"
# The row reflects the same: pending state, no profile yet.
row = db.conn().execute(
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE email = ? COLLATE NOCASE",
("newcomer@example.com",),
).fetchone()
assert row is not None
assert row["permission_state"] == "pending"
assert row["first_name"] is None
assert row["last_name"] is None
assert row["beta_request_reason"] is None
# /api/auth/me surfaces the same shape.
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["permission_state"] == "pending"
assert me["user"]["needs_profile"] is True
assert me["user"]["first_name"] == ""
assert me["user"]["last_name"] == ""
assert me["user"]["beta_request_reason"] == ""
# ---------------------------------------------------------------------------
# beta-request endpoint captures the fields and leaves state pending
# ---------------------------------------------------------------------------
def test_beta_request_populates_fields_keeps_state_pending(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Sign in the fresh user via the full OTC flow.
client.post("/auth/otc/request", json={"email": "alice@example.com"})
code = _outbound_otc_codes("alice@example.com")[-1]
client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
# Submit the capture form.
r = client.post(
"/api/auth/me/beta-request",
json={
"first_name": "Alice",
"last_name": "Liddell",
"beta_request_reason": "I want to help write the RFCs.",
},
)
assert r.status_code == 200, r.text
# The row reflects the captured fields; state stays pending.
row = db.conn().execute(
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE email = ? COLLATE NOCASE",
("alice@example.com",),
).fetchone()
assert row["permission_state"] == "pending"
assert row["first_name"] == "Alice"
assert row["last_name"] == "Liddell"
assert row["beta_request_reason"] == "I want to help write the RFCs."
# /api/auth/me now reports needs_profile=false (fields are set).
me = client.get("/api/auth/me").json()
assert me["user"]["permission_state"] == "pending"
assert me["user"]["needs_profile"] is False
assert me["user"]["first_name"] == "Alice"
def test_beta_request_refuses_anonymous(app_with_fake_gitea):
"""The endpoint requires authentication — an anonymous caller can't
file a request without first signing in via OTC."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
r = client.post(
"/api/auth/me/beta-request",
json={"first_name": "A", "last_name": "B", "beta_request_reason": "Hi"},
)
assert r.status_code == 401
def test_beta_request_refuses_granted_user(app_with_fake_gitea):
"""A grandfathered (already granted) user has no business filing a
beta request. The endpoint refuses with 409 so the client can
distinguish the failure from "we don't know you" (401)."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="grandfathered", role="contributor")
sign_in_as(
client,
user_id=1,
gitea_login="grandfathered",
display_name="Grandfathered",
role="contributor",
)
r = client.post(
"/api/auth/me/beta-request",
json={"first_name": "G", "last_name": "F", "beta_request_reason": "x"},
)
assert r.status_code == 409
# ---------------------------------------------------------------------------
# Pending user is refused write endpoints; admin grant promotes them
# ---------------------------------------------------------------------------
def test_pending_user_is_refused_write_endpoints(app_with_fake_gitea):
"""A pending user can read everything anonymous can read, but every
write-shaped endpoint refuses with 403. The refusal shape mirrors
the v0.6.0 / item #4 audit's anon-401 — both are "no contributor
capability"; pending is the authenticated-but-ungranted variant.
Representative samples: propose RFC, post discussion thread.
"""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Sign in via fresh OTC — lands pending.
client.post("/auth/otc/request", json={"email": "pending@example.com"})
code = _outbound_otc_codes("pending@example.com")[-1]
client.post("/auth/otc/verify", json={"email": "pending@example.com", "code": code})
# Reads work — every anonymous surface stays reachable.
assert client.get("/api/health").status_code == 200
assert client.get("/api/rfcs").status_code == 200
assert client.get("/api/philosophy").status_code == 200
# Propose — write-shaped, refused with 403.
r = client.post(
"/api/rfcs/propose",
json={"title": "T", "slug": "t", "pitch": "p", "tags": []},
)
assert r.status_code == 403
# The error body mentions the review state so a UI surface can
# render the right message — but the test asserts only on the
# status code (the body shape is the FastAPI default detail).
def test_admin_grant_promotes_pending_to_granted(app_with_fake_gitea):
"""v0.8.0 doesn't ship an admin UI for this — it's deferred to
item #7 / v0.9.0. For this release, an admin gesture is an
`UPDATE users SET permission_state='granted' WHERE email=?`. The
test flips the column directly via DB and asserts the
`require_contributor` gate now admits the user.
"""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Sign in a fresh OTC user — lands pending.
client.post("/auth/otc/request", json={"email": "promoted@example.com"})
code = _outbound_otc_codes("promoted@example.com")[-1]
client.post("/auth/otc/verify", json={"email": "promoted@example.com", "code": code})
# Before the grant: propose refused with 403.
r = client.post(
"/api/rfcs/propose",
json={"title": "T", "slug": "t-pre", "pitch": "p", "tags": []},
)
assert r.status_code == 403
# The admin gesture (v0.8.0 shape — direct UPDATE; v0.9.0 will
# ship a UI). The test stamps `permission_decided_by` and
# `permission_decided_at` as the v0.9.0 admin UI will, so the
# column population exercises the schema slot. user_id=99 is
# a placeholder admin row — provision it so the FK resolves.
provision_user_row(user_id=99, login="adminuser", role="admin")
db.conn().execute(
"""
UPDATE users
SET permission_state = 'granted',
permission_decided_by = 99,
permission_decided_at = datetime('now')
WHERE email = ?
""",
("promoted@example.com",),
)
# The next request reads the fresh column from the DB. The
# propose endpoint reaches the route body now (it then refuses
# for a different reason — the slug 't-prop' will fail
# the slug-format check or hit a mock-gitea path — but the
# status code is _not_ 403/401, which is the v0.8.0 assertion).
r = client.post(
"/api/rfcs/propose",
json={"title": "Title", "slug": "tprop", "pitch": "Pitch text.", "tags": []},
)
assert r.status_code != 403, r.text
assert r.status_code != 401, r.text
def test_grandfathered_user_is_unaffected_by_migration(app_with_fake_gitea):
"""An existing `users` row at migration time has
`permission_state='granted'` via the column default. The
grandfathered user passes write endpoints without filing a
beta request and without the admin UI. v0.6.0 (anon-write
audit) is the v0.6.0 contract; v0.8.0 widens the gate but
does not break this case.
"""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=5, login="oldhand", role="contributor")
# provision_user_row uses INSERT OR REPLACE INTO users with
# the column list it knows; permission_state is not in that
# list, so it picks up the column default ('granted') on
# insert. Confirm directly.
row = db.conn().execute(
"SELECT permission_state FROM users WHERE id = 5"
).fetchone()
assert row["permission_state"] == "granted"
sign_in_as(
client,
user_id=5,
gitea_login="oldhand",
display_name="Old Hand",
role="contributor",
)
# Propose is write-shaped; the call should not refuse on
# the permission_state gate. (Subsequent failure modes —
# e.g. mock-gitea wiring — are not the v0.8.0 concern; this
# test asserts on the gate, not the propose body's success.)
r = client.post(
"/api/rfcs/propose",
json={"title": "Title", "slug": "gf-slug", "pitch": "Pitch.", "tags": []},
)
assert r.status_code != 403, r.text
assert r.status_code != 401, r.text
# ---------------------------------------------------------------------------
# /auth/otc/request accepts any email — the v0.7.0 allowlist gate is gone
# ---------------------------------------------------------------------------
def test_otc_request_accepts_any_email_regardless_of_allowlist(app_with_fake_gitea):
"""v0.7.0 silently dropped OTC requests for emails not on the
`allowed_emails` table. v0.8.0 reverses this: the request
endpoint sends a code to any valid email; admission gates at
`permission_state` post-verify instead. The `allowed_emails`
table stays in the schema as a fast-path bypass for
deployments that want to pre-mark known-good emails (the v0.9.0
admin user-management page will collapse the two surfaces).
"""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Populate the allowlist with one specific email so the v0.7.0
# gate would have engaged. v0.8.0 ignores it for the request
# path.
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("known@example.com",))
# An email NOT on the allowlist still gets a code under v0.8.0.
r = client.post("/auth/otc/request", json={"email": "stranger@example.com"})
assert r.status_code == 200
codes = _outbound_otc_codes("stranger@example.com")
assert len(codes) == 1, "OTC code must be sent regardless of allowlist state"
# The row is there and the user can complete sign-in (and will
# land in 'pending' per the other tests).
row = db.conn().execute(
"SELECT 1 FROM otc_codes WHERE email = ?",
("stranger@example.com",),
).fetchone()
assert row is not None
def test_allowlist_table_still_present_in_schema(app_with_fake_gitea):
"""The schema migration leaves the `allowed_emails` table in
place the admin UI from v0.3.0 still manages it for the
fast-path bypass deployments may use. This is a regression net
for "did the v0.8.0 cleanup accidentally drop the table"."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app):
# The table accepts inserts (i.e. it exists) — no schema check
# gymnastics needed.
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("kept@example.com",))
row = db.conn().execute(
"SELECT email FROM allowed_emails WHERE email = ?",
("kept@example.com",),
).fetchone()
assert row is not None
+349
View File
@@ -0,0 +1,349 @@
"""End-to-end integration tests for the v0.7.0 email/OTC sign-in
vertical (§6.2).
The release replaces the Gitea OAuth gesture as the primary human
sign-in path. The tests prove:
* `/auth/otc/request` is rate-limited per-email back-to-back
requests inside `OTC_REQUEST_COOLDOWN_SECONDS` are refused with
429 (the loud-failure shape the spec calls out).
* The happy path: request code lands in the outbound buffer
verify with the code session cookie surfaces an authenticated
user via `/api/auth/me`.
* Expired codes refuse with 400.
* Already-consumed codes refuse with 400 on re-use.
* Wrong codes refuse with 400.
* Allowlist gate (v0.8.0 update): v0.7.0 silently dropped requests
for emails not on `allowed_emails`. v0.8.0 (item #6) removed
that gate from the request path; the admission gate is now
`permission_state` on the freshly-provisioned `users` row,
asserted in test_beta_access_vertical.py. The tests below
confirm v0.8.0's open-request shape for both on-list and
off-list emails.
* Migration link: an existing OAuth-era user (with a `users.email`
row) is linked by email on first OTC sign-in `gitea_id` is
preserved.
* Provisioning path: an unrecognized email creates a fresh
contributor row with `gitea_id = NULL`.
The Gitea bot user + token are still required at process construction
(every test harness sets the same `GITEA_*` env vars); the OTC flow
itself never reaches Gitea. The fakes from `test_propose_vertical`
remain in scope so the rest of the app boots cleanly.
"""
from __future__ import annotations
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
tmp_env,
)
def _reset_outbound():
from app import email as email_mod
email_mod.reset_sent_envelopes()
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
"""Pluck the `code` line out of every OTC email in the test buffer.
The OTC mailer stamps `kind='otc'` on the envelope so the §15.4
notification mailer's envelopes (the unsubscribe-footer shape)
don't accidentally satisfy the assertion. Each envelope's body
carries the code on its own indented line; this helper extracts
just that token so the test reads the same way the user would
read the email.
"""
from app import email as email_mod
out = []
for env in email_mod.sent_envelopes():
if env.get("kind") != "otc":
continue
if to_address is not None and env["to"] != to_address:
continue
for line in env["body"].splitlines():
tok = line.strip()
if tok.isdigit() and len(tok) == 6:
out.append(tok)
break
return out
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
def test_otc_request_then_verify_signs_in_a_fresh_user(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Request: 202 + a single OTC envelope to the requested address.
r = client.post("/auth/otc/request", json={"email": "newcomer@example.com"})
assert r.status_code == 200, r.text
codes = _outbound_otc_codes("newcomer@example.com")
assert len(codes) == 1
code = codes[0]
# Verify: 200 + session cookie + me-shape now reads authenticated.
r = client.post("/auth/otc/verify", json={"email": "newcomer@example.com", "code": code})
assert r.status_code == 200, r.text
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["email"] == "newcomer@example.com"
# Fresh provisioning: no gitea linker. The display name is the
# local part of the email per §6.2.
assert me["user"]["role"] == "contributor"
assert me["user"]["display_name"] == "newcomer"
# The `users` row reflects the same: gitea_id NULL, email set.
from app import db
row = db.conn().execute(
"SELECT gitea_id, email FROM users WHERE email = ? COLLATE NOCASE",
("newcomer@example.com",),
).fetchone()
assert row is not None
assert row["gitea_id"] is None
assert row["email"] == "newcomer@example.com"
# ---------------------------------------------------------------------------
# Failure modes on verify
# ---------------------------------------------------------------------------
def test_otc_verify_refuses_wrong_code(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "alice@example.com"})
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": "000000"})
assert r.status_code == 400
def test_otc_verify_refuses_consumed_code(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "alice@example.com"})
code = _outbound_otc_codes("alice@example.com")[-1]
# First verify succeeds.
r1 = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
assert r1.status_code == 200
# Drop the session cookie so the re-verify reads as fresh.
client.cookies.clear()
# Second verify with the same code is refused — `consumed_at`
# stamped on the row blocks the replay.
r2 = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
assert r2.status_code == 400
def test_otc_verify_refuses_expired_code(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "alice@example.com"})
code = _outbound_otc_codes("alice@example.com")[-1]
# Backdate the row's expires_at to the past. The TTL setting is
# an env var (default 10 min); rather than waiting, the test
# rewrites the row.
db.conn().execute(
"UPDATE otc_codes SET expires_at = datetime('now', '-1 minute') WHERE email = ?",
("alice@example.com",),
)
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
assert r.status_code == 400
# ---------------------------------------------------------------------------
# Rate limiting
# ---------------------------------------------------------------------------
def test_otc_request_rate_limited_per_email(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r1 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r1.status_code == 200
# Cooldown defaults to 60s; the second back-to-back call is
# refused with a loud 429.
r2 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r2.status_code == 429
# The buffer still has exactly one envelope — the rate-limited
# call didn't double-send.
assert len(_outbound_otc_codes("alice@example.com")) == 1
def test_otc_request_cooldown_is_per_email_not_global(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r1 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r1.status_code == 200
# Different email, fresh cooldown.
r2 = client.post("/auth/otc/request", json={"email": "bob@example.com"})
assert r2.status_code == 200
# ---------------------------------------------------------------------------
# Allowlist gate — v0.8.0 update
#
# v0.7.0 gated the OTC request endpoint on the `allowed_emails` table:
# emails not on the list got a silent drop (still 202, but no code).
# v0.8.0 (roadmap item #6) reverses this: the request endpoint
# accepts any valid email and sends a code. The admission gate moves
# to `permission_state` on the freshly-provisioned `users` row,
# which the next-tier tests in test_beta_access_vertical.py cover.
# The `allowed_emails` table stays in the schema as a fast-path
# bypass for admin convenience.
# ---------------------------------------------------------------------------
def test_otc_request_admits_emails_regardless_of_allowlist_population(app_with_fake_gitea):
"""v0.8.0: the OTC request path no longer consults `allowed_emails`.
Whether the allowlist is empty or populated, every valid email
receives a code; admission gates at `permission_state` post-verify.
"""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Populate the allowlist with one specific email; the v0.7.0
# gate would have engaged here.
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("invited@example.com",))
# The not-on-list email still gets a code under v0.8.0.
r = client.post("/auth/otc/request", json={"email": "stranger@example.com"})
assert r.status_code == 200
assert len(_outbound_otc_codes("stranger@example.com")) == 1
def test_otc_request_admits_allowlisted_email(app_with_fake_gitea):
"""v0.8.0: still works for emails that happen to be on the legacy
allowlist the table is no longer consulted at request time but
populated rows are admitted alongside everyone else (since the
gate is now open at the request surface)."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("invited@example.com",))
r = client.post("/auth/otc/request", json={"email": "invited@example.com"})
assert r.status_code == 200
assert len(_outbound_otc_codes("invited@example.com")) == 1
# ---------------------------------------------------------------------------
# Migration path — link by email to an OAuth-era user
# ---------------------------------------------------------------------------
def test_otc_links_to_existing_oauth_user_by_email(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Seed an OAuth-era row. `provision_user_row` writes
# email=<login>@test, so we sign in via OTC with the matching
# email and expect the same `users.id` to come back.
provision_user_row(user_id=42, login="legacyuser", role="contributor")
existing = db.conn().execute(
"SELECT id, gitea_id FROM users WHERE id = ?", (42,)
).fetchone()
assert existing["gitea_id"] == 42 # OAuth linker is set.
r = client.post("/auth/otc/request", json={"email": "legacyuser@test"})
assert r.status_code == 200
code = _outbound_otc_codes("legacyuser@test")[-1]
r = client.post("/auth/otc/verify", json={"email": "legacyuser@test", "code": code})
assert r.status_code == 200
# /api/auth/me reports the linked user — same id, original role.
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["id"] == 42
assert me["user"]["role"] == "contributor"
# gitea_id is preserved on the linked row — the migration path
# doesn't disturb the OAuth linker.
row = db.conn().execute(
"SELECT gitea_id FROM users WHERE id = ?", (42,)
).fetchone()
assert row["gitea_id"] == 42
def test_otc_provisions_fresh_user_when_email_matches_no_one(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post("/auth/otc/request", json={"email": "newperson@example.com"})
assert r.status_code == 200
code = _outbound_otc_codes("newperson@example.com")[-1]
r = client.post("/auth/otc/verify", json={"email": "newperson@example.com", "code": code})
assert r.status_code == 200
# A fresh row landed with NULL gitea_id (no OAuth linker).
row = db.conn().execute(
"SELECT id, gitea_id, gitea_login, role FROM users WHERE email = ? COLLATE NOCASE",
("newperson@example.com",),
).fetchone()
assert row is not None
assert row["gitea_id"] is None
assert row["gitea_login"] is None
assert row["role"] == "contributor"
# ---------------------------------------------------------------------------
# Re-request invalidates prior code
# ---------------------------------------------------------------------------
def test_otc_re_request_invalidates_prior_unused_code(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
# Drop the cooldown so the second request lands instead of 429ing.
monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0")
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "alice@example.com"})
first = _outbound_otc_codes("alice@example.com")[-1]
client.post("/auth/otc/request", json={"email": "alice@example.com"})
second = _outbound_otc_codes("alice@example.com")[-1]
assert first != second
# The old code is invalidated — verify with `first` now refuses.
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": first})
assert r.status_code == 400
# The new code still works.
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": second})
assert r.status_code == 200
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rfc-app-frontend",
"version": "0.13.0",
"version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
"version": "0.13.0",
"version": "0.8.0",
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.13.0",
"version": "0.8.0",
"type": "module",
"scripts": {
"dev": "vite",
+119
View File
@@ -352,6 +352,109 @@
}
.landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; }
/* --- v0.7.0: email + one-time-code sign-in (§6.2) --- */
.otc-login {
flex: 1;
display: flex; align-items: center; justify-content: center;
padding: 40px 24px;
}
.otc-login-inner {
max-width: 360px;
width: 100%;
display: flex; flex-direction: column;
gap: 14px;
}
.otc-login h1 {
font-size: 22px;
font-weight: 600;
margin: 0 0 4px;
}
.otc-login .otc-hint {
color: #555;
font-size: 14px;
line-height: 1.5;
margin: 0;
}
.otc-login input {
width: 100%;
padding: 10px 12px;
font-size: 15px;
border: 1px solid #ddd;
border-radius: 6px;
box-sizing: border-box;
}
.otc-login input:focus {
outline: none;
border-color: #1a1a1a;
}
.otc-login button[type="submit"] {
background: #1a1a1a; color: #fff;
border: none; border-radius: 6px;
padding: 10px 18px;
font-size: 14px; font-weight: 600;
cursor: pointer;
}
.otc-login button[type="submit"]:hover:not(:disabled) { background: #333; }
.otc-login button[type="submit"]:disabled { opacity: 0.5; cursor: not-allowed; }
.otc-login form {
display: flex; flex-direction: column;
gap: 10px;
}
.otc-actions {
display: flex; align-items: center; gap: 12px;
}
.otc-login .btn-link-quiet {
background: none; border: none;
color: #666; font-size: 13px;
cursor: pointer; padding: 0;
}
.otc-login .btn-link-quiet:hover { color: #1a1a1a; text-decoration: underline; }
/* v0.8.0 — labels + textarea for the first-OTC profile capture step. */
.otc-field-label {
font-size: 12px; color: #666;
margin: 8px 0 -4px;
font-weight: 600;
}
.otc-login textarea {
width: 100%;
padding: 10px 12px;
font-size: 15px;
border: 1px solid #ddd;
border-radius: 6px;
box-sizing: border-box;
font-family: inherit;
resize: vertical;
}
.otc-login textarea:focus {
outline: none;
border-color: #1a1a1a;
}
.otc-shortcut-hint {
color: #888; font-size: 12px; margin: 4px 0 0;
}
.otc-shortcut-hint kbd {
background: #f0f0ee; border: 1px solid #ddd; border-radius: 3px;
padding: 1px 5px; font-size: 11px; font-family: inherit;
}
.otc-status {
color: #555; font-size: 13px;
background: #f7f6f0;
border-left: 3px solid #cfc8a8;
padding: 8px 12px;
margin: 4px 0 0;
}
.otc-fallback {
font-size: 12px; color: #777;
margin: 16px 0 0;
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
}
.otc-fallback a, .otc-fallback .otc-fallback-link {
color: #666; text-decoration: none;
}
.otc-fallback a:hover { color: #1a1a1a; text-decoration: underline; }
.otc-fallback-sep { color: #ccc; }
/* --- Beta-pending page (post-OAuth-rejection) --- */
.beta-pending {
@@ -383,6 +486,22 @@
.btn-link-quiet { color: #666; text-decoration: none; font-size: 13px; }
.btn-link-quiet:hover { color: #1a1a1a; text-decoration: underline; }
/* v0.8.0 thin "your beta access is in review" banner. Shown on every
page (other than /beta-pending itself, which carries the larger
form of the message). Sits just under the app header so it doesn't
compete with the catalog rail. */
.pending-access-banner {
background: #fff8e0;
border-bottom: 1px solid #e6dca0;
color: #4a3f00;
font-size: 13px;
padding: 8px 16px;
text-align: center;
}
.pending-access-banner a {
color: #4a3f00; text-decoration: underline;
}
/* ── §8 RFC view: three-column shape ─────────────────────────────────── */
.main-pane {
+33 -7
View File
@@ -8,6 +8,7 @@ import PRView from './components/PRView.jsx'
import ProposalView from './components/ProposalView.jsx'
import ProposeModal from './components/ProposeModal.jsx'
import Landing from './components/Landing.jsx'
import Login from './components/Login.jsx'
import BetaPending from './components/BetaPending.jsx'
import Philosophy from './components/Philosophy.jsx'
import NotificationSettings from './components/NotificationSettings.jsx'
@@ -86,11 +87,15 @@ export default function App() {
// The deployment is in private beta: anonymous visitors get the full
// app in read-only mode (viewer = null is passed through to every
// component), and write affordances are hidden at the component
// level. /beta-pending is the post-OAuth-rejection page reachable by
// anyone. The original §14.1 Landing surface is retained for the
// `/welcome` URL only, in case a deployment wants to link to it.
// level. v0.8.0 (§6.1 / item #6): authenticated users with
// `permission_state='pending'` also pass through as `viewer` with
// their state attached every write-gated affordance reads the
// state and treats pending the same as anonymous, while reads
// remain open. The /beta-pending page is the home root for a
// pending user.
const viewer = me?.authenticated ? me.user : null
const isAdmin = viewer && (viewer.role === 'owner' || viewer.role === 'admin')
const isPending = viewer && viewer.permission_state === 'pending'
return (
<div className="app">
@@ -135,16 +140,18 @@ export default function App() {
<a className="btn-link" href="/auth/logout">Sign out</a>
</>
) : (
<a className="btn-signin-header" href="/auth/login" title="Private beta — only invited emails can sign in">
<Link className="btn-signin-header" to="/login" title="Private beta — only invited emails can sign in">
Sign in <span className="beta-chip">Beta</span>
</a>
</Link>
)}
</div>
</header>
{isPending && <PendingAccessBanner />}
<div className="app-body">
<Routes>
<Route path="/welcome" element={<Landing />} />
<Route path="/beta-pending" element={<BetaPending />} />
<Route path="/login" element={<Login />} />
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
Available to anonymous and authenticated viewers alike. */}
@@ -230,7 +237,26 @@ function AdminWithSidebar({ viewer }) {
)
}
function PendingAccessBanner() {
// v0.8.0 thin banner shown on every page (other than /beta-pending
// itself, which carries the same message in larger form) when the
// signed-in user's `permission_state='pending'`. Sign-out works
// normally via the header affordance.
return (
<div className="pending-access-banner">
Your beta access request is in review.{' '}
<Link to="/beta-pending">Learn more </Link>
</div>
)
}
function Welcome({ viewer }) {
// v0.8.0 a pending user landing on "/" gets the same page they'd
// see at /beta-pending, inline. This is the post-OTC home root for
// a user awaiting admin grant.
if (viewer && viewer.permission_state === 'pending') {
return <BetaPending viewer={viewer} />
}
if (!viewer) {
return (
<div className="welcome">
@@ -242,7 +268,7 @@ function Welcome({ viewer }) {
</p>
<p>
Discussion and contribution are in private <strong>Beta</strong>
read freely, and <a href="/auth/login">sign in</a> if your email has
read freely, and <Link to="/login">sign in</Link> if your email has
been invited.
</p>
<p>
+40
View File
@@ -25,6 +25,46 @@ export async function getMe() {
return jsonOrThrow(res)
}
// ── v0.7.0: email + one-time-code sign-in (§6.2) ─────────────────────────
//
// The legacy /auth/login → /auth/callback OAuth flow remains during the
// migration — the new UI just no longer points at it primarily. These
// two helpers drive the Login.jsx surface.
export async function requestOtc(email) {
const res = await fetch('/auth/otc/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
return jsonOrThrow(res)
}
export async function verifyOtc(email, code) {
const res = await fetch('/auth/otc/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, code }),
})
return jsonOrThrow(res)
}
// ── v0.8.0: open beta-access request flow (§6.1 / §14.1) ─────────────────
//
// On the first OTC sign-in, the user lands in `permission_state='pending'`
// and `/api/auth/me` reports `needs_profile=true`. The Login.jsx surface
// then prompts for first/last/why and POSTs them here. After this lands,
// the user sees the /beta-pending page until an admin grants access.
export async function submitBetaRequest({ first_name, last_name, beta_request_reason }) {
const res = await fetch('/api/auth/me/beta-request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ first_name, last_name, beta_request_reason }),
})
return jsonOrThrow(res)
}
export async function listRFCs() {
return jsonOrThrow(await fetch('/api/rfcs'))
}
+40 -19
View File
@@ -1,38 +1,59 @@
// BetaPending.jsx the post-OAuth-rejection page.
// BetaPending.jsx the "your request is in review" page (§6.1 / §14.1).
//
// When a deployment is in private-beta mode (i.e. its `allowed_emails`
// table has any rows), the OAuth callback redirects unrecognised users
// here instead of provisioning them. The framework cannot know the
// deployment operator's preferred contact channel so the deployment
// supplies one via VITE_BETA_CONTACT (an email, URL, or short
// instruction). If unset, we render a generic ask-the-operator line.
// v0.3.0 introduced this surface as the post-OAuth-rejection page (a
// user whose email wasn't on the `allowed_emails` table bounced here).
// v0.8.0 (roadmap item #6) repurposes it as the post-OTC pending-grant
// page: any authenticated user whose `permission_state='pending'` lands
// here on root visits, after a fresh-OTC profile capture, or via the
// header "Your beta access is in review" affordance.
//
// The deployment supplies a contact channel via VITE_BETA_CONTACT (an
// email, URL, or short instruction). If unset, we render a generic
// ask-the-operator line.
import { Link } from 'react-router-dom'
export default function BetaPending() {
export default function BetaPending({ viewer }) {
const contact = import.meta.env.VITE_BETA_CONTACT || ''
const isPending = viewer?.permission_state === 'pending'
return (
<div className="beta-pending">
<div className="beta-pending-inner">
<h1>{import.meta.env.VITE_APP_NAME} is in private Beta.</h1>
<p>
Discussion and contribution are gated to invited emails for now.
Reading is open every super-draft, every active RFC, and every
public conversation is visible without signing in.
</p>
<h1>
{isPending
? 'Your request is in review.'
: `${import.meta.env.VITE_APP_NAME} is in private Beta.`}
</h1>
{isPending ? (
<>
<p>
Thanks for telling us a bit about yourself. An admin will
review your request and get back to you as soon as we can.
</p>
<p>
While you wait, the catalog on the left lists every super-draft
and active RFC in the framework reading is open. Discussion
and contribution unlock once your access is granted.
</p>
</>
) : (
<p>
Discussion and contribution are gated to invited contributors for
now. Reading is open every super-draft, every active RFC, and
every public conversation is visible without signing in.
</p>
)}
{contact ? (
<p className="beta-pending-contact">
To request access, contact <strong>{contact}</strong> with the
email address you'd like to sign in with.
Questions? Contact <strong>{contact}</strong>.
</p>
) : (
<p className="beta-pending-contact">
To request access, contact the deployment operator with the email
address you'd like to sign in with.
Questions? Contact the deployment operator.
</p>
)}
<div className="beta-pending-actions">
<Link className="btn-primary" to="/">Browse as a guest</Link>
<Link className="btn-primary" to="/">Browse the catalog</Link>
<Link className="btn-link-quiet" to="/philosophy">Read the philosophy </Link>
</div>
</div>
+1 -1
View File
@@ -28,7 +28,7 @@ export default function Landing() {
first RFC defining <em>human</em>. Build the dictionary first.
</p>
<a className="btn-signin" href="/auth/login">Sign in with Gitea</a>
<Link className="btn-signin" to="/login">Sign in</Link>
<Link className="secondary-link" to="/philosophy">Read the full philosophy </Link>
<ul className="landing-deck">
+275
View File
@@ -0,0 +1,275 @@
// Login.jsx v0.7.0's primary sign-in surface (§6.2), extended by
// v0.8.0 (§6.1 / §14.1, roadmap item #6) with the first-OTC profile
// capture step.
//
// Three-step (the third is conditional):
// 1. Enter email POST /auth/otc/request on 200, advance.
// On 429 (rate-limit), surface a "wait a moment" hint and keep
// the user on step 1.
// 2. Enter the six-digit code from the email POST /auth/otc/verify
// on 200, the response body carries `needs_profile`:
// * needs_profile=false (returning user, OAuth-era grandfather,
// or already-captured pending user): redirect to "/".
// * needs_profile=true (fresh OTC sign-in, no profile fields
// yet): advance to step 3.
// Cmd/Ctrl+Enter on the code field is the keyboard shortcut.
// 3. First name, last name, and "why I should be included in the
// beta" POST /api/auth/me/beta-request redirect to
// /beta-pending. The user's row stays `permission_state='pending'`
// until an admin grants access.
//
// Server-side, /auth/otc/request returns 202 uniformly so abuse paths
// (e.g. distributed allowlist-probing) don't leak the recognized-email
// set. This surface never distinguishes "we couldn't reach you" from
// "we don't know you" it just advances to step 2. If a request was
// rate-limited, the user sees a 429 hint and stays on step 1.
//
// The legacy Gitea OAuth callback remains at /auth/login /auth/callback
// during the migration; we surface a "Sign in with Gitea" link as a
// fallback in the footer so users with active OAuth sessions or older
// invite emails still have a path.
import { useEffect, useRef, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { requestOtc, verifyOtc, submitBetaRequest } from '../api'
export default function Login() {
const [step, setStep] = useState('email')
const [email, setEmail] = useState('')
const [code, setCode] = useState('')
// v0.8.0 step 3 capture fields.
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [reason, setReason] = useState('')
const [status, setStatus] = useState('')
const [busy, setBusy] = useState(false)
const emailRef = useRef(null)
const codeRef = useRef(null)
const firstNameRef = useRef(null)
const navigate = useNavigate()
useEffect(() => {
if (step === 'email') emailRef.current?.focus()
else if (step === 'code') codeRef.current?.focus()
else if (step === 'profile') firstNameRef.current?.focus()
}, [step])
async function submitEmail(e) {
e.preventDefault()
if (!email.trim() || !email.includes('@')) {
setStatus('Enter a valid email address.')
return
}
setBusy(true)
setStatus('')
try {
await requestOtc(email.trim())
setStep('code')
setStatus('Check your inbox — a six-digit code is on the way.')
} catch (err) {
if (err.status === 429) {
setStatus('Slow down — wait a minute before requesting another code.')
} else {
setStatus(err.message || 'Could not request a code. Try again.')
}
} finally {
setBusy(false)
}
}
async function submitCode(e) {
if (e) e.preventDefault()
if (!code.trim() || code.trim().length !== 6) {
setStatus('Enter the six-digit code from your email.')
return
}
setBusy(true)
setStatus('')
try {
const result = await verifyOtc(email.trim(), code.trim())
// v0.8.0 a fresh OTC user lands in `permission_state='pending'`
// with no profile fields. The verify response now carries a
// `needs_profile` flag the server stamped from the row state;
// surface the capture form here instead of jumping straight to
// "/". The fallback path (no flag, e.g. an older backend
// before the migration ran) jumps to "/" as before.
if (result?.needs_profile) {
setStep('profile')
setStatus('')
setBusy(false)
return
}
// Reload so App.jsx's getMe() picks up the fresh session. We
// navigate to "/" via a hard load so any cached "anonymous"
// view state in memory is dropped cleanly.
window.location.assign('/')
} catch (err) {
setStatus('That code is invalid or expired. Try again, or request a new code.')
setBusy(false)
}
}
async function submitProfile(e) {
if (e) e.preventDefault()
const fn = firstName.trim()
const ln = lastName.trim()
const why = reason.trim()
if (!fn || !ln || !why) {
setStatus('All three fields are required.')
return
}
setBusy(true)
setStatus('')
try {
await submitBetaRequest({
first_name: fn,
last_name: ln,
beta_request_reason: why,
})
// The user is still `permission_state='pending'`; bounce them
// to /beta-pending so the next thing they see is the
// "your request is in review" page. Hard-load so App.jsx
// re-fetches /api/auth/me and picks up the captured fields.
window.location.assign('/beta-pending')
} catch (err) {
setStatus(err.message || 'Could not submit your request. Try again.')
setBusy(false)
}
}
function onCodeKey(e) {
// §6.2 ergonomic: Cmd/Ctrl+Enter submits from the code field.
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
submitCode(e)
}
}
function backToEmail() {
setStep('email')
setCode('')
setStatus('')
}
return (
<div className="otc-login">
<div className="otc-login-inner">
<h1>Sign in</h1>
{step === 'email' && (
<form onSubmit={submitEmail}>
<p className="otc-hint">
Enter your email. We'll send you a one-time code.
</p>
<input
ref={emailRef}
type="email"
autoComplete="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="you@example.com"
required
disabled={busy}
/>
<button type="submit" disabled={busy || !email.trim()}>
{busy ? 'Sending…' : 'Send code'}
</button>
</form>
)}
{step === 'code' && (
<form onSubmit={submitCode}>
<p className="otc-hint">
Enter the six-digit code we sent to <strong>{email}</strong>.
</p>
<input
ref={codeRef}
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="one-time-code"
maxLength={6}
value={code}
onChange={e => setCode(e.target.value.replace(/\D/g, ''))}
onKeyDown={onCodeKey}
placeholder="123456"
required
disabled={busy}
/>
<div className="otc-actions">
<button type="submit" disabled={busy || code.length !== 6}>
{busy ? 'Signing in…' : 'Sign in'}
</button>
<button
type="button"
className="btn-link-quiet"
onClick={backToEmail}
disabled={busy}
>
Use a different email
</button>
</div>
<p className="otc-shortcut-hint">
Tip: <kbd></kbd>+<kbd>Enter</kbd> (or <kbd>Ctrl</kbd>+<kbd>Enter</kbd>) to sign in.
</p>
</form>
)}
{step === 'profile' && (
<form onSubmit={submitProfile}>
<p className="otc-hint">
You're signed in. {import.meta.env.VITE_APP_NAME} is in private
beta tell us a bit about yourself and an admin will review
your request.
</p>
<label className="otc-field-label">First name</label>
<input
ref={firstNameRef}
type="text"
autoComplete="given-name"
value={firstName}
onChange={e => setFirstName(e.target.value)}
required
disabled={busy}
maxLength={120}
/>
<label className="otc-field-label">Last name</label>
<input
type="text"
autoComplete="family-name"
value={lastName}
onChange={e => setLastName(e.target.value)}
required
disabled={busy}
maxLength={120}
/>
<label className="otc-field-label">
Why you'd like to be included in the beta
</label>
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
required
disabled={busy}
rows={5}
maxLength={4000}
placeholder="A sentence or two is plenty."
/>
<div className="otc-actions">
<button
type="submit"
disabled={busy || !firstName.trim() || !lastName.trim() || !reason.trim()}
>
{busy ? 'Submitting…' : 'Submit request'}
</button>
</div>
</form>
)}
{status && <p className="otc-status">{status}</p>}
{step !== 'profile' && (
<p className="otc-fallback">
<Link to="/philosophy">Read the philosophy </Link>
<span className="otc-fallback-sep">·</span>
<a href="/auth/login">Sign in with Gitea (fallback)</a>
</p>
)}
</div>
</div>
)
}