Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca8ba69acb | |||
| 8aa65014b4 | |||
| f8e797ab09 | |||
| 21743a08b1 |
+426
-2
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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 != '';
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Generated
+2
-2
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.13.0",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user