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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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'))
|
||||
);
|
||||
@@ -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=<from Gitea OAuth app>
|
||||
OAUTH_CLIENT_SECRET=<from Gitea OAuth app>
|
||||
|
||||
# App
|
||||
APP_URL=https://rfc.wiggleverse.org
|
||||
APP_URL=https://ohm.wiggleverse.org
|
||||
SECRET_KEY=<openssl rand -hex 32>
|
||||
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 <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.
|
||||
|
||||
(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
|
||||
|
||||
+18
-12
@@ -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@<host>:/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 <tag>` on the next deploy.
|
||||
|
||||
**1.3.3 Write `.env`.**
|
||||
|
||||
```sh
|
||||
@@ -128,7 +134,7 @@ META_REPO=meta
|
||||
OAUTH_CLIENT_ID=<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>
|
||||
OWNER_GITEA_LOGIN=ben.stull
|
||||
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.**
|
||||
|
||||
```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
|
||||
|
||||
@@ -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
|
||||
+31
-2
@@ -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 <tag>`.
|
||||
|
||||
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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.2.3",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+87
-1
@@ -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 {
|
||||
|
||||
+81
-42
@@ -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 <div className="boot">Loading…</div>
|
||||
}
|
||||
|
||||
// §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 (
|
||||
<Routes>
|
||||
<Route path="/philosophy" element={<Philosophy authenticated={false} />} />
|
||||
<Route path="*" element={<Landing />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
// 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 (
|
||||
<div className="app">
|
||||
@@ -88,59 +86,78 @@ export default function App() {
|
||||
</div>
|
||||
<div className="header-right">
|
||||
{/* §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. */}
|
||||
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
||||
About
|
||||
</Link>
|
||||
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
|
||||
Settings
|
||||
</Link>
|
||||
{(me.user.role === 'owner' || me.user.role === 'admin') && (
|
||||
{viewer && (
|
||||
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
|
||||
Settings
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link to="/admin" className="header-admin" title="Admin home base">
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
className="inbox-trigger"
|
||||
onClick={() => setInboxOpen(o => !o)}
|
||||
title="Notifications inbox (§15.2)"
|
||||
>
|
||||
<span aria-hidden>📮</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="user-name">{me.user.display_name}</span>
|
||||
<span className={`user-role-badge role-${me.user.role}`}>{me.user.role}</span>
|
||||
<a className="btn-link" href="/auth/logout">Sign out</a>
|
||||
{viewer && (
|
||||
<button
|
||||
className="inbox-trigger"
|
||||
onClick={() => setInboxOpen(o => !o)}
|
||||
title="Notifications inbox (§15.2)"
|
||||
>
|
||||
<span aria-hidden>📮</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{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>
|
||||
</header>
|
||||
<div className="app-body">
|
||||
<Routes>
|
||||
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={me.user} />} />
|
||||
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={me.user} />} />
|
||||
<Route path="/admin/*" element={<AdminWithSidebar viewer={me.user} />} />
|
||||
<Route path="/welcome" element={<Landing />} />
|
||||
<Route path="/beta-pending" element={<BetaPending />} />
|
||||
<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={
|
||||
<>
|
||||
<Catalog
|
||||
viewer={viewer}
|
||||
onProposeRFC={() => setProposeOpen(true)}
|
||||
version={catalogVersion}
|
||||
/>
|
||||
<main className="main-pane">
|
||||
<Routes>
|
||||
<Route path="/" element={<Welcome viewer={me.user} />} />
|
||||
<Route path="/rfc/:slug" element={<RFCView viewer={me.user} />} />
|
||||
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={me.user} />} />
|
||||
<Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
||||
<Route path="/" element={<Welcome viewer={viewer} />} />
|
||||
<Route path="/rfc/:slug" element={<RFCView viewer={viewer} />} />
|
||||
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} />
|
||||
<Route path="/proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</>
|
||||
} />
|
||||
</Routes>
|
||||
</div>
|
||||
{proposeOpen && (
|
||||
{proposeOpen && viewer && (
|
||||
<ProposeModal
|
||||
onClose={() => setProposeOpen(false)}
|
||||
onSubmitted={({ pr_number }) => {
|
||||
@@ -150,7 +167,7 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{inboxOpen && (
|
||||
{inboxOpen && viewer && (
|
||||
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
|
||||
)}
|
||||
<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)
|
||||
// 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 (
|
||||
<main className="chrome-pane">
|
||||
<Philosophy authenticated={true} />
|
||||
<Philosophy authenticated={!!viewer} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -187,6 +204,28 @@ function AdminWithSidebar({ 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 (
|
||||
<div className="welcome">
|
||||
<h1>Welcome, {viewer.display_name}.</h1>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }) {
|
||||
<Routes>
|
||||
<Route index element={<UsersTab />} />
|
||||
<Route path="users" element={<UsersTab />} />
|
||||
<Route path="allowlist" element={<AllowlistTab />} />
|
||||
<Route path="graduation" element={<GraduationTab />} />
|
||||
<Route path="audit" element={<AuditTab />} />
|
||||
<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) ─────────────────────────────────────
|
||||
|
||||
function GraduationTab() {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 ? (
|
||||
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
|
||||
{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.'}
|
||||
</div>
|
||||
) : (
|
||||
@@ -148,7 +148,13 @@ export default function Catalog({ onProposeRFC, version }) {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -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'}
|
||||
<span className="beta-chip">Beta</span>
|
||||
</button>
|
||||
)}
|
||||
{(branchParam === 'main' || !canContribute) && viewer && (
|
||||
@@ -547,10 +548,13 @@ export default function RFCView({ viewer }) {
|
||||
onClick={handleStartContributing}
|
||||
>
|
||||
Start Contributing
|
||||
<span className="beta-chip">Beta</span>
|
||||
</button>
|
||||
)}
|
||||
{!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 && (
|
||||
<button
|
||||
@@ -742,7 +746,8 @@ export default function RFCView({ viewer }) {
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user