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
+75
View File
@@ -23,6 +23,81 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond release's steps in order — no A-to-B path is pre-computed beyond
that. that.
## 0.3.0 — 2026-05-27
**Minor — operator action required if a deployment wants to enable the
private-beta gate; no action required to stay open.** This release adds
an email allowlist that, when populated, restricts OAuth sign-in to the
listed emails while keeping all read paths public. Anonymous visitors
now see the full app (catalog, RFC bodies, public branch conversations)
in read-only mode instead of the §14.1 landing-page wall.
### Added
- **`allowed_emails` table** (`backend/migrations/011_allowlist.sql`).
Empty list = gate off (any successful OAuth provisions a user, as
before). Any rows present = gate on (only listed emails, plus
users already grandfathered by `gitea_id`, may sign in).
- **Admin → Allowlist tab** at `/admin/allowlist`. Add/remove emails,
see who added each row and when. Status banner shows whether the
gate is currently active.
- **`/beta-pending` page** shown after a rejected OAuth callback. Free-
text invite-contact line is configurable via the new
`VITE_BETA_CONTACT` env var (optional; falls back to a generic line).
- **Beta chips** next to the Discuss/Contribute mode toggle, the Sign
in link, and the header Sign-in button so anonymous viewers see
immediately what is gated.
- **Anonymous read mode** in the React app: the §14.1 Landing page is
preserved at `/welcome` for deployments that want to link to it, but
the default route now renders the full app shell with write
affordances hidden behind a sign-in CTA.
### Changed
- **`/auth/callback`** now consults `auth.is_allowed_sign_in()` after
fetching the Gitea profile. Rejected sign-ins clear the OAuth state
and redirect to `/beta-pending`; the session is not populated.
- **`Catalog`** receives a `viewer` prop. Anonymous viewers see "Sign
in to propose (Beta)" instead of "+ Propose New RFC".
- **`PhilosophyWithSidebar`** now reads `authenticated` from the
current viewer instead of hardcoded `true`.
### Fixed
- **Single-finger scroll on the `/philosophy` page** (and any other
`.chrome-pane`-hosted view: `/admin/*`, `/settings/notifications`)
was broken on iOS Safari. The `.app` container used `height: 100vh`,
which on iOS measures the URL-bar-hidden ("largest") viewport — so
`.app` overflowed what's actually visible. Combined with the
`body { overflow: hidden }` in `index.css`, this meant single-finger
touches on the visible area were consumed by the (blocked) page-
level scroll attempt rather than reaching the nested `.chrome-pane`
scroll. Two-finger touches bypassed the page-level layer and
one-finger then worked once the URL bar had collapsed. Switched
`.app` to `height: 100dvh` (dynamic viewport — adjusts as the URL
bar shows/hides), with `100vh` retained as a fallback for browsers
predating iOS 15.4 / Chrome 108.
### Upgrade steps (from 0.2.3)
1. The deployment **MUST** rebuild the frontend with the new
`VITE_BETA_CONTACT` env var optionally set in `frontend/.env` (it
is OK to leave it blank — the `/beta-pending` page falls back to
a generic line).
2. The deployment **MUST** restart the backend so migration
`011_allowlist.sql` runs. No data loss; the new table starts
empty, which keeps the gate off and preserves existing behavior.
3. To **enable** the private-beta gate, the deployment operator
**SHOULD** sign in once (so their `users` row exists and they
grandfather in by `gitea_id`), then open `/admin/allowlist` and
add the first invited email. The first row added turns the gate
on for any user not yet in `users`.
4. To **stay open**, do nothing — leave `allowed_emails` empty and
the deployment behaves exactly as 0.2.3.
5. The deployment **MAY** customise its `/beta-pending` contact line
by setting `VITE_BETA_CONTACT` (an email, a URL, or a short
instruction) before the frontend build. Unset is fine.
## 0.2.3 — 2026-05-26 ## 0.2.3 — 2026-05-26
**Patch — no operator action required.** Rebuild and restart per the **Patch — no operator action required.** Rebuild and restart per the
+1 -1
View File
@@ -1 +1 @@
0.2.3 0.3.0
+102
View File
@@ -50,6 +50,11 @@ class MuteBody(BaseModel):
muted: bool muted: bool
class AllowlistAddBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
note: str | None = Field(default=None, max_length=200)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Router # 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 return router
+37
View File
@@ -77,6 +77,43 @@ async def fetch_user_profile(config: Config, access_token: str) -> dict[str, Any
return resp.json() return resp.json()
def allowlist_is_active() -> bool:
"""The private-beta gate is on iff the `allowed_emails` table has any
rows. Empty list means "open" — any successful OAuth provisions a
user; first row added flips the deployment into private-beta mode.
See `migrations/011_allowlist.sql` for the reasoning.
"""
row = db.conn().execute("SELECT 1 FROM allowed_emails LIMIT 1").fetchone()
return row is not None
def is_allowed_sign_in(profile: dict[str, Any]) -> bool:
"""Decide whether a freshly-completed OAuth profile may sign in.
Three accept paths:
1. The allowlist is empty (gate off).
2. The Gitea profile's email is in `allowed_emails` (case-insensitive).
3. A `users` row already exists for this `gitea_id` — grandfather
per `migrations/011_allowlist.sql`.
"""
gitea_id = profile.get("id")
if gitea_id is not None:
existing = db.conn().execute(
"SELECT 1 FROM users WHERE gitea_id = ? LIMIT 1", (gitea_id,)
).fetchone()
if existing is not None:
return True
if not allowlist_is_active():
return True
email = (profile.get("email") or "").strip()
if not email:
return False
row = db.conn().execute(
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
).fetchone()
return row is not None
def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser: def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
"""Insert or update the users row for this Gitea profile. """Insert or update the users row for this Gitea profile.
+6
View File
@@ -107,6 +107,12 @@ def _oauth_router(config) -> APIRouter:
if not access_token: if not access_token:
raise HTTPException(400, "Token exchange failed") raise HTTPException(400, "Token exchange failed")
profile = await auth.fetch_user_profile(config, access_token) profile = await auth.fetch_user_profile(config, access_token)
if not auth.is_allowed_sign_in(profile):
# Private-beta gate: clear any partial OAuth state and bounce to
# the public /beta-pending page. The session is left empty so the
# rejected viewer continues as anonymous read-only.
request.session.pop(auth.SESSION_STATE_KEY, None)
return RedirectResponse("/beta-pending")
user = auth.provision_user(config, profile) user = auth.provision_user(config, profile)
auth.store_session(request, user) auth.store_session(request, user)
return RedirectResponse("/") return RedirectResponse("/")
+22
View File
@@ -0,0 +1,22 @@
-- Private-beta email allowlist.
--
-- The framework supports a deployment-gated sign-in mode: when this
-- table contains rows, only emails listed here (case-insensitively)
-- may sign in via OAuth. Users already provisioned in the `users`
-- table are grandfathered in by gitea_id and never re-checked against
-- this list — so the operator who allow-listed themselves, signed in
-- once, then removed their own email from the list does not lose
-- access.
--
-- An empty `allowed_emails` table is the "open" state: no allowlist
-- gate runs, and any successful OAuth sign-in provisions a new user
-- as before. This means a fresh framework install behaves exactly as
-- prior versions until the operator adds the first row, at which
-- point the gate turns on for everyone not yet in `users`.
CREATE TABLE allowed_emails (
email TEXT PRIMARY KEY COLLATE NOCASE,
added_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
+23 -17
View File
@@ -1,7 +1,7 @@
# RFC App — Deployment Reference & New-Session Prompt # RFC App — Deployment Reference & New-Session Prompt
Use this document as: Use this document as:
1. A reference for the current `rfc.wiggleverse.org` deployment 1. A reference for the current `ohm.wiggleverse.org` deployment
2. A prompt to paste into a new Claude session to deploy a new version 2. A prompt to paste into a new Claude session to deploy a new version
--- ---
@@ -36,10 +36,10 @@ For reference, the separate Gitea VM is `wiggleverse` project / `gitea` VM / 34.
| Record | Type | Value | Proxy | | Record | Type | Value | Proxy |
|--------|------|-------|-------| |--------|------|-------|-------|
| `rfc.wiggleverse.org` | A | 34.132.29.41 | DNS-only (gray cloud) | | `ohm.wiggleverse.org` | A | 34.132.29.41 | DNS-only (gray cloud) |
| `_dmarc.wiggleverse.org` | TXT | `v=DMARC1; p=none; rua=mailto:ben@wiggleverse.org` | n/a | | `_dmarc.wiggleverse.org` | TXT | `v=DMARC1; p=none; rua=mailto:ben@wiggleverse.org` | n/a |
> Note: `rfc.wiggleverse.org` uses **Let's Encrypt via certbot** directly on the VM. Keep the A record **DNS only (gray cloud)** — Cloudflare Flexible SSL would conflict with certbot. > Note: `ohm.wiggleverse.org` uses **Let's Encrypt via certbot** directly on the VM. Keep the A record **DNS only (gray cloud)** — Cloudflare Flexible SSL would conflict with certbot.
SPF (`v=spf1 include:_spf.google.com ~all`) and DKIM (`google._domainkey`) for `wiggleverse.org` are already in place via Workspace. SPF (`v=spf1 include:_spf.google.com ~all`) and DKIM (`google._domainkey`) for `wiggleverse.org` are already in place via Workspace.
@@ -64,7 +64,7 @@ SPF (`v=spf1 include:_spf.google.com ~all`) and DKIM (`google._domainkey`) for `
| `/opt/rfc-app/backend/.env` | All secrets and config (mode 0600) | | `/opt/rfc-app/backend/.env` | All secrets and config (mode 0600) |
| `/opt/rfc-app/backend/data/rfc-app.db` | SQLite database | | `/opt/rfc-app/backend/data/rfc-app.db` | SQLite database |
| `/opt/rfc-app/frontend/dist/` | Built React SPA (served by nginx) | | `/opt/rfc-app/frontend/dist/` | Built React SPA (served by nginx) |
| `/etc/nginx/sites-available/rfc.wiggleverse.org` | nginx vhost config | | `/etc/nginx/sites-available/ohm.wiggleverse.org` | nginx vhost config |
| `/etc/systemd/system/rfc-app.service` | systemd unit | | `/etc/systemd/system/rfc-app.service` | systemd unit |
--- ---
@@ -79,7 +79,7 @@ SPF (`v=spf1 include:_spf.google.com ~all`) and DKIM (`google._domainkey`) for `
## Gitea Setup (one-time) ## Gitea Setup (one-time)
These are already done for `rfc.wiggleverse.org`. Document here for replication. These are already done for `ohm.wiggleverse.org`. Document here for replication.
### Bot service account ### Bot service account
@@ -91,13 +91,13 @@ Created in Gitea as `rfc-bot`. Token scopes: `write:repository`, `write:user`, `
### Meta repo ### Meta repo
`wiggleverse/meta` — seeded by `scripts/seed_meta_repo.py`. Contains `PHILOSOPHY.md`, `README.md`, `CONTRIBUTING.md`, and `rfcs/` directory. Gitea webhook registered to `https://rfc.wiggleverse.org/api/webhooks/gitea`. `wiggleverse/meta` — seeded by `scripts/seed_meta_repo.py`. Contains `PHILOSOPHY.md`, `README.md`, `CONTRIBUTING.md`, and `rfcs/` directory. Gitea webhook registered to `https://ohm.wiggleverse.org/api/webhooks/gitea`.
### OAuth2 app ### OAuth2 app
Registered in Gitea Site Administration → Integrations → OAuth2 Applications: Registered in Gitea Site Administration → Integrations → OAuth2 Applications:
- Name: `RFC App` - Name: `RFC App`
- Redirect URI: `https://rfc.wiggleverse.org/auth/callback` - Redirect URI: `https://ohm.wiggleverse.org/auth/callback`
- Client ID and secret stored in `.env` - Client ID and secret stored in `.env`
--- ---
@@ -133,7 +133,7 @@ OAUTH_CLIENT_ID=<from Gitea OAuth app>
OAUTH_CLIENT_SECRET=<from Gitea OAuth app> OAUTH_CLIENT_SECRET=<from Gitea OAuth app>
# App # App
APP_URL=https://rfc.wiggleverse.org APP_URL=https://ohm.wiggleverse.org
SECRET_KEY=<openssl rand -hex 32> SECRET_KEY=<openssl rand -hex 32>
DATABASE_PATH=/opt/rfc-app/backend/data/rfc-app.db DATABASE_PATH=/opt/rfc-app/backend/data/rfc-app.db
OWNER_GITEA_LOGIN=ben.stull OWNER_GITEA_LOGIN=ben.stull
@@ -182,10 +182,16 @@ sudo systemctl restart rfc-app
For frontend changes, build on the VM directly (Node 20+ is already there): For frontend changes, build on the VM directly (Node 20+ is already there):
```bash ```bash
cd /opt/rfc-app/frontend && sudo -u rfc-app npm install cd /opt/rfc-app/frontend && sudo -u rfc-app npm ci
sudo -u rfc-app npm run build sudo -u rfc-app npm run build
``` ```
`npm ci` installs strictly from the committed `package-lock.json` and
will not regenerate it. Using `npm install` here causes the VM's npm
to rewrite the lockfile in place (notably stripping `libc` fields on
optional rollup native packages), which then conflicts with `git
checkout <tag>` on the next deploy.
The output lands in `/opt/rfc-app/frontend/dist/` owned by `rfc-app` — nginx serves it directly, no copy step needed. The output lands in `/opt/rfc-app/frontend/dist/` owned by `rfc-app` — nginx serves it directly, no copy step needed.
(Building locally and `gcloud compute scp`-ing the dist also works. Plain `rsync -e ssh` from the Mac fails because OS Login uses short-lived SSH certs that only the gcloud wrapper can mint interactively.) (Building locally and `gcloud compute scp`-ing the dist also works. Plain `rsync -e ssh` from the Mac fails because OS Login uses short-lived SSH certs that only the gcloud wrapper can mint interactively.)
@@ -197,7 +203,7 @@ Schema migrations run automatically on restart (append-only, safe to re-run).
## First-Time Deployment (new server) ## First-Time Deployment (new server)
### 1. Add DNS record ### 1. Add DNS record
Add `rfc.wiggleverse.org` → 34.132.29.41 as an A record in Cloudflare, **DNS only (gray cloud)**. Do not proxy — certbot needs to reach the VM directly. Add `ohm.wiggleverse.org` → 34.132.29.41 as an A record in Cloudflare, **DNS only (gray cloud)**. Do not proxy — certbot needs to reach the VM directly.
### 2. Host prep ### 2. Host prep
```bash ```bash
@@ -230,15 +236,15 @@ sudo -u rfc-app -H bash -c \
### 6. Build the frontend (on the VM) ### 6. Build the frontend (on the VM)
```bash ```bash
cd /opt/rfc-app/frontend && sudo -u rfc-app npm install cd /opt/rfc-app/frontend && sudo -u rfc-app npm ci
sudo -u rfc-app npm run build sudo -u rfc-app npm run build
``` ```
### 7. nginx ### 7. nginx
```bash ```bash
sudo cp /opt/rfc-app/deploy/nginx/rfc.wiggleverse.org.conf \ sudo cp /opt/rfc-app/deploy/nginx/ohm.wiggleverse.org.conf \
/etc/nginx/sites-available/rfc.wiggleverse.org /etc/nginx/sites-available/ohm.wiggleverse.org
sudo ln -s /etc/nginx/sites-available/rfc.wiggleverse.org \ sudo ln -s /etc/nginx/sites-available/ohm.wiggleverse.org \
/etc/nginx/sites-enabled/ /etc/nginx/sites-enabled/
sudo usermod -a -G rfc-app www-data sudo usermod -a -G rfc-app www-data
sudo chmod -R g+rX /opt/rfc-app/frontend/dist sudo chmod -R g+rX /opt/rfc-app/frontend/dist
@@ -247,7 +253,7 @@ sudo nginx -t && sudo systemctl reload nginx
### 8. Let's Encrypt ### 8. Let's Encrypt
```bash ```bash
sudo certbot --nginx -d rfc.wiggleverse.org sudo certbot --nginx -d ohm.wiggleverse.org
``` ```
### 9. systemd ### 9. systemd
@@ -259,7 +265,7 @@ sudo systemctl status rfc-app
``` ```
### 10. Smoke test ### 10. Smoke test
Visit `https://rfc.wiggleverse.org`: Visit `https://ohm.wiggleverse.org`:
1. Landing page renders with sign-in button 1. Landing page renders with sign-in button
2. Sign in with Gitea OAuth → catalog loads 2. Sign in with Gitea OAuth → catalog loads
3. `+ Propose New RFC` opens the propose modal 3. `+ Propose New RFC` opens the propose modal
@@ -301,7 +307,7 @@ Paste the following into a new Claude session to continue development:
--- ---
> I'm working on the **Wiggleverse RFC App** — a FastAPI + SQLite + React + Vite application deployed at `rfc.wiggleverse.org` on a GCP e2-small VM (`rfc-app` in the `wiggleverse-rfc` project; separate from the `gitea` VM in `wiggleverse` that runs Gitea at `git.wiggleverse.org`). The app is the primary interface for the Open Human Model (OHM) RFC working group. > I'm working on the **Wiggleverse RFC App** — a FastAPI + SQLite + React + Vite application deployed at `ohm.wiggleverse.org` on a GCP e2-small VM (`rfc-app` in the `wiggleverse-rfc` project; separate from the `gitea` VM in `wiggleverse` that runs Gitea at `git.wiggleverse.org`). The app is the primary interface for the Open Human Model (OHM) RFC working group.
> >
> **Stack:** > **Stack:**
> - Backend: Python 3.11, FastAPI, uvicorn (single process), SQLite WAL mode > - Backend: Python 3.11, FastAPI, uvicorn (single process), SQLite WAL mode
+18 -12
View File
@@ -1,6 +1,6 @@
# Runbook # Runbook
Single-host deployment of the RFC app at `rfc.wiggleverse.org`, sharing Single-host deployment of the RFC app at `ohm.wiggleverse.org`, sharing
infrastructure with `git.wiggleverse.org` (same Gitea instance, same nginx, infrastructure with `git.wiggleverse.org` (same Gitea instance, same nginx,
same Let's Encrypt). The shape matches §4.2: one process, one SQLite file, same Let's Encrypt). The shape matches §4.2: one process, one SQLite file,
no separate worker. no separate worker.
@@ -18,7 +18,7 @@ recover from a partial install is safe.
- Ubuntu/Debian-style host with nginx and certbot already serving - Ubuntu/Debian-style host with nginx and certbot already serving
`git.wiggleverse.org` over HTTPS. `git.wiggleverse.org` over HTTPS.
- DNS: an `A` record for `rfc.wiggleverse.org` pointing at the same IP as - DNS: an `A` record for `ohm.wiggleverse.org` pointing at the same IP as
`git.wiggleverse.org`. `git.wiggleverse.org`.
- Python 3.11+ available system-wide (the project has no `requires-python` - Python 3.11+ available system-wide (the project has no `requires-python`
pin; the current production VM runs 3.11 on Debian bookworm). Node 20+ pin; the current production VM runs 3.11 on Debian bookworm). Node 20+
@@ -75,7 +75,7 @@ Invite → rfc-bot → Owner**.
Integrations → OAuth2 Applications → Create Application**: Integrations → OAuth2 Applications → Create Application**:
- Name: `RFC App` - Name: `RFC App`
- Redirect URI: `https://rfc.wiggleverse.org/auth/callback` - Redirect URI: `https://ohm.wiggleverse.org/auth/callback`
Copy the client ID and client secret. They go into `.env`. Copy the client ID and client secret. They go into `.env`.
@@ -93,7 +93,7 @@ sudo -u rfc-app /opt/rfc-app/backend/.venv/bin/pip install \
```sh ```sh
# On your laptop: # On your laptop:
cd frontend && npm install && npm run build cd frontend && npm ci && npm run build
rsync -a dist/ ben.stull@<host>:/tmp/rfc-app-dist/ rsync -a dist/ ben.stull@<host>:/tmp/rfc-app-dist/
# On the host: # On the host:
sudo -u rfc-app mkdir -p /opt/rfc-app/frontend/dist sudo -u rfc-app mkdir -p /opt/rfc-app/frontend/dist
@@ -104,10 +104,16 @@ sudo chown -R rfc-app:rfc-app /opt/rfc-app/frontend/dist
Or build on the host directly if Node is installed there: Or build on the host directly if Node is installed there:
```sh ```sh
cd /opt/rfc-app/frontend && sudo -u rfc-app npm install cd /opt/rfc-app/frontend && sudo -u rfc-app npm ci
sudo -u rfc-app npm run build sudo -u rfc-app npm run build
``` ```
`npm ci` installs strictly from the committed `package-lock.json` and
refuses to mutate it. `npm install` was previously used here but can
regenerate the lockfile in place (e.g. stripping `libc` fields from
optional rollup native packages), which then collides with `git
checkout <tag>` on the next deploy.
**1.3.3 Write `.env`.** **1.3.3 Write `.env`.**
```sh ```sh
@@ -128,7 +134,7 @@ META_REPO=meta
OAUTH_CLIENT_ID=<from 1.2.3> OAUTH_CLIENT_ID=<from 1.2.3>
OAUTH_CLIENT_SECRET=<from 1.2.3> OAUTH_CLIENT_SECRET=<from 1.2.3>
APP_URL=https://rfc.wiggleverse.org APP_URL=https://ohm.wiggleverse.org
SECRET_KEY=<openssl rand -hex 32> SECRET_KEY=<openssl rand -hex 32>
OWNER_GITEA_LOGIN=ben.stull OWNER_GITEA_LOGIN=ben.stull
GITEA_WEBHOOK_SECRET=<openssl rand -hex 32> GITEA_WEBHOOK_SECRET=<openssl rand -hex 32>
@@ -182,9 +188,9 @@ Re-running is safe; every step is upsert-shaped.
**1.4.1 nginx vhost.** **1.4.1 nginx vhost.**
```sh ```sh
sudo cp /opt/rfc-app/deploy/nginx/rfc.wiggleverse.org.conf \ sudo cp /opt/rfc-app/deploy/nginx/ohm.wiggleverse.org.conf \
/etc/nginx/sites-available/rfc.wiggleverse.org /etc/nginx/sites-available/ohm.wiggleverse.org
sudo ln -s /etc/nginx/sites-available/rfc.wiggleverse.org \ sudo ln -s /etc/nginx/sites-available/ohm.wiggleverse.org \
/etc/nginx/sites-enabled/ /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx sudo nginx -t && sudo systemctl reload nginx
``` ```
@@ -200,7 +206,7 @@ sudo systemctl reload nginx
**1.4.2 Let's Encrypt cert.** **1.4.2 Let's Encrypt cert.**
```sh ```sh
sudo certbot --nginx -d rfc.wiggleverse.org sudo certbot --nginx -d ohm.wiggleverse.org
``` ```
### 1.5 systemd ### 1.5 systemd
@@ -226,7 +232,7 @@ RFC app started — meta repo wiggleverse/meta
### 1.6 Smoke test ### 1.6 Smoke test
In a browser at `https://rfc.wiggleverse.org`: In a browser at `https://ohm.wiggleverse.org`:
1. The landing page renders (§14.1 — title, pitch, three-item deck, 1. The landing page renders (§14.1 — title, pitch, three-item deck,
sign-in affordance). sign-in affordance).
@@ -384,7 +390,7 @@ say), restore from the most recent backup per §2.2.
`rfc-app`. `rfc-app`.
- **OAuth callback returns "Invalid state".** The redirect URI in Gitea - **OAuth callback returns "Invalid state".** The redirect URI in Gitea
must match `APP_URL/auth/callback` exactly. Confirm it's must match `APP_URL/auth/callback` exactly. Confirm it's
`https://rfc.wiggleverse.org/auth/callback`. `https://ohm.wiggleverse.org/auth/callback`.
- **The catalog stays empty after a merge.** Check the webhook: - **The catalog stays empty after a merge.** Check the webhook:
`journalctl -u rfc-app | grep webhook`. Gitea's **Settings → Webhooks `journalctl -u rfc-app | grep webhook`. Gitea's **Settings → Webhooks
→ Recent Deliveries** on the meta repo shows the delivery status; the → Recent Deliveries** on the meta repo shows the delivery status; the
@@ -2,21 +2,21 @@
# frontend served as static files from the Vite build output. # frontend served as static files from the Vite build output.
# #
# Install: # Install:
# sudo cp deploy/nginx/rfc.wiggleverse.org.conf \ # sudo cp deploy/nginx/ohm.wiggleverse.org.conf \
# /etc/nginx/sites-available/rfc.wiggleverse.org # /etc/nginx/sites-available/ohm.wiggleverse.org
# sudo ln -s /etc/nginx/sites-available/rfc.wiggleverse.org \ # sudo ln -s /etc/nginx/sites-available/ohm.wiggleverse.org \
# /etc/nginx/sites-enabled/ # /etc/nginx/sites-enabled/
# sudo nginx -t && sudo systemctl reload nginx # sudo nginx -t && sudo systemctl reload nginx
# #
# Then add the Let's Encrypt cert: # Then add the Let's Encrypt cert:
# sudo certbot --nginx -d rfc.wiggleverse.org # sudo certbot --nginx -d ohm.wiggleverse.org
# Certbot will rewrite this file to add the 443 listener and certificate # Certbot will rewrite this file to add the 443 listener and certificate
# directives; the rest of the config below stays as written. # directives; the rest of the config below stays as written.
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name rfc.wiggleverse.org; server_name ohm.wiggleverse.org;
# Static SPA assets live in the Vite build output. The systemd unit # Static SPA assets live in the Vite build output. The systemd unit
# runs as user `rfc-app`; make sure nginx (usually `www-data`) can # runs as user `rfc-app`; make sure nginx (usually `www-data`) can
+31 -2
View File
@@ -69,7 +69,7 @@ The shortest path from scratch:
any deployment-identifying value; if a required variable is any deployment-identifying value; if a required variable is
missing, the build fails loudly. missing, the build fails loudly.
6. **Build and run.** `cd frontend && npm install && npm run 6. **Build and run.** `cd frontend && npm ci && npm run
build`, then start the backend per `deploy/RUNBOOK.md`. The build`, then start the backend per `deploy/RUNBOOK.md`. The
first sign-in is the OWNER login from `backend/.env`. first sign-in is the OWNER login from `backend/.env`.
@@ -166,7 +166,7 @@ The mechanics in practice:
5. **Check out the framework at the target version.** `git 5. **Check out the framework at the target version.** `git
fetch && git checkout <tag>`. fetch && git checkout <tag>`.
6. **Rebuild.** `npm install && npm run build` for the frontend; 6. **Rebuild.** `npm ci && npm run build` for the frontend;
restart the backend. restart the backend.
7. **Smoke-test.** Sign in, check the brand reflects your 7. **Smoke-test.** Sign in, check the brand reflects your
@@ -240,6 +240,35 @@ The deployment repo does **not** hold:
edit a framework file, the right move is to file a change against edit a framework file, the right move is to file a change against
the framework, get a release, and pin to it. the framework, get a release, and pin to it.
## Private-beta gate
From `0.3.0` onward, every deployment ships with an opt-in email
allowlist. The default state is **off**: an empty `allowed_emails`
table behaves exactly like 0.2.x — any successful Gitea OAuth
provisions a user.
To run a closed beta:
1. Sign in once as the deployment operator so your `users` row exists
(you will be grandfathered by `gitea_id` thereafter — adding the
first allowlist row does **not** lock you out).
2. Open `/admin/allowlist` and add the first invited email. As soon as
any row exists, sign-in is restricted to listed emails plus
grandfathered users.
3. Optionally set `VITE_BETA_CONTACT` in `frontend/.env` (an email
address, a URL, or a short instruction). It is shown to rejected
sign-ins on the `/beta-pending` page so visitors know how to
request an invitation.
To re-open the deployment, remove all rows from `allowed_emails` (the
admin tab has a Remove button per row) — the gate flips off as soon
as the last row is gone.
Anonymous viewers see the full app in read-only mode regardless of
allowlist state. Write affordances (Propose, chat, Contribute, Open
PR) are hidden behind a sign-in CTA, and the public read endpoints
behave the same in both states.
## When something goes wrong ## When something goes wrong
If a framework behavior is wrong for your deployment, file it as a If a framework behavior is wrong for your deployment, file it as a
+11
View File
@@ -13,3 +13,14 @@
# VITE_APP_NAME=Wiggleverse RFC # VITE_APP_NAME=Wiggleverse RFC
# VITE_APP_NAME=Wiggleverse Open Human Model # VITE_APP_NAME=Wiggleverse Open Human Model
VITE_APP_NAME= VITE_APP_NAME=
# Optional contact line shown on the /beta-pending page when a deployment
# is in private-beta mode (i.e. the backend's `allowed_emails` table has
# rows). Free-text — an email address, a URL, or a one-line instruction
# tells visitors how to request an invitation. If unset, the page falls
# back to a generic "contact the deployment operator" line.
#
# Examples:
# VITE_BETA_CONTACT=ben@wiggleverse.org
# VITE_BETA_CONTACT=DM @ben on Matrix
VITE_BETA_CONTACT=
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "rfc-app-frontend", "name": "rfc-app-frontend",
"private": true, "private": true,
"version": "0.2.3", "version": "0.3.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+87 -1
View File
@@ -5,7 +5,16 @@
height: 100vh; color: #888; font-size: 14px; height: 100vh; color: #888; font-size: 14px;
} }
.app { height: 100vh; display: flex; flex-direction: column; } /* `100dvh` is the dynamic viewport height — adjusts as iOS Safari's
URL bar shows/hides. Without it, `100vh` measures the URL-bar-hidden
("largest") viewport, so the app overflows what's actually visible.
Combined with `body { overflow: hidden }` in index.css, the result
on iOS is that single-finger touches get consumed by the (blocked)
page-level scroll attempt and never reach the nested .chrome-pane;
two-finger touches bypass that and one-finger works thereafter.
The 100vh line stays as a fallback for browsers older than iOS
15.4 / Chrome 108 (early 2022) that don't understand dvh. */
.app { height: 100vh; height: 100dvh; display: flex; flex-direction: column; }
.app-header { .app-header {
height: 48px; flex-shrink: 0; height: 48px; flex-shrink: 0;
@@ -33,6 +42,31 @@
} }
.btn-link:hover { background: rgba(255,255,255,0.25); } .btn-link:hover { background: rgba(255,255,255,0.25); }
.btn-signin-header {
color: #fff; text-decoration: none;
background: rgba(255,255,255,0.15);
border-radius: 6px; padding: 4px 10px;
font-size: 13px;
display: inline-flex; align-items: center; gap: 6px;
}
.btn-signin-header:hover { background: rgba(255,255,255,0.25); }
/* Beta chip — small uppercase tag sitting alongside a button label or
* link. Renders well on both dark headers and light surfaces. */
.beta-chip {
font-size: 9px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em;
padding: 1px 5px; border-radius: 3px;
background: #b45309; color: #fff;
line-height: 1.5;
vertical-align: middle;
}
.btn-link .beta-chip,
.btn-mode-toggle .beta-chip,
.btn-start-contribution-header .beta-chip {
margin-left: 5px;
}
.app-body { flex: 1; display: flex; overflow: hidden; } .app-body { flex: 1; display: flex; overflow: hidden; }
/* --- Catalog (left pane, §7) --- */ /* --- Catalog (left pane, §7) --- */
@@ -318,6 +352,37 @@
} }
.landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; } .landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; }
/* --- Beta-pending page (post-OAuth-rejection) --- */
.beta-pending {
min-height: 100vh;
display: flex; align-items: center; justify-content: center;
padding: 40px 20px;
}
.beta-pending-inner {
max-width: 560px;
text-align: center;
}
.beta-pending h1 { font-size: 24px; margin: 0 0 16px; }
.beta-pending p { font-size: 15px; line-height: 1.6; color: #333; margin: 0 0 14px; }
.beta-pending-contact {
background: #fafafa; border: 1px solid #eee; border-radius: 8px;
padding: 14px 18px;
color: #444;
}
.beta-pending-actions {
margin-top: 24px;
display: flex; gap: 18px; justify-content: center; align-items: center;
}
.beta-pending-actions .btn-primary {
background: #1a1a1a; color: #fff;
border-radius: 8px; padding: 9px 18px;
font-size: 14px; font-weight: 600; text-decoration: none;
}
.beta-pending-actions .btn-primary:hover { background: #333; }
.btn-link-quiet { color: #666; text-decoration: none; font-size: 13px; }
.btn-link-quiet:hover { color: #1a1a1a; text-decoration: underline; }
/* ── §8 RFC view: three-column shape ─────────────────────────────────── */ /* ── §8 RFC view: three-column shape ─────────────────────────────────── */
.main-pane { .main-pane {
@@ -1678,6 +1743,27 @@
padding: 1px 5px; border-radius: 3px; padding: 1px 5px; border-radius: 3px;
} }
.allowlist-add {
display: flex; gap: 8px; margin-bottom: 22px; flex-wrap: wrap;
align-items: center;
}
.allowlist-add input[type="email"] {
border: 1px solid #d1d5db; border-radius: 6px;
padding: 6px 10px; font-size: 13px; min-width: 240px;
}
.allowlist-add input[type="text"] {
border: 1px solid #d1d5db; border-radius: 6px;
padding: 6px 10px; font-size: 13px; flex: 1; min-width: 200px;
}
.allowlist-add .btn-primary {
background: #1a1a1a; color: #fff;
border: none; border-radius: 6px;
padding: 6px 14px; font-size: 13px; font-weight: 600;
cursor: pointer;
}
.allowlist-add .btn-primary:hover:not(:disabled) { background: #333; }
.allowlist-add .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.user-cell { display: flex; flex-direction: column; gap: 1px; } .user-cell { display: flex; flex-direction: column; gap: 1px; }
.user-handle { font-weight: 500; color: #111; } .user-handle { font-weight: 500; color: #111; }
.mute-toggle { .mute-toggle {
+81 -42
View File
@@ -8,6 +8,7 @@ import PRView from './components/PRView.jsx'
import ProposalView from './components/ProposalView.jsx' import ProposalView from './components/ProposalView.jsx'
import ProposeModal from './components/ProposeModal.jsx' import ProposeModal from './components/ProposeModal.jsx'
import Landing from './components/Landing.jsx' import Landing from './components/Landing.jsx'
import BetaPending from './components/BetaPending.jsx'
import Philosophy from './components/Philosophy.jsx' import Philosophy from './components/Philosophy.jsx'
import NotificationSettings from './components/NotificationSettings.jsx' import NotificationSettings from './components/NotificationSettings.jsx'
import Admin from './components/Admin.jsx' import Admin from './components/Admin.jsx'
@@ -68,17 +69,14 @@ export default function App() {
return <div className="boot">Loading</div> return <div className="boot">Loading</div>
} }
// §14.2: the philosophy route is reachable by anonymous visitors too. // The deployment is in private beta: anonymous visitors get the full
// Resolve it before the authentication gate so a signed-out reader // app in read-only mode (viewer = null is passed through to every
// who follows the §14.1 landing link does not get bounced to sign-in. // component), and write affordances are hidden at the component
if (!me?.authenticated) { // level. /beta-pending is the post-OAuth-rejection page reachable by
return ( // anyone. The original §14.1 Landing surface is retained for the
<Routes> // `/welcome` URL only, in case a deployment wants to link to it.
<Route path="/philosophy" element={<Philosophy authenticated={false} />} /> const viewer = me?.authenticated ? me.user : null
<Route path="*" element={<Landing />} /> const isAdmin = viewer && (viewer.role === 'owner' || viewer.role === 'admin')
</Routes>
)
}
return ( return (
<div className="app"> <div className="app">
@@ -88,59 +86,78 @@ export default function App() {
</div> </div>
<div className="header-right"> <div className="header-right">
{/* §14.3: the persistent About link. One word, no badge, no {/* §14.3: the persistent About link. One word, no badge, no
state — visible from every authenticated screen so a state — visible from every screen so a viewer mid-PR who
contributor mid-PR who wonders why a conversation is wonders why a conversation is public can reach the answer
public can reach the answer in two clicks. */} in two clicks. Anonymous viewers see it too. */}
<Link to="/philosophy" className="header-about" title="Why this exists (§14)"> <Link to="/philosophy" className="header-about" title="Why this exists (§14)">
About About
</Link> </Link>
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)"> {viewer && (
Settings <Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
</Link> Settings
{(me.user.role === 'owner' || me.user.role === 'admin') && ( </Link>
)}
{isAdmin && (
<Link to="/admin" className="header-admin" title="Admin home base"> <Link to="/admin" className="header-admin" title="Admin home base">
Admin Admin
</Link> </Link>
)} )}
<button {viewer && (
className="inbox-trigger" <button
onClick={() => setInboxOpen(o => !o)} className="inbox-trigger"
title="Notifications inbox (§15.2)" onClick={() => setInboxOpen(o => !o)}
> title="Notifications inbox (§15.2)"
<span aria-hidden>📮</span> >
{unreadCount > 0 && ( <span aria-hidden>📮</span>
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span> {unreadCount > 0 && (
)} <span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
</button> )}
<span className="user-name">{me.user.display_name}</span> </button>
<span className={`user-role-badge role-${me.user.role}`}>{me.user.role}</span> )}
<a className="btn-link" href="/auth/logout">Sign out</a> {viewer ? (
<>
<span className="user-name">{viewer.display_name}</span>
<span className={`user-role-badge role-${viewer.role}`}>{viewer.role}</span>
<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">
Sign in <span className="beta-chip">Beta</span>
</a>
)}
</div> </div>
</header> </header>
<div className="app-body"> <div className="app-body">
<Routes> <Routes>
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={me.user} />} /> <Route path="/welcome" element={<Landing />} />
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={me.user} />} /> <Route path="/beta-pending" element={<BetaPending />} />
<Route path="/admin/*" element={<AdminWithSidebar viewer={me.user} />} /> <Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
{viewer && (
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={viewer} />} />
)}
{isAdmin && (
<Route path="/admin/*" element={<AdminWithSidebar viewer={viewer} />} />
)}
<Route path="*" element={ <Route path="*" element={
<> <>
<Catalog <Catalog
viewer={viewer}
onProposeRFC={() => setProposeOpen(true)} onProposeRFC={() => setProposeOpen(true)}
version={catalogVersion} version={catalogVersion}
/> />
<main className="main-pane"> <main className="main-pane">
<Routes> <Routes>
<Route path="/" element={<Welcome viewer={me.user} />} /> <Route path="/" element={<Welcome viewer={viewer} />} />
<Route path="/rfc/:slug" element={<RFCView viewer={me.user} />} /> <Route path="/rfc/:slug" element={<RFCView viewer={viewer} />} />
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={me.user} />} /> <Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} />
<Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} /> <Route path="/proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} />
</Routes> </Routes>
</main> </main>
</> </>
} /> } />
</Routes> </Routes>
</div> </div>
{proposeOpen && ( {proposeOpen && viewer && (
<ProposeModal <ProposeModal
onClose={() => setProposeOpen(false)} onClose={() => setProposeOpen(false)}
onSubmitted={({ pr_number }) => { onSubmitted={({ pr_number }) => {
@@ -150,7 +167,7 @@ export default function App() {
}} }}
/> />
)} )}
{inboxOpen && ( {inboxOpen && viewer && (
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} /> <Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
)} )}
<ToastHost /> <ToastHost />
@@ -158,14 +175,14 @@ export default function App() {
) )
} }
function PhilosophyWithSidebar() { function PhilosophyWithSidebar({ viewer }) {
// The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin) // The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
// all use the full app body — no catalog left pane, no propose modal. // all use the full app body — no catalog left pane, no propose modal.
// The header carries the navigation back; the body is a single // The header carries the navigation back; the body is a single
// reading surface. // reading surface.
return ( return (
<main className="chrome-pane"> <main className="chrome-pane">
<Philosophy authenticated={true} /> <Philosophy authenticated={!!viewer} />
</main> </main>
) )
} }
@@ -187,6 +204,28 @@ function AdminWithSidebar({ viewer }) {
} }
function Welcome({ viewer }) { function Welcome({ viewer }) {
if (!viewer) {
return (
<div className="welcome">
<h1>Welcome.</h1>
<p>
The catalog on the left lists every super-draft and active RFC in the
framework. Open one to read the canonical body and the public
conversation behind each definition.
</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
been invited.
</p>
<p>
Wondering why a conversation is public, why graduation costs what it
does, or why the model is in the chat? <Link to="/philosophy">Read the
philosophy</Link>.
</p>
</div>
)
}
return ( return (
<div className="welcome"> <div className="welcome">
<h1>Welcome, {viewer.display_name}.</h1> <h1>Welcome, {viewer.display_name}.</h1>
+18
View File
@@ -534,6 +534,24 @@ export async function listGraduationQueue() {
return jsonOrThrow(await fetch('/api/admin/graduation-queue')) return jsonOrThrow(await fetch('/api/admin/graduation-queue'))
} }
export async function listAllowlist() {
return jsonOrThrow(await fetch('/api/admin/allowlist'))
}
export async function addAllowlistEmail(email, note) {
return jsonOrThrow(await fetch('/api/admin/allowlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, note: note || null }),
}))
}
export async function removeAllowlistEmail(email) {
return jsonOrThrow(await fetch(`/api/admin/allowlist/${encodeURIComponent(email)}`, {
method: 'DELETE',
}))
}
export async function searchUsers(q) { export async function searchUsers(q) {
const params = new URLSearchParams() const params = new URLSearchParams()
if (q) params.set('q', q) if (q) params.set('q', q)
+139
View File
@@ -19,10 +19,14 @@ import {
listAuditLog, listAuditLog,
listPermissionEvents, listPermissionEvents,
listGraduationQueue, listGraduationQueue,
listAllowlist,
addAllowlistEmail,
removeAllowlistEmail,
} from '../api.js' } from '../api.js'
const TABS = [ const TABS = [
{ path: 'users', label: 'Users' }, { path: 'users', label: 'Users' },
{ path: 'allowlist', label: 'Allowlist' },
{ path: 'graduation', label: 'Graduation queue' }, { path: 'graduation', label: 'Graduation queue' },
{ path: 'audit', label: 'Audit log' }, { path: 'audit', label: 'Audit log' },
{ path: 'permissions', label: 'Permission events' }, { path: 'permissions', label: 'Permission events' },
@@ -54,6 +58,7 @@ export default function Admin({ viewer }) {
<Routes> <Routes>
<Route index element={<UsersTab />} /> <Route index element={<UsersTab />} />
<Route path="users" element={<UsersTab />} /> <Route path="users" element={<UsersTab />} />
<Route path="allowlist" element={<AllowlistTab />} />
<Route path="graduation" element={<GraduationTab />} /> <Route path="graduation" element={<GraduationTab />} />
<Route path="audit" element={<AuditTab />} /> <Route path="audit" element={<AuditTab />} />
<Route path="permissions" element={<PermissionsTab />} /> <Route path="permissions" element={<PermissionsTab />} />
@@ -174,6 +179,140 @@ function UsersTab() {
) )
} }
// ── Private-beta allowlist (`migrations/011_allowlist.sql`) ────────────────
function AllowlistTab() {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [draftEmail, setDraftEmail] = useState('')
const [draftNote, setDraftNote] = useState('')
const [busy, setBusy] = useState(false)
async function refresh() {
setError(null)
try {
setData(await listAllowlist())
} catch (e) {
setError(e.message)
}
}
useEffect(() => { refresh() }, [])
async function handleAdd(event) {
event.preventDefault()
const email = draftEmail.trim()
if (!email) return
setBusy(true); setError(null)
try {
await addAllowlistEmail(email, draftNote.trim() || null)
setDraftEmail(''); setDraftNote('')
await refresh()
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
async function handleRemove(email) {
if (!confirm(`Remove ${email} from the allowlist?`)) return
setBusy(true); setError(null)
try {
await removeAllowlistEmail(email)
await refresh()
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
if (data == null && !error) return <p className="muted">Loading allowlist…</p>
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Allowlist</h2>
<p className="muted">
When this list has any rows, OAuth sign-in is restricted: only emails
here (case-insensitive) may sign in. Already-provisioned users are
grandfathered by their Gitea ID and never re-checked. An empty list
turns the gate off entirely.
</p>
<p className="muted">
Status:{' '}
<strong>{data?.active ? 'Private beta gate active' : 'Open anyone can sign in'}</strong>
</p>
</header>
{error && <p className="settings-note warning">{error}</p>}
<form className="allowlist-add" onSubmit={handleAdd}>
<input
type="email"
placeholder="email@example.com"
value={draftEmail}
onChange={e => setDraftEmail(e.target.value)}
required
disabled={busy}
/>
<input
type="text"
placeholder="Note (optional)"
value={draftNote}
onChange={e => setDraftNote(e.target.value)}
maxLength={200}
disabled={busy}
/>
<button type="submit" className="btn-primary" disabled={busy || !draftEmail.trim()}>
Add to allowlist
</button>
</form>
{data?.items?.length > 0 ? (
<table className="admin-table">
<thead>
<tr>
<th>Email</th>
<th>Note</th>
<th>Added by</th>
<th>Added at</th>
<th></th>
</tr>
</thead>
<tbody>
{data.items.map(r => (
<tr key={r.email}>
<td><code>{r.email}</code></td>
<td>{r.note || <span className="muted">—</span>}</td>
<td>
{r.added_by_login
? <span>@{r.added_by_login}</span>
: <span className="muted">—</span>}
</td>
<td className="muted">{r.created_at}</td>
<td>
<button
type="button"
className="btn-link-quiet"
onClick={() => handleRemove(r.email)}
disabled={busy}
>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="muted">
No allow-listed emails yet. Add the first one to put the deployment
into private-beta mode.
</p>
)}
</div>
)
}
// ── Graduation-readiness queue (§13.2) ───────────────────────────────────── // ── Graduation-readiness queue (§13.2) ─────────────────────────────────────
function GraduationTab() { function GraduationTab() {
+41
View File
@@ -0,0 +1,41 @@
// BetaPending.jsx — the post-OAuth-rejection page.
//
// 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.
import { Link } from 'react-router-dom'
export default function BetaPending() {
const contact = import.meta.env.VITE_BETA_CONTACT || ''
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>
{contact ? (
<p className="beta-pending-contact">
To request access, contact <strong>{contact}</strong> with the
email address you'd like to sign in with.
</p>
) : (
<p className="beta-pending-contact">
To request access, contact the deployment operator with the email
address you'd like to sign in with.
</p>
)}
<div className="beta-pending-actions">
<Link className="btn-primary" to="/">Browse as a guest</Link>
<Link className="btn-link-quiet" to="/philosophy">Read the philosophy </Link>
</div>
</div>
</div>
)
}
+9 -3
View File
@@ -22,7 +22,7 @@ const SORT_OPTIONS = [
{ id: 'state', label: 'State' }, { id: 'state', label: 'State' },
] ]
export default function Catalog({ onProposeRFC, version }) { export default function Catalog({ viewer, onProposeRFC, version }) {
const [rfcs, setRfcs] = useState([]) const [rfcs, setRfcs] = useState([])
const [proposals, setProposals] = useState([]) const [proposals, setProposals] = useState([])
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@@ -93,7 +93,7 @@ export default function Catalog({ onProposeRFC, version }) {
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}> <div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
{rfcs.length === 0 {rfcs.length === 0
? 'No RFCs in the catalog yet. Propose one below.' ? (viewer ? 'No RFCs in the catalog yet. Propose one below.' : 'No RFCs in the catalog yet.')
: 'No matches.'} : 'No matches.'}
</div> </div>
) : ( ) : (
@@ -148,7 +148,13 @@ export default function Catalog({ onProposeRFC, version }) {
</div> </div>
<div className="catalog-footer"> <div className="catalog-footer">
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New RFC</button> {viewer ? (
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New RFC</button>
) : (
<a className="btn-propose" href="/auth/login" title="Private beta — only invited emails can propose">
Sign in to propose <span className="beta-chip">Beta</span>
</a>
)}
</div> </div>
</aside> </aside>
) )
+8 -3
View File
@@ -535,9 +535,10 @@ export default function RFCView({ viewer }) {
type="button" type="button"
className={`btn-mode-toggle ${mode}`} className={`btn-mode-toggle ${mode}`}
onClick={() => setMode(mode === 'discuss' ? 'contribute' : 'discuss')} onClick={() => setMode(mode === 'discuss' ? 'contribute' : 'discuss')}
title={mode === 'discuss' ? 'Flip into edit mode' : 'Flip back to read-only discuss'} title={mode === 'discuss' ? 'Flip into edit mode (Beta)' : 'Flip back to read-only discuss (Beta)'}
> >
{mode === 'discuss' ? 'Contribute' : 'Discuss'} {mode === 'discuss' ? 'Contribute' : 'Discuss'}
<span className="beta-chip">Beta</span>
</button> </button>
)} )}
{(branchParam === 'main' || !canContribute) && viewer && ( {(branchParam === 'main' || !canContribute) && viewer && (
@@ -547,10 +548,13 @@ export default function RFCView({ viewer }) {
onClick={handleStartContributing} onClick={handleStartContributing}
> >
Start Contributing Start Contributing
<span className="beta-chip">Beta</span>
</button> </button>
)} )}
{!viewer && ( {!viewer && (
<a className="btn-link" href="/auth/login">Sign in</a> <a className="btn-link" href="/auth/login" title="Private beta — only invited emails can sign in">
Sign in <span className="beta-chip">Beta</span>
</a>
)} )}
{canOpenPR && ( {canOpenPR && (
<button <button
@@ -742,7 +746,8 @@ export default function RFCView({ viewer }) {
/> />
) : ( ) : (
<div className="readonly-bar"> <div className="readonly-bar">
Read-only view. <a href="/auth/login">Sign in</a> to participate. Read-only view. Discussion is in private <strong>Beta</strong> {' '}
<a href="/auth/login">sign in</a> if your email has been invited.
</div> </div>
)} )}
</div> </div>