Release 0.8.0: open beta-access request flow (first/last/why)

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-28 02:25:59 -07:00
parent 8aa65014b4
commit ca8ba69acb
17 changed files with 1327 additions and 145 deletions
+30 -14
View File
@@ -13,9 +13,13 @@ sign-in path. The tests prove:
* Expired codes refuse with 400.
* Already-consumed codes refuse with 400 on re-use.
* Wrong codes refuse with 400.
* Allowlist gate: when `allowed_emails` is populated and the email
isn't on it, the response is still 202 (no leak), but no email
lands in the outbound buffer and verify finds no matching code.
* 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.
@@ -201,33 +205,45 @@ def test_otc_request_cooldown_is_per_email_not_global(app_with_fake_gitea):
# ---------------------------------------------------------------------------
# Allowlist gate
# 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_silently_drops_when_email_not_on_allowlist(app_with_fake_gitea):
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 so the gate turns on.
# 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"})
# Still 202 — the allowlist's state is not leaked to callers.
assert r.status_code == 200
# But no email was sent, and no row landed in otc_codes.
assert _outbound_otc_codes("stranger@example.com") == []
row = db.conn().execute(
"SELECT 1 FROM otc_codes WHERE email = ?",
("stranger@example.com",),
).fetchone()
assert row is None
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