Release 0.3.0: private-beta gate + anonymous read mode

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

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

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

See CHANGELOG.md for full details + upgrade steps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-27 20:58:58 -07:00
parent 1a9374aa52
commit 21fcbc92d4
19 changed files with 715 additions and 87 deletions
+11
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+18
View File
@@ -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)
+139
View File
@@ -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() {
+41
View File
@@ -0,0 +1,41 @@
// BetaPending.jsx the post-OAuth-rejection page.
//
// When a deployment is in private-beta mode (i.e. its `allowed_emails`
// table has any rows), the OAuth callback redirects unrecognised users
// here instead of provisioning them. The framework cannot know the
// deployment operator's preferred contact channel so the deployment
// supplies one via VITE_BETA_CONTACT (an email, URL, or short
// instruction). If unset, we render a generic ask-the-operator line.
import { Link } from 'react-router-dom'
export default function BetaPending() {
const contact = import.meta.env.VITE_BETA_CONTACT || ''
return (
<div className="beta-pending">
<div className="beta-pending-inner">
<h1>{import.meta.env.VITE_APP_NAME} is in private Beta.</h1>
<p>
Discussion and contribution are gated to invited emails for now.
Reading is open every super-draft, every active RFC, and every
public conversation is visible without signing in.
</p>
{contact ? (
<p className="beta-pending-contact">
To request access, contact <strong>{contact}</strong> with the
email address you'd like to sign in with.
</p>
) : (
<p className="beta-pending-contact">
To request access, contact the deployment operator with the email
address you'd like to sign in with.
</p>
)}
<div className="beta-pending-actions">
<Link className="btn-primary" to="/">Browse as a guest</Link>
<Link className="btn-link-quiet" to="/philosophy">Read the philosophy </Link>
</div>
</div>
</div>
)
}
+9 -3
View File
@@ -22,7 +22,7 @@ const SORT_OPTIONS = [
{ id: 'state', label: 'State' },
]
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>
)
+8 -3
View File
@@ -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>