diff --git a/CHANGELOG.md b/CHANGELOG.md index d3ad917..cab6c43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 **Patch — no operator action required.** Rebuild and restart per the diff --git a/VERSION b/VERSION index 7179039..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.3 +0.3.0 diff --git a/backend/app/api_admin.py b/backend/app/api_admin.py index 89d9224..f130381 100644 --- a/backend/app/api_admin.py +++ b/backend/app/api_admin.py @@ -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 diff --git a/backend/app/auth.py b/backend/app/auth.py index e5fc4fb..b37ed51 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -77,6 +77,43 @@ async def fetch_user_profile(config: Config, access_token: str) -> dict[str, Any 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: """Insert or update the users row for this Gitea profile. diff --git a/backend/app/main.py b/backend/app/main.py index 0f851fd..7b2b21a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -107,6 +107,12 @@ def _oauth_router(config) -> APIRouter: if not access_token: raise HTTPException(400, "Token exchange failed") 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) auth.store_session(request, user) return RedirectResponse("/") diff --git a/backend/migrations/011_allowlist.sql b/backend/migrations/011_allowlist.sql new file mode 100644 index 0000000..dc46d1d --- /dev/null +++ b/backend/migrations/011_allowlist.sql @@ -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')) +); diff --git a/deploy/DEPLOY-NEW-SESSION-PROMPT.md b/deploy/DEPLOY-NEW-SESSION-PROMPT.md index 62e871a..9ec3541 100644 --- a/deploy/DEPLOY-NEW-SESSION-PROMPT.md +++ b/deploy/DEPLOY-NEW-SESSION-PROMPT.md @@ -1,7 +1,7 @@ # RFC App — Deployment Reference & New-Session Prompt 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 --- @@ -36,10 +36,10 @@ For reference, the separate Gitea VM is `wiggleverse` project / `gitea` VM / 34. | 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 | -> 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. @@ -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/data/rfc-app.db` | SQLite database | | `/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 | --- @@ -79,7 +79,7 @@ SPF (`v=spf1 include:_spf.google.com ~all`) and DKIM (`google._domainkey`) for ` ## 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 @@ -91,13 +91,13 @@ Created in Gitea as `rfc-bot`. Token scopes: `write:repository`, `write:user`, ` ### 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 Registered in Gitea Site Administration → Integrations → OAuth2 Applications: - 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` --- @@ -133,7 +133,7 @@ OAUTH_CLIENT_ID= OAUTH_CLIENT_SECRET= # App -APP_URL=https://rfc.wiggleverse.org +APP_URL=https://ohm.wiggleverse.org SECRET_KEY= DATABASE_PATH=/opt/rfc-app/backend/data/rfc-app.db 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): ```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 ``` +`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 ` 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. (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) ### 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 ```bash @@ -230,15 +236,15 @@ sudo -u rfc-app -H bash -c \ ### 6. Build the frontend (on the VM) ```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 ``` ### 7. nginx ```bash -sudo cp /opt/rfc-app/deploy/nginx/rfc.wiggleverse.org.conf \ - /etc/nginx/sites-available/rfc.wiggleverse.org -sudo ln -s /etc/nginx/sites-available/rfc.wiggleverse.org \ +sudo cp /opt/rfc-app/deploy/nginx/ohm.wiggleverse.org.conf \ + /etc/nginx/sites-available/ohm.wiggleverse.org +sudo ln -s /etc/nginx/sites-available/ohm.wiggleverse.org \ /etc/nginx/sites-enabled/ sudo usermod -a -G rfc-app www-data 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 ```bash -sudo certbot --nginx -d rfc.wiggleverse.org +sudo certbot --nginx -d ohm.wiggleverse.org ``` ### 9. systemd @@ -259,7 +265,7 @@ sudo systemctl status rfc-app ``` ### 10. Smoke test -Visit `https://rfc.wiggleverse.org`: +Visit `https://ohm.wiggleverse.org`: 1. Landing page renders with sign-in button 2. Sign in with Gitea OAuth → catalog loads 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:** > - Backend: Python 3.11, FastAPI, uvicorn (single process), SQLite WAL mode diff --git a/deploy/RUNBOOK.md b/deploy/RUNBOOK.md index 6ef34a5..830939f 100644 --- a/deploy/RUNBOOK.md +++ b/deploy/RUNBOOK.md @@ -1,6 +1,6 @@ # 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, same Let's Encrypt). The shape matches §4.2: one process, one SQLite file, no separate worker. @@ -18,7 +18,7 @@ recover from a partial install is safe. - Ubuntu/Debian-style host with nginx and certbot already serving `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`. - 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+ @@ -75,7 +75,7 @@ Invite → rfc-bot → Owner**. Integrations → OAuth2 Applications → Create Application**: - 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`. @@ -93,7 +93,7 @@ sudo -u rfc-app /opt/rfc-app/backend/.venv/bin/pip install \ ```sh # On your laptop: -cd frontend && npm install && npm run build +cd frontend && npm ci && npm run build rsync -a dist/ ben.stull@:/tmp/rfc-app-dist/ # On the host: 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: ```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 ``` +`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 ` on the next deploy. + **1.3.3 Write `.env`.** ```sh @@ -128,7 +134,7 @@ META_REPO=meta OAUTH_CLIENT_ID= OAUTH_CLIENT_SECRET= -APP_URL=https://rfc.wiggleverse.org +APP_URL=https://ohm.wiggleverse.org SECRET_KEY= OWNER_GITEA_LOGIN=ben.stull GITEA_WEBHOOK_SECRET= @@ -182,9 +188,9 @@ Re-running is safe; every step is upsert-shaped. **1.4.1 nginx vhost.** ```sh -sudo cp /opt/rfc-app/deploy/nginx/rfc.wiggleverse.org.conf \ - /etc/nginx/sites-available/rfc.wiggleverse.org -sudo ln -s /etc/nginx/sites-available/rfc.wiggleverse.org \ +sudo cp /opt/rfc-app/deploy/nginx/ohm.wiggleverse.org.conf \ + /etc/nginx/sites-available/ohm.wiggleverse.org +sudo ln -s /etc/nginx/sites-available/ohm.wiggleverse.org \ /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl reload nginx ``` @@ -200,7 +206,7 @@ sudo systemctl reload nginx **1.4.2 Let's Encrypt cert.** ```sh -sudo certbot --nginx -d rfc.wiggleverse.org +sudo certbot --nginx -d ohm.wiggleverse.org ``` ### 1.5 systemd @@ -226,7 +232,7 @@ RFC app started — meta repo wiggleverse/meta ### 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, sign-in affordance). @@ -384,7 +390,7 @@ say), restore from the most recent backup per §2.2. `rfc-app`. - **OAuth callback returns "Invalid state".** The redirect URI in Gitea 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: `journalctl -u rfc-app | grep webhook`. Gitea's **Settings → Webhooks → Recent Deliveries** on the meta repo shows the delivery status; the diff --git a/deploy/nginx/rfc.wiggleverse.org.conf b/deploy/nginx/ohm.wiggleverse.org.conf similarity index 89% rename from deploy/nginx/rfc.wiggleverse.org.conf rename to deploy/nginx/ohm.wiggleverse.org.conf index e83b6d8..3b108c1 100644 --- a/deploy/nginx/rfc.wiggleverse.org.conf +++ b/deploy/nginx/ohm.wiggleverse.org.conf @@ -2,21 +2,21 @@ # frontend served as static files from the Vite build output. # # Install: -# sudo cp deploy/nginx/rfc.wiggleverse.org.conf \ -# /etc/nginx/sites-available/rfc.wiggleverse.org -# sudo ln -s /etc/nginx/sites-available/rfc.wiggleverse.org \ +# sudo cp deploy/nginx/ohm.wiggleverse.org.conf \ +# /etc/nginx/sites-available/ohm.wiggleverse.org +# sudo ln -s /etc/nginx/sites-available/ohm.wiggleverse.org \ # /etc/nginx/sites-enabled/ # sudo nginx -t && sudo systemctl reload nginx # # 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 # directives; the rest of the config below stays as written. server { 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 # runs as user `rfc-app`; make sure nginx (usually `www-data`) can diff --git a/docs/DEPLOYMENTS.md b/docs/DEPLOYMENTS.md index 98a5413..18e3e96 100644 --- a/docs/DEPLOYMENTS.md +++ b/docs/DEPLOYMENTS.md @@ -69,7 +69,7 @@ The shortest path from scratch: any deployment-identifying value; if a required variable is 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 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 fetch && git checkout `. -6. **Rebuild.** `npm install && npm run build` for the frontend; +6. **Rebuild.** `npm ci && npm run build` for the frontend; restart the backend. 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 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 If a framework behavior is wrong for your deployment, file it as a diff --git a/frontend/.env.example b/frontend/.env.example index 92e36db..3dac9f8 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -13,3 +13,14 @@ # VITE_APP_NAME=Wiggleverse RFC # VITE_APP_NAME=Wiggleverse Open Human Model 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= diff --git a/frontend/package.json b/frontend/package.json index f46db0d..40f0b02 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.2.3", + "version": "0.3.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index a010cd4..a577e39 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -5,7 +5,16 @@ 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 { height: 48px; flex-shrink: 0; @@ -33,6 +42,31 @@ } .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; } /* --- Catalog (left pane, §7) --- */ @@ -318,6 +352,37 @@ } .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 ─────────────────────────────────── */ .main-pane { @@ -1678,6 +1743,27 @@ 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-handle { font-weight: 500; color: #111; } .mute-toggle { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2f6e3c2..26f0bf6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 BetaPending from './components/BetaPending.jsx' import Philosophy from './components/Philosophy.jsx' import NotificationSettings from './components/NotificationSettings.jsx' import Admin from './components/Admin.jsx' @@ -68,17 +69,14 @@ export default function App() { return
Loading…
} - // §14.2: the philosophy route is reachable by anonymous visitors too. - // Resolve it before the authentication gate so a signed-out reader - // who follows the §14.1 landing link does not get bounced to sign-in. - if (!me?.authenticated) { - return ( - - } /> - } /> - - ) - } + // 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. + const viewer = me?.authenticated ? me.user : null + const isAdmin = viewer && (viewer.role === 'owner' || viewer.role === 'admin') return (
@@ -88,59 +86,78 @@ export default function App() {
{/* §14.3: the persistent About link. One word, no badge, no - state — visible from every authenticated screen so a - contributor mid-PR who wonders why a conversation is - public can reach the answer in two clicks. */} + state — visible from every screen so a viewer mid-PR who + wonders why a conversation is public can reach the answer + in two clicks. Anonymous viewers see it too. */} About - - Settings - - {(me.user.role === 'owner' || me.user.role === 'admin') && ( + {viewer && ( + + Settings + + )} + {isAdmin && ( Admin )} - - {me.user.display_name} - {me.user.role} - Sign out + {viewer && ( + + )} + {viewer ? ( + <> + {viewer.display_name} + {viewer.role} + Sign out + + ) : ( + + Sign in Beta + + )}
- } /> - } /> - } /> + } /> + } /> + } /> + {viewer && ( + } /> + )} + {isAdmin && ( + } /> + )} setProposeOpen(true)} version={catalogVersion} />
- } /> - } /> - } /> - setCatalogVersion(v => v + 1)} />} /> + } /> + } /> + } /> + setCatalogVersion(v => v + 1)} />} />
} />
- {proposeOpen && ( + {proposeOpen && viewer && ( setProposeOpen(false)} onSubmitted={({ pr_number }) => { @@ -150,7 +167,7 @@ export default function App() { }} /> )} - {inboxOpen && ( + {inboxOpen && viewer && ( setInboxOpen(false)} lastChangeTick={inboxTick} /> )} @@ -158,14 +175,14 @@ export default function App() { ) } -function PhilosophyWithSidebar() { +function PhilosophyWithSidebar({ viewer }) { // The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin) // all use the full app body — no catalog left pane, no propose modal. // The header carries the navigation back; the body is a single // reading surface. return (
- +
) } @@ -187,6 +204,28 @@ function AdminWithSidebar({ viewer }) { } function Welcome({ viewer }) { + if (!viewer) { + return ( +
+

Welcome.

+

+ 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. +

+

+ Discussion and contribution are in private Beta — + read freely, and sign in if your email has + been invited. +

+

+ Wondering why a conversation is public, why graduation costs what it + does, or why the model is in the chat? Read the + philosophy. +

+
+ ) + } return (

Welcome, {viewer.display_name}.

diff --git a/frontend/src/api.js b/frontend/src/api.js index 0cb5f8f..c96a37b 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -534,6 +534,24 @@ export async function listGraduationQueue() { 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) { const params = new URLSearchParams() if (q) params.set('q', q) diff --git a/frontend/src/components/Admin.jsx b/frontend/src/components/Admin.jsx index bc689ba..027c880 100644 --- a/frontend/src/components/Admin.jsx +++ b/frontend/src/components/Admin.jsx @@ -19,10 +19,14 @@ import { listAuditLog, listPermissionEvents, listGraduationQueue, + listAllowlist, + addAllowlistEmail, + removeAllowlistEmail, } from '../api.js' const TABS = [ { path: 'users', label: 'Users' }, + { path: 'allowlist', label: 'Allowlist' }, { path: 'graduation', label: 'Graduation queue' }, { path: 'audit', label: 'Audit log' }, { path: 'permissions', label: 'Permission events' }, @@ -54,6 +58,7 @@ export default function Admin({ viewer }) { } /> } /> + } /> } /> } /> } /> @@ -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

Loading allowlist…

+ + return ( +
+
+

Allowlist

+

+ 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. +

+

+ Status:{' '} + {data?.active ? 'Private beta — gate active' : 'Open — anyone can sign in'} +

+
+ {error &&

{error}

} + +
+ setDraftEmail(e.target.value)} + required + disabled={busy} + /> + setDraftNote(e.target.value)} + maxLength={200} + disabled={busy} + /> + +
+ + {data?.items?.length > 0 ? ( + + + + + + + + + + + + {data.items.map(r => ( + + + + + + + + ))} + +
EmailNoteAdded byAdded at
{r.email}{r.note || } + {r.added_by_login + ? @{r.added_by_login} + : } + {r.created_at} + +
+ ) : ( +

+ No allow-listed emails yet. Add the first one to put the deployment + into private-beta mode. +

+ )} +
+ ) +} + // ── Graduation-readiness queue (§13.2) ───────────────────────────────────── function GraduationTab() { diff --git a/frontend/src/components/BetaPending.jsx b/frontend/src/components/BetaPending.jsx new file mode 100644 index 0000000..c77505d --- /dev/null +++ b/frontend/src/components/BetaPending.jsx @@ -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 ( +
+
+

{import.meta.env.VITE_APP_NAME} is in private Beta.

+

+ 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. +

+ {contact ? ( +

+ To request access, contact {contact} with the + email address you'd like to sign in with. +

+ ) : ( +

+ To request access, contact the deployment operator with the email + address you'd like to sign in with. +

+ )} +
+ Browse as a guest + Read the philosophy → +
+
+
+ ) +} diff --git a/frontend/src/components/Catalog.jsx b/frontend/src/components/Catalog.jsx index 0ca41ed..fc001a5 100644 --- a/frontend/src/components/Catalog.jsx +++ b/frontend/src/components/Catalog.jsx @@ -22,7 +22,7 @@ const SORT_OPTIONS = [ { id: 'state', label: 'State' }, ] -export default function Catalog({ onProposeRFC, version }) { +export default function Catalog({ viewer, onProposeRFC, version }) { const [rfcs, setRfcs] = useState([]) const [proposals, setProposals] = useState([]) const [search, setSearch] = useState('') @@ -93,7 +93,7 @@ export default function Catalog({ onProposeRFC, version }) { {filtered.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.'}
) : ( @@ -148,7 +148,13 @@ export default function Catalog({ onProposeRFC, version }) {
- + {viewer ? ( + + ) : ( + + Sign in to propose Beta + + )}
) diff --git a/frontend/src/components/RFCView.jsx b/frontend/src/components/RFCView.jsx index 5cf8a2c..46eda2c 100644 --- a/frontend/src/components/RFCView.jsx +++ b/frontend/src/components/RFCView.jsx @@ -535,9 +535,10 @@ export default function RFCView({ viewer }) { type="button" className={`btn-mode-toggle ${mode}`} 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'} + Beta )} {(branchParam === 'main' || !canContribute) && viewer && ( @@ -547,10 +548,13 @@ export default function RFCView({ viewer }) { onClick={handleStartContributing} > Start Contributing + Beta )} {!viewer && ( - Sign in + + Sign in Beta + )} {canOpenPR && (