Release 0.3.0: private-beta gate + anonymous read mode

Adds an email allowlist (toggleable per deployment) that restricts
OAuth sign-in to listed emails while keeping read paths public.
Anonymous visitors now see the full app shell in read-only mode
instead of the §14.1 landing wall. Empty allowlist = gate off, so
deployments that don't enable it behave exactly as 0.2.3.

Also fixes single-finger scroll on /philosophy and other .chrome-pane
views on iOS Safari (.app: 100vh → 100dvh).

Renames deploy/nginx/rfc.wiggleverse.org.conf →
ohm.wiggleverse.org.conf to match the deployed-domain rename
(rfc.wiggleverse.org deprovisioned 2026-05-27).

See CHANGELOG.md for full details + upgrade steps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-27 20:58:58 -07:00
parent 1a9374aa52
commit 21fcbc92d4
19 changed files with 715 additions and 87 deletions
+102
View File
@@ -50,6 +50,11 @@ class MuteBody(BaseModel):
muted: bool
class AllowlistAddBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
note: str | None = Field(default=None, max_length=200)
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
@@ -385,6 +390,103 @@ def make_router(config: Config) -> APIRouter:
]
}
# ----- Private-beta allowlist (`migrations/011_allowlist.sql`) -----
@router.get("/api/admin/allowlist")
async def list_allowlist(request: Request) -> dict[str, Any]:
auth.require_admin(request)
rows = db.conn().execute(
"""
SELECT a.email, a.note, a.created_at,
u.gitea_login AS added_by_login,
u.display_name AS added_by_display
FROM allowed_emails a
LEFT JOIN users u ON u.id = a.added_by_user_id
ORDER BY a.created_at DESC
"""
).fetchall()
return {
"active": len(rows) > 0,
"items": [
{
"email": r["email"],
"note": r["note"] or "",
"added_by_login": r["added_by_login"],
"added_by_display": r["added_by_display"],
"created_at": r["created_at"],
}
for r in rows
],
}
@router.post("/api/admin/allowlist")
async def add_allowlist(body: AllowlistAddBody, request: Request) -> dict[str, Any]:
viewer = auth.require_admin(request)
email = body.email.strip()
if "@" not in email or len(email.split("@")[-1]) < 2:
raise HTTPException(422, "Email looks malformed")
existing = db.conn().execute(
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
).fetchone()
if existing is not None:
raise HTTPException(409, "Email already on the allowlist")
db.conn().execute(
"""
INSERT INTO allowed_emails (email, added_by_user_id, note)
VALUES (?, ?, ?)
""",
(email, viewer.user_id, body.note),
)
# Audit trail: when the email already maps to a known user, emit a
# permission_events row so §6.5's log stays the single place to
# look for "who let this person in." For brand-new emails the
# allowed_emails row itself carries (added_by_user_id, created_at)
# which is sufficient until the user actually signs in.
subject = db.conn().execute(
"SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1", (email,)
).fetchone()
if subject is not None:
db.conn().execute(
"""
INSERT INTO permission_events
(actor_user_id, subject_user_id, event_kind, details)
VALUES (?, ?, 'allowlist_added', ?)
""",
(
viewer.user_id,
subject["id"],
json.dumps({"email": email, "note": body.note or ""}),
),
)
return {"ok": True, "email": email}
@router.delete("/api/admin/allowlist/{email}")
async def remove_allowlist(email: str, request: Request) -> dict[str, Any]:
viewer = auth.require_admin(request)
existing = db.conn().execute(
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
).fetchone()
if existing is None:
raise HTTPException(404, "Email not on the allowlist")
db.conn().execute("DELETE FROM allowed_emails WHERE email = ?", (email,))
subject = db.conn().execute(
"SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1", (email,)
).fetchone()
if subject is not None:
db.conn().execute(
"""
INSERT INTO permission_events
(actor_user_id, subject_user_id, event_kind, details)
VALUES (?, ?, 'allowlist_removed', ?)
""",
(
viewer.user_id,
subject["id"],
json.dumps({"email": email}),
),
)
return {"ok": True, "email": email}
return router