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:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user