Compare commits

..

5 Commits

Author SHA1 Message Date
Ben Stull 21743a08b1 Release 0.6.0: anonymous-write gates audit + hardening
Sweep-the-edges hardening release (roadmap item #4). Audits every
write-shaped backend endpoint to confirm each one enforces an explicit
auth.require_contributor (or stricter) gate before doing state-changing
work; adds a regression test net (test_anon_offlimits_vertical.py, 12
tests, 66 assertions) so future endpoints can't quietly ship without a
gate. The only behaviour change: GET /api/rfcs/<slug>/graduate/progress
now requires auth.require_user since the step detail (repo name, PR
number, rollback steps) isn't part of the v0.3.0 anonymous-read contract.
No schema, no env, no dependency changes; operator action is rebuild +
restart per CHANGELOG §0.6.0 upgrade steps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:47:49 -07:00
Ben Stull c92730a737 Release 0.5.0: PR-less per-RFC discussion (contribution still requires PR)
Roadmap item #3. An RFC's main view now carries a discussion surface
distinct from PR comments and from branch chat. The substrate is the
existing threads/thread_messages tables — rows with branch_name IS NULL
scope to the RFC's main view; the schema already permitted that shape,
v0.5.0 is the first build to write it. Five new endpoints under
/api/rfcs/<slug>/discussion/..., a new RFCDiscussionPanel right-column
component used when branchParam === main, SPEC §10.10 settling
discussion-vs-contribution, and §17 listing the new routes. Notification
routing reuses the existing chat_message_in_participated_thread /
chat_reply_to_my_message event kinds with branch_name=null on the
fan-out row; a distinct event_kind is a §19.2 candidate. Anonymous
viewers can read; writes require contributor — v0.6.0's item #4 will
harden adjacent gates. No schema migration; minor bump, no operator
action required beyond rebuild and restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:04:52 -07:00
Ben Stull 0f8b318afa Release 0.4.0: auto-set RFC owner = proposer
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:53:09 -07:00
Ben Stull 21fcbc92d4 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>
2026-05-27 20:58:58 -07:00
Ben Stull 1a9374aa52 Release 0.2.3: /api/health framework dependency for flotilla
Adds an unauthenticated GET /api/health endpoint returning JSON
{version, status} for ops tooling. version is the running framework
version (read from VERSION at process start, cached as a module-level
constant in backend/app/health.py); status is "ok" with HTTP 200 in
v1, with the "degraded" / 503 path reserved in the response shape for
future degradation conditions.

The structural value is the version-match check: a deploy control
panel (flotilla, in particular) polls the endpoint after
`systemctl restart` reports active and verifies the returned version
equals the tag just deployed, catching the failure mode where a
restart did not pick up the new code.

Patch-shaped per SPEC.md §20.2 — no operator action required, no env
vars, no schema changes. The CHANGELOG carries one MAY-language step
naming the optional monitoring affordance.

SPEC.md §17 now lists the endpoint; the §19.2 candidate-topic entry
records the topic's settlement (version source = VERSION file at
import time; degraded = reserved v1 scaffold; CHANGELOG = patch shape
with MAY-language).

Tests: backend/tests/test_health.py (3 tests — 200/payload shape,
unauthenticated, version-matches-VERSION). Full suite 128/128 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:40:40 -07:00
33 changed files with 2894 additions and 118 deletions
+346
View File
@@ -23,6 +23,352 @@ 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.6.0 — 2026-05-28
**Minor — no operator action required.** A sweep-the-edges hardening
release (roadmap item #4, "anon discuss + contribute off-limits") that
audits every write-shaped backend endpoint and asserts each one
enforces an explicit `auth.require_contributor` (or stricter) gate
before doing any state-changing work. v0.3.0 hid write affordances
behind a sign-in CTA on the frontend; v0.5.0 added the PR-less
discussion surface with its own write gate; v0.6.0 sweeps the rest
and adds a regression test net so future endpoints can't quietly
ship without a gate. No schema migration, no env-var changes, no
new dependencies. The only behavioural change is one tightening: the
`GET /api/rfcs/<slug>/graduate/progress` SSE now requires
`auth.require_user` since it surfaces operator-visible step detail
(repo name, PR number, rollback steps) not part of the v0.3.0
anonymous-read contract for catalog/RFC bodies.
### Changed
- **`backend/app/api_graduation.py`** — `GET /graduate/progress`
now calls `auth.require_user(request)` as its first line.
Anonymous callers receive 401 instead of being able to subscribe
to a graduation's SSE. The floor is `require_user` (not
`require_contributor`) so a write-muted operator can still observe
the progress of a graduation they kicked off before being muted.
- **SPEC `§6.1`** (`SPEC.md`) — the Anonymous role's bullet now
documents the v0.6.0 audit: every write-shaped endpoint in §17
enforces an explicit gate; anonymous writes refuse 401. The list
of audited write families is recorded in-line.
- **SPEC `§10.10`** — the discussion-vs-contribution section now
records that the v0.5.0 write gates have a regression test net
(`test_anon_offlimits_vertical.py`) added in v0.6.0.
- **SPEC `§17`** — the `GET /api/rfcs/<slug>/graduate/progress`
bullet now documents the `require_user` gate added in v0.6.0,
with the rationale.
### Added (tests)
- **`backend/tests/test_anon_offlimits_vertical.py`** — twelve new
tests asserting that every write-shaped endpoint surveyed in the
v0.6.0 audit refuses anonymous callers with 401, and that the five
anonymous-read surfaces (health, philosophy, auth/me, catalog,
RFC view, discussion threads, proposals) stay reachable. Sixty-six
assertions in total, covering: propose; proposal merge / decline /
withdraw; branch promote-to-branch / start-edit-branch / metadata /
manual-flush / visibility / grants (POST + DELETE) / threads (POST)
/ messages (POST) / resolve / chat-seen / changes (accept / decline
/ reask) / chat-stream; super-draft start-edit-branch + metadata;
PR pr-draft / open / seen / review / merge / withdraw /
description / resolution-branch; discussion thread create + message
post + resolve; admin role / mute / allowlist (POST + DELETE) plus
the admin reads; notification preferences / quiet-hours / watch /
mark-read / user-mute (POST + DELETE); funder credentials (POST +
DELETE) + consent (POST + DELETE); graduation kickoff + claim +
progress SSE; PR review page anonymous-readable.
### Anonymous-writeable allowlist
Two endpoints are intentionally anonymous-by-design. They are not
audit findings; they are documented here so the contract is
explicit:
- `GET /auth/login` and `GET /auth/callback` — the OAuth
round-trip. Anonymous-by-design because they ARE the sign-in
entrypoint.
- `POST /api/webhooks/gitea` and `POST /api/webhooks/email-bounce`
— anonymous in the session sense but authenticated by HMAC shared
secret (`GITEA_WEBHOOK_SECRET` and `WEBHOOK_EMAIL_BOUNCE_SECRET`
respectively). The webhook receiver is the wrong place for an
authenticated session; the shared-secret shape is correct.
### §19.2 candidates surfaced
- None unique to this release. The two pre-existing candidates the
audit touched — anonymous-read polish for the discussion surface
(carried from v0.5.0) and the operator-visibility floor on
graduation progress — were settled here as `require_user` on
`/graduate/progress` rather than deferred.
### Upgrade steps (from 0.5.0)
1. The deployment **MUST** rebuild the frontend (`npm install &&
npm run build`) so the frontend bundle's reported version matches
the backend's. No new env vars; existing `frontend/.env` is
sufficient.
2. The deployment **MUST** restart the backend so the new
`require_user` gate on `/graduate/progress` is enforced. No
schema migration runs.
3. The deployment **MAY** announce the audit completion to
operators: every write-shaped backend endpoint now enforces an
explicit gate, and the `test_anon_offlimits_vertical.py` test
net asserts the contract on every CI run. The audited surfaces
are listed in the `Added (tests)` section above and in `SPEC.md`
§6.1.
4. The deployment **MUST NOT** assume any new envelope behaviour:
v0.6.0 is purely a hardening release. No schema, no env, no
dependency changes; the operator's role is reduced to rebuild +
restart.
## 0.5.0 — 2026-05-27
**Minor — no operator action required.** This release wires the
PR-less per-RFC discussion surface (roadmap item #3, SPEC §10.10).
An RFC's main view now carries a discussion panel distinct from PR
comments and branch chat; contribution — proposing edits the document
will land — still requires opening a PR via the §10.1 affordance.
The substrate is the existing `threads` / `thread_messages` tables;
rows with `threads.branch_name IS NULL` scope to the RFC's main view.
No schema migration is required.
### Added
- **PR-less discussion endpoints** (`backend/app/api_discussion.py`)
mounted at `/api/rfcs/<slug>/discussion/...`:
- `GET /api/rfcs/<slug>/discussion/threads` — list threads where
`threads.branch_name IS NULL`. Anonymous-readable per the v0.3.0
contract; the default whole-doc chat thread is materialized
lazily on first read.
- `POST /api/rfcs/<slug>/discussion/threads` — open a new
discussion thread (`thread_kind='chat'`, `anchor_kind='whole-doc'`,
`branch_name=NULL`). Body: optional `label`, optional first
`message`. Requires contributor role.
- `GET /api/rfcs/<slug>/discussion/threads/<thread_id>/messages`
— read messages on a discussion thread. Anonymous-readable.
- `POST /api/rfcs/<slug>/discussion/threads/<thread_id>/messages`
— post a message. Body: `text`, optional `quote`. Requires
contributor role.
- `POST /api/rfcs/<slug>/discussion/threads/<thread_id>/resolve`
— resolve a discussion thread. Permission per §10.10: thread
creator, RFC owner / arbiter, or app admin / owner.
- **`RFCDiscussionPanel.jsx`** — the right-column surface on the RFC
view when the viewer is on `main`. Composer requires sign-in;
Cmd/Ctrl+Enter sends. Multiple threads surface as pill-shaped
tabs above the message feed. A "New thread" affordance opens a
fresh thread on the same RFC.
- **SPEC `§10.10` PR-less discussion vs. contribution** — settles
the distinction between discussion (RFC-scoped, no PR, both
anonymous-readable and contributor-writeable) and contribution
(still requires a PR via §10.1). Also extends `§5`'s `threads`
table commentary so the null-branch interpretation is documented
as actively used rather than reserved scaffold.
- **SPEC `§17`** — lists the five new `discussion/...` endpoints
in the illustrative table.
### Changed
- **`backend/app/chat.py`** — `_fan_out_chat` now passes
`branch_name` straight through to the notify chokepoint instead of
coercing `None` to `"main"`. The notifications row carries the
null through, which preserves the §15.7 reconciler's keying on
`(rfc_slug, branch_name)` for the eventual discussion-side
chat-seen advance (deferred to a §19.2 candidate). Existing
branch-scoped chat continues to pass non-null branch names; the
change is invisible to that path.
- **`backend/app/notify.py`** — `fan_out_chat_message`'s
`branch_name` parameter is now typed `str | None` to match the
v0.5.0 PR-less shape. Routing rules are unchanged; the inbox prose
renders identically whether the chat lives on a branch or on the
RFC's discussion surface, which is the right honest signal.
- **`RFCView.jsx`** — the right-column panel is now conditional:
when `branchParam === 'main'`, render `RFCDiscussionPanel`
(the new PR-less surface); otherwise render the existing
`ChatPanel` (branch chat unchanged).
### §19.2 candidates surfaced
- PR-less discussion: range / paragraph anchors (the data model
permits them; the UI is the deferred part).
- PR-less discussion: distinct notification `event_kind`s
(`open_rfc_discussion_thread`, etc.) if usage shows contributors
want to filter discussion-vs-branch in the §15.2 inbox.
- PR-less discussion: chat-seen cursor closing the §15.7
reconciliation loop for the new surface.
- PR-less discussion: AI participant invocation (discussion-only,
no `<change>` block side-effects).
- PR-less discussion: anonymous-read polish to match the v0.6.0
write-gate hardening (item #4).
### Upgrade steps (from 0.4.0)
1. The deployment **MUST** rebuild the frontend (`npm install &&
npm run build`) so `RFCDiscussionPanel.jsx` ships in the bundle.
No new env vars; existing `frontend/.env` is sufficient.
2. The deployment **MUST** restart the backend so the new
`api_discussion` router mounts. No schema migration runs — the
`threads` table already supports `branch_name IS NULL` per §5,
and the v0.5.0 build is the first to write rows in that shape.
3. The deployment **MAY** announce the new surface to its
contributors: the RFC view's main page now carries a discussion
panel below the document. Existing branch chat and PR review
surfaces are unchanged.
4. The deployment **SHOULD NOT** expect a hardening of the
anonymous-write gate in v0.5.0 — that lands in v0.6.0 (item #4).
v0.5.0's write paths already refuse anonymous posts, so no
pre-emptive operator action is needed.
## 0.4.0 — 2026-05-27
**Minor — no operator action required beyond rebuild + restart.** The
proposer of a new RFC is now the implicit first owner of its super-
draft entry, set automatically at propose time from the session user.
The §13.1 claim flow remains available for *additional* owners. No
schema changes, no env-var changes, no API-shape changes; only newly
proposed RFCs receive the auto-owner — existing super-drafts whose
`owners:` is empty are unaffected and can still be claimed via §13.1
as before. This is a §19.3 rule-2 spec correction: `SPEC.md` §9.1,
§9.2, and §13.1 are updated to reflect the new shape.
### Changed
- **`POST /api/rfcs/propose`** (`backend/app/api.py`) now sets
`Entry.owners = [user.gitea_login]` when constructing the new
super-draft entry, instead of `owners=[]`. The session's
`gitea_login` is the canonical source; the endpoint never accepted
an owner field from the request payload and still doesn't.
- **`SPEC.md` §9.1** narrowed: the "no proposed-owner or working-
group fields" sentence becomes a proposer-owner-auto / working-
group-deferred split, with a §19.3 rule-2 note.
- **`SPEC.md` §9.2** frontmatter shape: `owners: []` → `owners:
[<proposer.gitea_login>]`, with a §19.3 rule-2 note.
- **`SPEC.md` §13.1** reframed: claim flow is now a graduation-time
broadening for additional owners, not a precondition for the
proposer's own RFC. The §13.1 / §13.2 / §13.3 graduation pipeline
itself is unchanged — the "at least one owner" precondition for
graduation still holds and is now satisfied by default.
### Upgrade steps (from 0.3.0)
1. The deployment **MUST** rebuild and restart per the routine
deploy steps. No `.env` changes, no schema/migration changes, no
API-shape changes.
2. Operators **SHOULD** note that newly proposed RFCs after the
upgrade carry the proposer in `owners:` automatically. Existing
super-drafts with empty `owners:` are not migrated; they remain
claimable via the §13.1 flow exactly as before. No deployment-
side data action is required.
3. Deployments **MAY** communicate the UX shift to active proposers
(the "Claim ownership" affordance no longer applies to your own
newly proposed RFC), but the affordance simply hides on RFCs the
viewer already owns, so no operator-side action is required.
## 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
routine deploy steps; no `.env` changes, no schema changes, no
behavior changes a deployment would notice in steady state beyond
the new endpoint below.
### Added
- **`GET /api/health`** — an unauthenticated probe returning JSON
`{version, status}` for ops tooling. `version` is the running
framework version (the contents of `VERSION` per §20.1, read at
process start and cached as a module-level constant in
`backend/app/health.py`); `status` is `"ok"` with HTTP 200 in
v1. The `"degraded"` / HTTP 503 path stays reserved in the
response shape so a later release can wire real degradation
conditions without breaking the contract. The version-match
check is the structural value — a deploy-control-panel polling
the endpoint after `systemctl restart` catches the failure mode
where a restart did not pick up the new code. See `SPEC.md` §17
and the §19.2 settlement.
### Upgrade steps (from 0.2.2)
1. The deployment **MAY** configure its monitoring (Pingdom,
Healthchecks.io, the flotilla deploy control panel, etc.) to
probe `/api/health` and compare the returned `version` against
the tag last deployed. The endpoint is unauthenticated by
design — no PII in the payload, no session required.
## 0.2.2 — 2026-05-26
**Patch — no operator action required.** Rebuild the frontend and
+218 -13
View File
@@ -253,13 +253,18 @@ and exact columns are illustrative; the implementing session can adjust.
- `threads` — every conversation in the system, whether scoped to an RFC's
main view, a branch, or a span within a branch's document. Columns:
`id`, `rfc_slug`, `branch_name` (nullable — null means scoped to the
RFC's main view), `anchor_kind` (`whole-doc` | `range` | `paragraph`),
RFC's main view, the PR-less per-RFC discussion surface per §10's
closing note; non-null means scoped to a branch's work, including
PR-comment threads), `anchor_kind` (`whole-doc` | `range` | `paragraph`),
`anchor_payload` (JSON: serialized ProseMirror range or paragraph id),
`thread_kind` (`chat` | `flag` | `review``review` is the diff-anchored
PR-review thread defined in §10.4), `label` (short human-authored summary;
for flags this is the entire content), `state` (`open` | `resolved` |
`stale`), `created_by`, `created_at`, `resolved_at`, `resolved_by`.
Visibility is derived from the underlying branch (§11.1).
Visibility is derived from the underlying branch (§11.1); for the
null-branch PR-less discussion surface, visibility follows the RFC
(anonymous read open per the §14 / v0.3.0 contract, write requires
contributor per §6.1, tightened toward anon-write-refused in v0.6.0).
- `thread_messages` — the actual chat content for `chat`-kind threads.
Columns: `id`, `thread_id`, `role` (`user` | `assistant` | `system`),
`author_user_id` (nullable; null for assistant), `model_id` (nullable;
@@ -349,7 +354,15 @@ Authorization is owned by the app. Gitea sees only the bot account.
1. **Anonymous.** Can read public RFCs (the meta repo's main branch,
every RFC repo's main branch), read any branch whose `read_public`
is true, read any PR. Cannot chat, propose, create branches, or
open PRs.
open PRs. v0.6.0 (roadmap item #4) closed the audit: every
write-shaped endpoint surveyed in §17 enforces an explicit
`auth.require_contributor` (or stricter) gate before doing any
state-changing work; anonymous writes refuse 401. The explicit
audit covers propose, branch create, branch threads, PR-less
discussion threads + messages, PR open / merge / withdraw,
funder credentials + consent, admin allowlist add, graduation
kickoff + claim. Anonymous reads on every catalog and RFC-body
surface remain open per the v0.3.0 contract.
2. **Contributor.** Default role for any authenticated account.
Everything anonymous can do, plus: propose new RFCs (open a PR
against the meta repo), create branches on any RFC repo, open PRs
@@ -1120,12 +1133,20 @@ The modal collects four fields:
type their own.
No proposer name or email — the logged-in identity is canonical and
need not be retyped. No proposed-owner or working-group fields —
ownership flows through the post-merge claim flow (§13.1), and
need not be retyped. No proposer-owner field either: the proposer is
implicitly the first owner of their own RFC, set automatically at
submit-time from the session user (see §9.2); the post-merge claim
flow (§13.1) remains for *other* contributors to add themselves as
owners on an RFC they didn't propose. No working-group field —
arbiters are admin work, not the proposer's call. AI's drafting role
is intentionally narrow: tag suggestions only. The proposer is
making a specific claim about a specific word, and having AI propose
the claim for them would undercut the gesture.
the claim for them would undercut the gesture. (§19.3 rule-2
correction: 0.4.0 narrowed this paragraph; the prior version asserted
"no proposed-owner field" without qualification, but running code
revealed that forcing a separate claim-flow gesture for the
proposer's own RFC was UX friction with no benefit — the proposer
already self-identified by proposing.)
Primary action: **"Open proposal PR"** — naming the actual Git
artifact produced, consistent with §10.1's *Open PR* and §13's
@@ -1141,7 +1162,13 @@ is populated from the modal and session:
- `state: super-draft`, `id: null`, `repo: null`,
`graduated_at: null`, `graduated_by: null` — fixed at creation.
- `proposed_by: <session email>`, `proposed_at: <today>` — auto.
- `owners: []` — empty; the claim flow (§13.1) fills this.
- `owners: [<proposer.gitea_login>]` — the proposer is the first
owner at propose time, set automatically from the session user.
The §13.1 claim flow remains available for *additional* owners on
the same entry. (§19.3 rule-2 correction: 0.4.0 changed this from
`owners: []`; the prior shape required a separate claim-flow
gesture for the proposer's own RFC, which running code surfaced
as needless UX friction.)
- `arbiters: []` — empty; arbiters are admin work, not the
proposer's call.
@@ -1625,6 +1652,44 @@ framework's evidence unit; admitting plumbing commits — "fix merge
conflict with main" — into that timeline would dilute the signal each
commit is meant to carry.
### 10.10 PR-less discussion vs. contribution
PR comments and branch chat (§10.4, §8.4) are PR-scoped: they live on
the `threads` rows whose `branch_name` names a branch (the PR's head
or, pre-PR, a feature branch). They are the right surface for *this
specific proposed change*. They are not the right surface for "what
about this part of the RFC overall?" or "have we considered…?" — a
question that doesn't yet warrant cutting a branch and that would
distort a PR's review timeline if it landed there.
The RFC view carries a **discussion surface** distinct from PR
comments. Its substrate is `threads` rows whose `branch_name IS NULL`
(§5) — the same conversation table used by branch chat, with the
nullable column doing the segregating. Posting a discussion thread or
message does not open a PR; the §1 chokepoint is unaffected because
chat messages never produced Git writes. Contribution — proposing
edits the document will land — still requires opening a PR via the
§10.1 affordance.
The distinction in one line: **discussion is what the RFC is for;
contribution is how the RFC changes.** Either is honest; conflating
them was the failure mode of generic-PR-comments-as-only-conversation.
Reads on the discussion surface follow §14 / the v0.3.0 anonymous-read
contract: anyone can see the conversation. Writes require contributor
role per §6.1: v0.5.0 implemented the gate on the three discussion
write paths (POST threads, POST messages, POST resolve); v0.6.0 (item
#4) audited the adjacent surfaces and added the matching test net
(`test_anon_offlimits_vertical.py`) so a regression on any write
endpoint is caught immediately. The gates use `auth.require_contributor`
as the canonical helper. The notification routing reuses the
existing `chat_message_in_participated_thread` /
`chat_reply_to_my_message` event kinds with `branch_name=null` on the
fan-out row; the §15.7 reconciler and §15 inbox prose render
identically whether the chat lives on a branch or on the RFC's
discussion surface. A distinct `open_rfc_discussion_thread` event
kind is a §19.2 candidate if evidence demands the split.
---
## 11. Branches and PRs: visibility, contribute, lifecycle
@@ -1718,11 +1783,20 @@ PR do not block graduation; they remain on the meta repo subject to
### 13.1 Claim ownership (prerequisite)
If a super-draft has no owner, any signed-in contributor can click
"Claim ownership," which opens a PR against the meta repo adding their
username to the `owners:` field of the entry. Owners and admins can
merge. (A self-merge window for un-acted claims is not enabled in v1;
configurable later if needed.) Multiple claims simply append.
The proposer is already the first owner of any super-draft they
proposed (per §9.2; auto-set at propose time). The claim flow exists
for *additional* contributors to become owners on an existing super-
draft — typically a draft whose proposer has stepped away, or a draft
graduating with a working group. Any signed-in contributor can click
"Claim ownership," which opens a PR against the meta repo adding
their username to the `owners:` field of the entry. Owners and admins
can merge. (A self-merge window for un-acted claims is not enabled in
v1; configurable later if needed.) Multiple claims simply append.
(§19.3 rule-2 correction: 0.4.0 narrowed this section's framing; the
prior version implied owners always started empty and the claim flow
was a hard prerequisite to graduation, but with the proposer now
auto-set as first owner, the claim flow is a graduation-time
broadening rather than a precondition for the proposer's own RFC.)
### 13.2 The Graduate dialog
@@ -2416,6 +2490,17 @@ specified* and what is intentionally out of scope for v1.
The follow-up session will refine this. A minimal starting set:
- `GET /api/health` — unauthenticated. Returns JSON
`{version, status}` where `version` is the running framework
version (the contents of `VERSION` per §20.1, read at process
start) and `status` is `"ok"` (HTTP 200) or `"degraded"`
(HTTP 503). v1 always reports `"ok"`; the 503 / `"degraded"`
path is reserved scaffold for future degradation conditions.
Used by ops tooling (e.g. the flotilla deploy control panel)
as a post-flight probe — the structural check is that the
returned `version` matches the tag the operator just deployed,
catching the failure mode where a restart did not pick up the
new code.
- `GET /api/rfcs` — list entries with state, id, title, slug, repo,
owners, last_active_at, has_open_prs, starred-by-me. Supports
search, sort, filter chips, and the `unclaimed` predicate.
@@ -2471,7 +2556,13 @@ The follow-up session will refine this. A minimal starting set:
trailing `rollback` step's events if any earlier step fails. The
Graduate dialog opens this stream on confirm and renders the step
stack from the events. The stream closes on success or on
rollback completion.
rollback completion. Requires `auth.require_user` per v0.6.0
(item #4): the step detail (repo name, PR number, rollback steps)
is operator-visible state and isn't part of the v0.3.0
anonymous-read contract for catalog and RFC bodies. The floor is
`require_user` (not `require_contributor`) so a write-muted
operator can still observe a graduation they kicked off before
being muted.
- `GET /api/rfcs/<slug>/blocking-prs` — list open meta-repo PRs
against `rfcs/<slug>.md` per §13.2's precondition popover. Returns
PR number, title, author, last-activity timestamp, and the
@@ -2518,6 +2609,26 @@ The follow-up session will refine this. A minimal starting set:
- `POST /api/rfcs/<slug>/branches/<branch>/threads/<thread_id>/resolve`
— resolve a thread per §8.12; permission per the rules in that
section.
- `GET /api/rfcs/<slug>/discussion/threads` — list threads on the
RFC's PR-less discussion surface per §10.10 (rows where
`threads.branch_name IS NULL`). Anonymous-readable per the v0.3.0
anonymous-read contract; the default whole-doc chat thread is
materialized lazily on first read, mirroring the §8.12 branch-chat
default.
- `POST /api/rfcs/<slug>/discussion/threads` — open a discussion
thread per §10.10. Body: optional `label` (short summary), optional
first `message`. Writes require contributor role; anonymous viewers
receive 401. Thread is created with `anchor_kind='whole-doc'`,
`thread_kind='chat'`, `branch_name=NULL`.
- `GET /api/rfcs/<slug>/discussion/threads/<thread_id>/messages`
read messages on a discussion thread. Anonymous-readable.
- `POST /api/rfcs/<slug>/discussion/threads/<thread_id>/messages`
post a message into a discussion thread per §10.10. Body: `text`,
optional `quote`. Writes require contributor role.
- `POST /api/rfcs/<slug>/discussion/threads/<thread_id>/resolve`
resolve a discussion thread per §10.10; permission collapses to the
thread creator, any RFC owner / arbiter per §6.3, and any app
admin / owner per §6.1.
- `POST /api/rfcs/<slug>/branches/<branch>/open-pr` — open a PR per
§10.1; body carries the AI-drafted (and possibly edited) title and
description.
@@ -3208,6 +3319,100 @@ binding.
read-only relationship counts as active). A future session may
settle a tighter definition (e.g., has any `actions` row on the
RFC) if the generous proxy refuses too many legitimate mutes.
- **Health-check endpoint for ops tooling.** *Settled in the
post-v1 session that picked it. The flotilla deploy control panel
(a sibling operator-side tool spec'd in flotilla/SPEC.md) needs a
small framework-side endpoint to verify a deploy landed correctly.
Specifically: an unauthenticated `GET /api/health` returning JSON
`{version, status}` where `version` is the running framework
version recorded at startup and `status` is `"ok"` (HTTP 200) or
`"degraded"` (HTTP 503). flotilla uses the endpoint as a
post-flight probe — after `systemctl restart` reports active,
flotilla polls the endpoint and verifies the returned `version`
matches the tag just deployed. The version-match check is the
structural catch for the failure mode where a restart did not
actually pick up the new code. Scope intentionally small: one
endpoint, version + status payload, no auth (no PII), ships in
a patch release as a §17 addition. The endpoint becomes part of
§20.3's versioned surface and is available to any deployment
without operator action — flotilla is one consumer of many
possible. Earns its session next: flotilla v1 is blocked on this
dependency.*
*Settled in this session as follows. The running process reads
the `VERSION` file at the repo root at import time and caches the
string as a module-level constant (`backend/app/health.py`),
mirroring `backend/app/philosophy.py`'s disk-first shape. The
§20.1 invariant guarantees `VERSION` and
`frontend/package.json#version` are equal, so the choice is
cleanliness rather than correctness: `VERSION` is the canonical
source per §20.1, lives at the repo root, is one text line. A
missing `VERSION` file fails loudly per §20.6 — the module raises
at import time rather than serving a placeholder, because the
whole point of the endpoint is the version-match check and a
silent default would defeat it. `"degraded"` is reserved scaffold
for v1: a healthy startup always reports `"ok"` with HTTP 200,
and the `"degraded"` / 503 path stays in the response shape so a
later release can wire real degradation conditions (reconciler
stuck, migrations pending, provider universe empty) without
breaking flotilla's parser. Probing SQLite at request time is
out of scope — per §4.2 the DB is colocated, so a process that
can respond can reach it; the check would add code for no
signal. The §20.4 CHANGELOG treatment is the patch shape — no
operator action required — with an `### Added` section naming
the endpoint so operators discover it and one MAY-language step
("operators MAY configure their monitoring to probe `/api/health`;
the endpoint is unauthenticated by design"). §17 now lists the
endpoint in its illustrative table.*
- **PR-less discussion: range and paragraph anchors.** v0.5.0 lands
the structural discussion surface (§10.10) but constrains every
PR-less thread to `anchor_kind='whole-doc'` — the data model permits
`range` and `paragraph` anchors (§5) but the UI work to surface a
passage-anchored thread on a non-branch view is the deferred half.
The natural follow-on is a margin-icon affordance on the main view
matching §8.12's branch-side surface, with the anchor stored on the
null-branch thread. Earns its session when discussion volume warrants
the precision; v0.5.0's flat surface is sufficient for most "have we
considered…?" gestures. Touches §10.10 and §8.12.
- **PR-less discussion: distinct notification event_kinds.** v0.5.0
routes per-RFC discussion messages through the existing
`chat_message_in_participated_thread` and `chat_reply_to_my_message`
event kinds with `branch_name=null` on the fan-out row. The inbox
prose reads identically whether the chat lives on a branch or on the
RFC's discussion surface, which is honest signal: the conversation
shape is the same; only the scope differs. A future session may
introduce `open_rfc_discussion_thread` / `post_rfc_discussion_message`
if evidence shows contributors want to filter discussion-vs-branch
chat distinctly in the §15.2 inbox. Touches §15.1 (the event_kind
enum), §15.2 (the inbox filter chips), and §10.10.
- **PR-less discussion: chat-seen cursor.** §15.7 commits the
`branch_chat_seen` cursor for branch-scoped chat. The PR-less
discussion surface has no equivalent cursor in v0.5.0; the inbox
reconciler's keying on `(rfc_slug, branch_name)` does match a
null-branch advance, but no write path advances it. A natural
follow-on is a sibling table — `rfc_discussion_seen` or a
null-branch row on `branch_chat_seen` — that the discussion panel
advances on read, closing the §15.7 reconciliation loop for
discussion-surface notifications. Defer-able until inbox volume on
the new surface shows it matters.
- **PR-less discussion: AI participation.** v0.5.0's discussion
surface is human-only — no AI participant invocation, no `<change>`
block parsing, no per-thread model picker. The branch chat (§8.12)
retains the §18 AI surface. The natural follow-on is wiring the AI
participant into discussion threads (the model picker, the
`Ask Claude` button on a selection tooltip) without enabling
document edits — a discussion-only AI turn produces only chat
content, no `changes` row, no commit. Contribution still requires a
PR; the AI's discussion-side help is just better prompts. Touches
§10.10, §8.12, and §18.
- **PR-less discussion: anonymous read polish.** v0.5.0 inherits the
v0.3.0 anonymous-read contract — anyone can read; only signed-in
contributors can write. The composer affordance for anonymous
viewers ("Sign in to comment.") matches the existing read-only-bar
treatment but the surface has not yet been audited for the v0.6.0
hardening that tightens write gates app-wide. The §19.2 "public
face of discuss mode" entry overlaps; this entry is its discussion-
surface variant.
- **Deployment-supplied subject framing.** The framework was built
with one deployment in mind (OHM, standardizing natural-language
vocabulary), but the substrate generalizes to any domain that
+1 -1
View File
@@ -1 +1 @@
0.2.2
0.6.0
+23 -1
View File
@@ -20,6 +20,7 @@ from pydantic import BaseModel, Field
from . import (
api_admin,
api_branches,
api_discussion,
api_graduation,
api_notifications,
api_prs,
@@ -28,6 +29,7 @@ from . import (
entry as entry_mod,
cache,
funder,
health,
philosophy,
providers as providers_mod,
)
@@ -80,6 +82,22 @@ def make_router(
# the §15.8 mute typeahead) and the §6/§17 admin surfaces
# (role, write-mute, audit-log, graduation-readiness queue).
router.include_router(api_admin.make_router(config))
# v0.5.0: §5 / §7 / §10 — PR-less per-RFC discussion endpoints.
# The substrate is the existing threads/thread_messages tables;
# rows whose branch_name IS NULL scope to the RFC's main view.
# Contribution still requires a PR (api_prs above); this surface
# is for discussion that does not yet warrant a branch.
router.include_router(api_discussion.make_router())
# ---------------------------------------------------------------
# §17: /api/health — unauthenticated post-flight probe.
# Used by ops tooling (flotilla) to verify a deploy landed via
# version-match. v1 always reports ok; see backend/app/health.py.
# ---------------------------------------------------------------
@router.get("/api/health")
async def get_health() -> dict[str, Any]:
return {"version": health.VERSION, "status": "ok"}
# ---------------------------------------------------------------
# §14.2: /api/philosophy — PHILOSOPHY.md served verbatim.
@@ -287,7 +305,11 @@ def make_router(
proposed_at=entry_mod.today(),
graduated_at=None,
graduated_by=None,
owners=[],
# §9.2: the proposer is the implicit first owner at propose time.
# The §13.1 claim flow exists for *other* contributors to become
# owners on an RFC they didn't propose; the proposer never needs
# to claim their own RFC.
owners=[user.gitea_login],
arbiters=[],
tags=[t.strip() for t in payload.tags if t.strip()],
body=payload.pitch.strip() + "\n",
+102
View File
@@ -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
+330
View File
@@ -0,0 +1,330 @@
"""§5 / §7 / §10 — PR-less per-RFC discussion endpoints (v0.5.0).
This module surfaces the discussion-without-PR shape committed by the
roadmap's item #3. The substrate is the existing `threads` /
`thread_messages` pair from §5: rows whose `branch_name` is NULL are
scoped to the RFC's main view (the schema comment on the column says
exactly this; until now no write path produced such rows). This module
is the read+write surface for those rows.
Contribution still requires a PR: the §10 PR flow is unchanged, the
branch-scoped chat in `api_branches.py` is unchanged, and accept /
decline of AI `<change>` blocks still lives on a branch. What this
module adds is the "discuss freely about the RFC, no branch yet" surface
a place to drop a question, a flag-style observation, or a multi-turn
conversation that does not yet warrant cutting a branch.
Auth shape mirrors the v0.3.0 anonymous-read contract: reads are open,
writes require `auth.require_contributor`. Item #4 ("anon discuss/
contribute off-limits") tightens the read gate in v0.6.0; v0.5.0's
write gate already holds the line.
Notification routing reuses the existing `fan_out_chat_message` path
with `branch_name=None`; the `notifications.branch_name` column is
nullable, and the inbox row prose ("@alice posted a chat message on
<RFC title>") renders identically whether the chat lives on a branch
or on the RFC's discussion surface. The existing
`chat_message_in_participated_thread` / `chat_reply_to_my_message`
event kinds carry both shapes; introducing a parallel
`open_rfc_discussion_thread` / `post_rfc_discussion_message` enum pair
would split routing without adding signal. The §15 §19.2 candidate
"distinct event_kinds for PR-less discussion" notes the option for a
future session if evidence demands the split.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import auth, chat as chat_layer, db
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Request bodies
# ---------------------------------------------------------------------------
class DiscussionThreadCreateBody(BaseModel):
"""A discussion thread is a `thread_kind='chat'`, `anchor_kind='whole-doc'`,
`branch_name=NULL` row. Anchored-range / per-paragraph threads on the
RFC discussion surface are a §19.2 candidate the schema supports
them; the UI work to surface a range-anchor on a non-branch view is
the deferred part. v0.5.0 keeps the shape narrow."""
label: str | None = Field(default=None, max_length=400)
message: str | None = Field(default=None, max_length=20_000)
class DiscussionMessageBody(BaseModel):
text: str = Field(min_length=1, max_length=20_000)
quote: str | None = Field(default=None, max_length=2000)
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
def make_router() -> APIRouter:
router = APIRouter()
# -------------------------------------------------------------------
# GET /api/rfcs/<slug>/discussion/threads
# Lists every PR-less thread on the RFC. The default whole-doc thread
# is materialized lazily on first list (mirroring the §8.12 branch-
# chat default-thread treatment) so the UI always has a target for
# the compose-message affordance.
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/discussion/threads")
async def list_discussion_threads(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
_require_rfc_readable(slug)
# Ensure the default whole-doc discussion thread exists. We mint
# it on first read regardless of viewer (anonymous viewers can
# trigger the creation — the row's `created_by` is null in that
# case, mirroring `_ensure_branch_chat_thread`).
_ensure_discussion_thread(slug, viewer)
rows = db.conn().execute(
"""
SELECT id, anchor_kind, anchor_payload, thread_kind, label, state,
created_by, created_at, resolved_at, resolved_by
FROM threads
WHERE rfc_slug = ? AND branch_name IS NULL
ORDER BY id
""",
(slug,),
).fetchall()
return {"items": [_serialize_thread(r) for r in rows]}
# -------------------------------------------------------------------
# POST /api/rfcs/<slug>/discussion/threads
# Open a fresh discussion thread. Writes require require_contributor
# — anonymous viewers can read but cannot open a thread, per item
# #4's hardening anticipated in v0.6.0 (we already enforce it here
# to avoid the open window).
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/discussion/threads")
async def create_discussion_thread(
slug: str, body: DiscussionThreadCreateBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_readable(slug)
cur = db.conn().execute(
"""
INSERT INTO threads
(rfc_slug, branch_name, anchor_kind, anchor_payload,
thread_kind, label, created_by)
VALUES (?, NULL, 'whole-doc', NULL, 'chat', ?, ?)
""",
(slug, body.label, viewer.user_id),
)
thread_id = cur.lastrowid
message_id = None
if body.message:
message_id = chat_layer.append_user_message(
thread_id=thread_id,
author_user_id=viewer.user_id,
text=body.message,
quote=None,
)
return {"thread_id": thread_id, "message_id": message_id}
# -------------------------------------------------------------------
# GET /api/rfcs/<slug>/discussion/threads/<thread_id>/messages
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/discussion/threads/{thread_id}/messages")
async def get_discussion_thread_messages(
slug: str, thread_id: int, request: Request
) -> dict[str, Any]:
_viewer = auth.current_user(request)
_require_rfc_readable(slug)
thread = _require_discussion_thread(slug, thread_id)
rows = db.conn().execute(
"""
SELECT m.id, m.role, m.author_user_id,
u.gitea_login AS author_login,
u.display_name AS author_display,
m.model_id, m.text, m.quote, m.created_at
FROM thread_messages m
LEFT JOIN users u ON u.id = m.author_user_id
WHERE m.thread_id = ?
ORDER BY m.id
""",
(thread_id,),
).fetchall()
return {
"thread": _serialize_thread(thread),
"messages": [_serialize_message(r) for r in rows],
}
# -------------------------------------------------------------------
# POST /api/rfcs/<slug>/discussion/threads/<thread_id>/messages
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/discussion/threads/{thread_id}/messages")
async def post_discussion_message(
slug: str, thread_id: int, body: DiscussionMessageBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_readable(slug)
_require_discussion_thread(slug, thread_id)
message_id = chat_layer.append_user_message(
thread_id=thread_id,
author_user_id=viewer.user_id,
text=body.text,
quote=body.quote,
)
return {"ok": True, "message_id": message_id}
# -------------------------------------------------------------------
# POST /api/rfcs/<slug>/discussion/threads/<thread_id>/resolve
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/discussion/threads/{thread_id}/resolve")
async def resolve_discussion_thread(
slug: str, thread_id: int, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_readable(slug)
thread = _require_discussion_thread(slug, thread_id)
if not _can_resolve(rfc, thread, viewer):
raise HTTPException(
403,
"Only the thread creator, an RFC owner/arbiter, or an app admin/owner may resolve",
)
db.conn().execute(
"""
UPDATE threads
SET state = 'resolved',
resolved_by = ?,
resolved_at = datetime('now')
WHERE id = ?
""",
(viewer.user_id, thread_id),
)
return {"ok": True, "thread_id": thread_id}
return router
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _require_rfc_readable(slug: str):
"""Per the v0.3.0 anonymous-read contract: any cached RFC is readable
by anyone. Withdrawn entries refuse reads of every shape same rule
`_require_rfc_with_repo` in `api_branches.py` follows."""
row = db.conn().execute(
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn")
return row
def _require_discussion_thread(slug: str, thread_id: int):
"""A discussion thread is one whose (rfc_slug, branch_name) = (slug,
NULL). Refuse cleanly if the thread id resolves to a branch-scoped
thread instead that lookup belongs on the branch endpoints."""
row = db.conn().execute(
"""
SELECT * FROM threads
WHERE id = ? AND rfc_slug = ? AND branch_name IS NULL
""",
(thread_id, slug),
).fetchone()
if not row:
raise HTTPException(404, "Discussion thread not found")
return row
def _ensure_discussion_thread(slug: str, viewer) -> int:
"""Per the §8.12 lazy-create pattern, materialize a default whole-doc
chat thread on the RFC's discussion surface on first read. Created_by
is null when an anonymous viewer triggers creation the thread is
structurally owned by the RFC, not by whoever opened the view."""
row = db.conn().execute(
"""
SELECT id FROM threads
WHERE rfc_slug = ? AND branch_name IS NULL
AND anchor_kind = 'whole-doc' AND thread_kind = 'chat'
ORDER BY id LIMIT 1
""",
(slug,),
).fetchone()
if row:
return row["id"]
cur = db.conn().execute(
"""
INSERT INTO threads
(rfc_slug, branch_name, anchor_kind, thread_kind, label, created_by)
VALUES (?, NULL, 'whole-doc', 'chat', NULL, ?)
""",
(slug, viewer.user_id if viewer else None),
)
return cur.lastrowid
def _can_resolve(rfc, thread, viewer) -> bool:
if viewer is None:
return False
if viewer.role in ("owner", "admin"):
return True
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")
if viewer.gitea_login in owners or viewer.gitea_login in arbiters:
return True
if thread["created_by"] == viewer.user_id:
return True
return False
# ---------------------------------------------------------------------------
# Serializers — mirror api_branches.py's shape
# ---------------------------------------------------------------------------
def _serialize_thread(row) -> dict[str, Any]:
payload = row["anchor_payload"]
try:
anchor = json.loads(payload) if payload else None
except Exception:
anchor = None
return {
"id": row["id"],
"anchor_kind": row["anchor_kind"],
"anchor_payload": anchor,
"thread_kind": row["thread_kind"],
"label": row["label"],
"state": row["state"],
"created_by": row["created_by"],
"created_at": row["created_at"],
"resolved_at": row["resolved_at"] if "resolved_at" in row.keys() else None,
"resolved_by": row["resolved_by"] if "resolved_by" in row.keys() else None,
}
def _serialize_message(row) -> dict[str, Any]:
return {
"id": row["id"],
"role": row["role"],
"author_user_id": row["author_user_id"],
"author_login": row["author_login"],
"author_display": row["author_display"],
"model_id": row["model_id"],
"text": row["text"],
"quote": row["quote"],
"created_at": row["created_at"],
}
+10 -1
View File
@@ -520,7 +520,16 @@ def make_router(
@router.get("/api/rfcs/{slug}/graduate/progress")
async def graduate_progress(slug: str, request: Request):
del request
# v0.6.0 (item #4): the progress SSE surfaces admin-internal step
# detail (repo name, PR number, rollback steps) that isn't part of
# the v0.3.0 anonymous-read contract for catalog/RFC bodies. The
# corresponding POST /graduate is gated to RFC owners/arbiters and
# app admins/owners via `_can_graduate`; the read SSE shares that
# operator-visible surface, so it requires at least an
# authenticated viewer. We keep the floor at require_user (not
# require_contributor) so a write-muted operator can still observe
# the progress of a graduation they kicked off before being muted.
auth.require_user(request)
state = _get_active(slug)
if state is None:
raise HTTPException(404, "No graduation in flight for this slug")
+37
View File
@@ -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.
+6 -1
View File
@@ -168,10 +168,15 @@ def _fan_out_chat(thread_id: int, author_user_id: int, message_id: int) -> None:
).fetchone()
if pr_row:
pr_number = pr_row["pr_number"]
# v0.5.0 (§5 / §10 — PR-less discussion): a thread with
# branch_name IS NULL is scoped to the RFC's main view. Pass None
# through to the notify chokepoint so the notifications row keeps
# `branch_name` null — coercing it to "main" would misroute the
# §15.7 chat-seen reconciler (which keys on branch_name).
notify.fan_out_chat_message(
actor_user_id=author_user_id,
rfc_slug=row["rfc_slug"],
branch_name=row["branch_name"] or "main",
branch_name=row["branch_name"],
thread_id=thread_id,
message_id=message_id,
is_review_thread=(row["thread_kind"] == "review"),
+37
View File
@@ -0,0 +1,37 @@
"""§17 health-check endpoint source.
A small unauthenticated probe used by ops tooling (e.g. the flotilla
deploy control panel) to verify a deploy landed correctly. The
structural value is the version-match check after `systemctl
restart` reports active, the operator polls `/api/health` and
verifies the returned `version` equals the tag just deployed,
catching the failure mode where a restart did not pick up the new
code.
The version is read from the `VERSION` file at the repo root at
import time (§20.1's canonical source) and cached as a module-level
constant. A missing `VERSION` fails loudly per §20.6 rather than
serving a placeholder a silent default would defeat the
version-match check that is the entire point of the endpoint.
`status` is always `"ok"` (HTTP 200) in v1. The `"degraded"` / 503
path stays in the response shape so a later release can wire real
degradation conditions (reconciler stuck, migrations pending,
provider universe empty) without breaking the contract.
"""
from __future__ import annotations
from pathlib import Path
_VERSION_PATH = Path(__file__).resolve().parents[2] / "VERSION"
def _read_version() -> str:
text = _VERSION_PATH.read_text(encoding="utf-8").strip()
if not text:
raise RuntimeError(f"VERSION file at {_VERSION_PATH} is empty")
return text
VERSION: str = _read_version()
+6
View File
@@ -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("/")
+9 -1
View File
@@ -212,7 +212,7 @@ def fan_out_chat_message(
*,
actor_user_id: int,
rfc_slug: str,
branch_name: str,
branch_name: str | None,
thread_id: int,
message_id: int,
is_review_thread: bool = False,
@@ -227,6 +227,14 @@ def fan_out_chat_message(
(state='watching', i.e. full stream) get a churn-class
`chat_message_in_participated_thread`. The two are union'd so a user
who is both gets only the personal-direct row.
v0.5.0: `branch_name` may be None that is the PR-less per-RFC
discussion shape (`threads.branch_name IS NULL`, §5). The
notifications row carries the null through; the inbox prose renders
identically whether the chat lives on a branch or on the RFC's
discussion surface, and the §15.7 reconciler keys on
(rfc_slug, branch_name) so a null branch correctly matches the
PR-less discussion's eventual chat-seen-equivalent advance.
"""
_bump_auto_watch(actor_user_id, rfc_slug)
+22
View File
@@ -0,0 +1,22 @@
-- Private-beta email allowlist.
--
-- The framework supports a deployment-gated sign-in mode: when this
-- table contains rows, only emails listed here (case-insensitively)
-- may sign in via OAuth. Users already provisioned in the `users`
-- table are grandfathered in by gitea_id and never re-checked against
-- this list — so the operator who allow-listed themselves, signed in
-- once, then removed their own email from the list does not lose
-- access.
--
-- An empty `allowed_emails` table is the "open" state: no allowlist
-- gate runs, and any successful OAuth sign-in provisions a new user
-- as before. This means a fresh framework install behaves exactly as
-- prior versions until the operator adds the first row, at which
-- point the gate turns on for everyone not yet in `users`.
CREATE TABLE allowed_emails (
email TEXT PRIMARY KEY COLLATE NOCASE,
added_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -0,0 +1,476 @@
"""v0.6.0 (roadmap item #4) — "anon discuss + contribute off-limits"
vertical.
A sweep-the-edges hardening release. The v0.3.0 release hid the write
affordances from anonymous viewers; v0.5.0 added the PR-less discussion
surface with its own write gate. v0.6.0 audits both: every write-shaped
endpoint refuses anonymous callers with 401 (or 403 when the role check
runs after the auth check), and every anonymous-read surface stays
reachable.
This test is the regression net for the audit. It walks each module's
representative write endpoint as an anonymous client and asserts the
401/403, then walks the same surfaces' representative read endpoints
as anonymous and asserts the 200. The intent is breadth over depth:
one assertion per write endpoint family is enough to catch a
regression where someone strips the `auth.require_contributor` line.
Endpoints covered (one or two from each module):
- api.py: propose, decline (admin), withdraw,
funder credentials POST/DELETE, funder consent
POST/DELETE
- api_branches.py: promote-to-branch, start-edit-branch, metadata,
manual-flush, visibility, grants POST/DELETE,
threads POST, thread messages POST, resolve,
chat-seen, change accept/decline/reask
- api_prs.py: pr-draft, open-pr, seen, review, merge, withdraw,
description, resolution-branch
- api_discussion.py: thread create, message post, resolve
- api_admin.py: role POST, mute POST, allowlist POST/DELETE
- api_notifications.py: prefs POST, watch POST, mark-read POST,
quiet-hours POST, user-mute POST/DELETE
- api_graduation.py: graduate POST, claim POST, progress GET
The §15.7 reads (`/api/notifications`, `/api/watches`,
`/api/users/me/*`) are per-user surfaces they require an
authenticated viewer by definition; an anonymous 401 on those reads is
shape-correct, not a regression. The test does not assert reads on
those.
"""
from __future__ import annotations
import pytest
# Reuse the fixture / session / fake-Gitea harness from Slice 1.
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_anonymous_can_read_every_public_surface(app_with_fake_gitea):
"""Per §14 / the v0.3.0 anonymous-read contract: the catalog, the
RFC view, the PR-less discussion surface, the philosophy page, and
the health probe must remain reachable for unauthenticated viewers.
This is the read side of the item #4 contract — the read surfaces
must NOT regress to require auth as the write gates tighten.
"""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# No session cookie — viewer is anonymous.
client.cookies.clear()
# The five read surfaces an anonymous viewer must reach.
assert client.get("/api/health").status_code == 200
assert client.get("/api/philosophy").status_code == 200
assert client.get("/api/auth/me").status_code == 200
assert client.get("/api/rfcs").status_code == 200
assert client.get("/api/rfcs/ohm").status_code == 200
assert client.get("/api/rfcs/ohm/main").status_code == 200
assert client.get("/api/rfcs/ohm/discussion/threads").status_code == 200
assert client.get("/api/proposals").status_code == 200
def test_anonymous_propose_refused(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
r = client.post(
"/api/rfcs/propose",
json={"title": "X", "slug": "x", "pitch": "p", "tags": []},
)
assert r.status_code == 401
def test_anonymous_proposal_admin_paths_refused(app_with_fake_gitea):
"""The admin-gated proposal actions — merge, decline — must refuse
anonymous callers with 401 (the auth check runs before the role
check; both refusals are correct, but 401 is the structural signal
"no session at all")."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
# PR number doesn't need to exist — the gate runs first.
assert client.post("/api/proposals/1/merge").status_code == 401
assert (
client.post("/api/proposals/1/decline", json={"comment": "no"}).status_code
== 401
)
assert client.post("/api/proposals/1/withdraw").status_code == 401
def test_anonymous_branch_writes_refused_on_active_rfc(app_with_fake_gitea):
"""Branch-scoped writes on an active RFC: promote-to-branch,
manual-flush, visibility, grants, threads create, message post,
resolve, chat-seen, change accept/decline/reask. All must 401 for
anonymous callers."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
client.cookies.clear()
# Branch-scoped writes — slug + branch values are placeholders;
# the auth gate runs before any state lookup.
slug = "ohm"
branch = "feature-x"
assert (
client.post(
f"/api/rfcs/{slug}/branches/main/promote-to-branch",
json={},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/manual-flush",
json={"new_content": "hi", "paragraph_count": 1},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/visibility",
json={"read_public": False},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/grants",
json={"grantee_gitea_login": "alice"},
).status_code == 401
)
assert (
client.delete(
f"/api/rfcs/{slug}/branches/{branch}/grants/alice",
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/threads",
json={"thread_kind": "chat", "anchor_kind": "whole-doc"},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/threads/1/messages",
json={"text": "hi"},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/threads/1/resolve",
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/chat-seen",
json={"last_seen_message_id": 1},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/changes/1/accept",
json={"proposed": "x"},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/changes/1/decline",
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/changes/1/reask",
).status_code == 401
)
# Chat stream — POST shaped, same auth gate.
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/threads/1/chat",
json={"text": "hi"},
).status_code == 401
)
def test_anonymous_super_draft_writes_refused(app_with_fake_gitea):
"""Super-draft-scoped writes: start-edit-branch and metadata. The
PR open / merge paths share the gate via api_prs.py see the
PR-flow test below for those."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
assert (
client.post(
"/api/rfcs/anything/start-edit-branch", json={}
).status_code == 401
)
assert (
client.post(
"/api/rfcs/anything/metadata", json={"title": "x"}
).status_code == 401
)
def test_anonymous_pr_flow_writes_refused(app_with_fake_gitea):
"""All §10 PR-flow writes — open, merge, withdraw, description,
review, seen, pr-draft, resolution-branch must 401 for anonymous."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
client.cookies.clear()
slug, branch, pr = "ohm", "feature-x", 1
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/pr-draft"
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/open-pr",
json={"title": "t", "description": "d"},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/seen",
json={"last_seen_message_id": 1},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/review",
json={"text": "x", "anchor_payload": {}},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/merge"
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/withdraw"
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/description",
json={"title": "t", "description": "d"},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/resolution-branch"
).status_code == 401
)
def test_anonymous_discussion_writes_refused(app_with_fake_gitea):
"""The v0.5.0 PR-less discussion surface — write gates must hold.
This duplicates the assertion in `test_discussion_vertical.py` and
keeps it here too as the canonical home for the item #4 audit."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
client.cookies.clear()
assert (
client.post(
"/api/rfcs/ohm/discussion/threads",
json={"message": "drive-by"},
).status_code == 401
)
assert (
client.post(
"/api/rfcs/ohm/discussion/threads/1/messages",
json={"text": "drive-by"},
).status_code == 401
)
assert (
client.post(
"/api/rfcs/ohm/discussion/threads/1/resolve"
).status_code == 401
)
def test_anonymous_admin_writes_refused(app_with_fake_gitea):
"""Admin surfaces — role, mute, allowlist — refuse anonymous.
The auth check runs before the require_admin role check, so the
response is 401."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
assert (
client.post(
"/api/admin/users/1/role", json={"role": "admin"}
).status_code == 401
)
assert (
client.post(
"/api/admin/users/1/mute", json={"muted": True}
).status_code == 401
)
assert (
client.post(
"/api/admin/allowlist", json={"email": "x@y.z"}
).status_code == 401
)
assert (
client.delete("/api/admin/allowlist/x@y.z").status_code == 401
)
# Admin reads also gated.
assert client.get("/api/admin/users").status_code == 401
assert client.get("/api/admin/audit").status_code == 401
assert client.get("/api/admin/permission-events").status_code == 401
assert client.get("/api/admin/graduation-queue").status_code == 401
assert client.get("/api/admin/allowlist").status_code == 401
def test_anonymous_notification_writes_refused(app_with_fake_gitea):
"""Notification preference / watch / mark-read / user-mute writes —
all per-user surfaces, all require an authenticated viewer."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
client.cookies.clear()
assert (
client.post(
"/api/users/me/notification-preferences",
json={"email_personal_direct": False},
).status_code == 401
)
assert (
client.post(
"/api/users/me/quiet-hours",
json={"start": None, "end": None, "timezone": None},
).status_code == 401
)
assert (
client.post("/api/rfcs/ohm/watch", json={"state": "watching"}).status_code
== 401
)
assert client.post("/api/notifications/1/read").status_code == 401
assert (
client.post("/api/notifications/read", json={}).status_code == 401
)
assert client.post("/api/users/1/notification-mute").status_code == 401
assert client.delete("/api/users/1/notification-mute").status_code == 401
def test_anonymous_funder_writes_refused(app_with_fake_gitea):
"""§6.7 funder credential + consent writes — registering a key,
consenting to fund all refuse anonymous callers."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
client.cookies.clear()
assert (
client.post(
"/api/users/me/funder/credentials",
json={"provider": "anthropic", "api_key": "sk-test"},
).status_code == 401
)
assert (
client.delete(
"/api/users/me/funder/credentials/anthropic"
).status_code == 401
)
assert (
client.post("/api/rfcs/ohm/funder/consent").status_code == 401
)
assert (
client.delete("/api/rfcs/ohm/funder/consent").status_code == 401
)
def test_anonymous_graduation_writes_refused(app_with_fake_gitea):
"""§13 graduation: the POST kickoff and POST claim both refuse
anonymous. The progress SSE was gated to require_user in v0.6.0
(item #4) since it surfaces admin-internal step detail."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
assert (
client.post(
"/api/rfcs/anything/graduate",
json={
"rfc_id": "RFC-0001",
"repo_name": "rfc-0001-x",
"owners": ["alice"],
},
).status_code == 401
)
assert client.post("/api/rfcs/anything/claim").status_code == 401
# v0.6.0 tightening: progress SSE now requires require_user.
# No graduation is in flight, but the auth check runs first.
assert (
client.get("/api/rfcs/anything/graduate/progress").status_code == 401
)
def test_anonymous_can_read_published_pr_view(app_with_fake_gitea):
"""The PR review page is §11.3 universal-public — once a PR is
open, anonymous viewers can read it. This guards against a
regression where the read endpoint accidentally grows an auth
gate."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# Seed an open PR row directly — the cache shape is enough for
# the read endpoint; the live Gitea fetch falls back gracefully.
db.conn().execute(
"""
INSERT INTO cached_prs
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
opened_by, opened_at, head_branch, base_branch, head_sha)
VALUES ('ohm', 'rfc_branch', 'wiggleverse/rfc-0001-ohm', 7, 't', 'd',
'open', 'alice', datetime('now'), 'feature-x', 'main', 'sha7')
"""
)
client.cookies.clear()
# Anonymous read on an open PR: should be 200. The endpoint may
# surface a partial response (the FakeGitea won't have the head
# branch's RFC.md, so branch_body falls back to empty) but the
# auth gate must let the read through.
r = client.get("/api/rfcs/ohm/prs/7")
assert r.status_code == 200
body = r.json()
assert body["capabilities"]["is_anonymous"] is True
assert body["capabilities"]["can_merge"] is False
assert body["capabilities"]["can_post_review"] is False
+237
View File
@@ -0,0 +1,237 @@
"""End-to-end integration tests for the v0.5.0 PR-less discussion
surface roadmap item #3, "discussion without PR; contribution requires
PR."
The vertical: an active RFC exists; the discussion endpoints under
`/api/rfcs/<slug>/discussion/...` open threads with
`threads.branch_name IS NULL`, post messages into them, and surface
them on subsequent reads. Branch-scoped threads (the §8.12 surface)
remain segregated. Anonymous viewers can read; only signed-in
contributors can write.
"""
from __future__ import annotations
import pytest
# Reuse the harness from Slice 1 / Slice 2.
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_rfc_view_vertical import seed_active_rfc, SEED_BODY
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_create_and_post_to_pr_less_discussion_thread(app_with_fake_gitea):
"""The vertical: signed-in contributor opens a thread on the RFC's
discussion surface, posts a message, and the thread + message
surface on subsequent reads with branch_name IS NULL."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="alice", display_name="Alice", role="contributor")
# Listing materializes the default whole-doc thread.
r = client.get("/api/rfcs/ohm/discussion/threads")
assert r.status_code == 200, r.text
items = r.json()["items"]
assert len(items) == 1
default_thread_id = items[0]["id"]
assert items[0]["anchor_kind"] == "whole-doc"
assert items[0]["thread_kind"] == "chat"
# Open an additional discussion thread with a first message.
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Question about §3", "message": "Is consent baked into the trait model?"},
)
assert r.status_code == 200, r.text
payload = r.json()
thread_id = payload["thread_id"]
message_id = payload["message_id"]
assert thread_id is not None and message_id is not None
# Confirm the row carries branch_name IS NULL (the PR-less shape).
row = db.conn().execute(
"SELECT rfc_slug, branch_name, thread_kind, anchor_kind, created_by FROM threads WHERE id = ?",
(thread_id,),
).fetchone()
assert row["rfc_slug"] == "ohm"
assert row["branch_name"] is None
assert row["thread_kind"] == "chat"
assert row["anchor_kind"] == "whole-doc"
assert row["created_by"] == 1
# The thread surfaces on the list endpoint alongside the default.
r = client.get("/api/rfcs/ohm/discussion/threads")
ids = [t["id"] for t in r.json()["items"]]
assert default_thread_id in ids
assert thread_id in ids
# Posting a reply on the new thread persists and returns the id.
r = client.post(
f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages",
json={"text": "Following up — see §3.2."},
)
assert r.status_code == 200, r.text
reply_id = r.json()["message_id"]
# The messages read endpoint returns both messages in order.
r = client.get(f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages")
assert r.status_code == 200
messages = r.json()["messages"]
assert [m["id"] for m in messages] == [message_id, reply_id]
assert messages[0]["author_login"] == "alice"
assert messages[0]["text"].startswith("Is consent")
def test_anonymous_can_read_but_cannot_post_discussion(app_with_fake_gitea):
"""Per the v0.3.0 anonymous-read contract: reads on the discussion
surface are open; write attempts return 401. v0.6.0 (item #4) will
tighten the read gate v0.5.0 holds the write line so there is no
open window between releases."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# Seed the discussion thread + first message as Alice.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"message": "First."},
)
assert r.status_code == 200
thread_id = r.json()["thread_id"]
# Drop the session — viewer is anonymous now.
client.cookies.clear()
# Reads are open.
r = client.get("/api/rfcs/ohm/discussion/threads")
assert r.status_code == 200
assert any(t["id"] == thread_id for t in r.json()["items"])
r = client.get(f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages")
assert r.status_code == 200
assert len(r.json()["messages"]) >= 1
# Writes refuse 401.
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"message": "Drive-by."},
)
assert r.status_code == 401
r = client.post(
f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages",
json={"text": "Drive-by reply."},
)
assert r.status_code == 401
def test_discussion_threads_and_branch_threads_are_segregated(app_with_fake_gitea):
"""A branch-scoped thread (the §8.12 surface, branch_name='main' or a
feature branch) MUST NOT surface on the discussion endpoint, which
is keyed on branch_name IS NULL. The two surfaces share a table; the
null-filter is what segregates them."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=3, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice", role="contributor")
# Manually materialize a branch-scoped thread on a feature branch.
db.conn().execute(
"""
INSERT INTO threads
(rfc_slug, branch_name, anchor_kind, thread_kind, label, created_by)
VALUES ('ohm', 'alice-draft-aa00', 'whole-doc', 'chat', NULL, 3)
"""
)
# And one on the discussion surface.
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"message": "Discussion-surface message."},
)
assert r.status_code == 200
discussion_thread_id = r.json()["thread_id"]
# The discussion list contains the null-branch thread (plus the
# default whole-doc) and excludes the feature-branch thread.
r = client.get("/api/rfcs/ohm/discussion/threads")
assert r.status_code == 200
ids = [t["id"] for t in r.json()["items"]]
assert discussion_thread_id in ids
# Feature-branch thread MUST NOT surface.
branch_thread_row = db.conn().execute(
"SELECT id FROM threads WHERE branch_name = 'alice-draft-aa00'"
).fetchone()
assert branch_thread_row is not None
assert branch_thread_row["id"] not in ids
def test_discussion_thread_resolve_permissions(app_with_fake_gitea):
"""A thread's creator can resolve it; an unrelated contributor cannot;
an admin / owner / RFC-owner can. Mirrors §8.12's resolution rule for
branch-scoped threads."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=4, login="alice", role="contributor")
provision_user_row(user_id=5, login="bob", role="contributor")
provision_user_row(user_id=6, login="ben", role="owner")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=4, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Alice's thread", "message": "..."},
)
thread_id = r.json()["thread_id"]
# Unrelated contributor refused.
sign_in_as(client, user_id=5, gitea_login="bob", display_name="Bob", role="contributor")
r = client.post(f"/api/rfcs/ohm/discussion/threads/{thread_id}/resolve")
assert r.status_code == 403
# Creator allowed.
sign_in_as(client, user_id=4, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post(f"/api/rfcs/ohm/discussion/threads/{thread_id}/resolve")
assert r.status_code == 200
# Open another thread, resolve it as the owner.
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Another thread", "message": "..."},
)
thread_id2 = r.json()["thread_id"]
sign_in_as(client, user_id=6, gitea_login="ben", display_name="Ben", role="owner")
r = client.post(f"/api/rfcs/ohm/discussion/threads/{thread_id2}/resolve")
assert r.status_code == 200
def test_discussion_404_on_unknown_rfc(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/api/rfcs/nonexistent/discussion/threads")
assert r.status_code == 404
+68
View File
@@ -0,0 +1,68 @@
"""Integration tests for the §17 `/api/health` endpoint.
The endpoint is the structural framework-side dependency for the
flotilla deploy control panel see SPEC.md §17 and the §19.2
candidate-topic settlement. The tests cover the contract: HTTP 200,
JSON `{version, status}`, `version` matches `VERSION` at the repo
root, `status` is `"ok"` in v1, and no auth gate is in front of it.
"""
from __future__ import annotations
from pathlib import Path
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea,
tmp_env,
)
_VERSION_PATH = Path(__file__).resolve().parents[2] / "VERSION"
def test_health_returns_version_and_ok_status(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
expected_version = _VERSION_PATH.read_text(encoding="utf-8").strip()
with TestClient(app) as client:
r = client.get("/api/health")
assert r.status_code == 200, r.text
payload = r.json()
assert payload == {"version": expected_version, "status": "ok"}
def test_health_is_unauthenticated(app_with_fake_gitea):
"""flotilla polls /api/health without credentials; the endpoint
must not require a session cookie. The §17 entry names it
unauthenticated by design no PII in the payload, version-match
is the whole structural value, and gating it would force ops
tooling to hold a long-lived token for a check that needs none.
"""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# No sign-in step. A fresh client has no session cookie.
r = client.get("/api/health")
assert r.status_code == 200, r.text
assert "version" in r.json()
def test_health_version_matches_VERSION_file(app_with_fake_gitea):
"""The §20.1 invariant: VERSION at the repo root is the canonical
source. The endpoint reports the same string verbatim, so flotilla's
post-flight version-match check can compare against the tag
operators just deployed without a transform step.
"""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
raw = _VERSION_PATH.read_text(encoding="utf-8")
expected = raw.strip()
# The file itself carries no leading "v" per §20.1, and the endpoint
# mirrors that — a flotilla check comparing against `v0.2.3` would
# need to strip the prefix on its side, not ours.
assert not expected.startswith("v"), "VERSION must not carry a leading v per §20.1"
with TestClient(app) as client:
r = client.get("/api/health")
assert r.json()["version"] == expected
+40
View File
@@ -496,6 +496,11 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
proposal = r.json()
assert proposal["entry"]["title"] == "Open Human Model"
assert proposal["entry"]["state"] == "super-draft"
# §9.2: the proposer is the implicit first owner at propose time.
# The owners field is a single-element list containing exactly the
# session user's gitea_login — no request-supplied owner field
# exists or is honored.
assert proposal["entry"]["owners"] == ["alice"]
assert proposal["affordances"]["merge"] is True
# Owner merges. The catalog picks up the new super-draft.
@@ -516,6 +521,10 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
view = r.json()
assert view["state"] == "super-draft"
assert "shared definition" in view["body"]
# §9.2: the auto-set proposer-owner survives the meta-repo round-trip
# — it's in the file's frontmatter on main after merge, not just
# in the pending-PR view above.
assert view["owners"] == ["alice"]
# The pending-ideas list no longer carries the merged proposal.
r = client.get("/api/proposals")
@@ -568,6 +577,37 @@ def test_anonymous_cannot_propose(app_with_fake_gitea):
assert r.status_code == 401
def test_proposer_is_auto_owner_request_payload_ignored(app_with_fake_gitea):
"""§9.2: the owners field on the new entry is always exactly
`[session.gitea_login]`. The propose endpoint never accepts an owner
from the client; a request payload that smuggles one in is ignored
by the Pydantic body model and the auto-set value lands instead.
"""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=11, login="alice", role="contributor")
sign_in_as(client, user_id=11, gitea_login="alice", display_name="Alice", role="contributor")
# Extra unknown fields like `owners` are dropped by the
# ProposeBody model; the session user is the only source of truth.
r = client.post("/api/rfcs/propose", json={
"title": "Spoof attempt",
"slug": "spoof-attempt",
"pitch": "p",
"tags": [],
"owners": ["mallory", "eve"],
"proposed_by": "mallory@test",
})
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
r = client.get(f"/api/proposals/{pr_number}")
assert r.status_code == 200, r.text
entry = r.json()["entry"]
assert entry["owners"] == ["alice"]
# proposed_by also comes from the session, never the body.
assert entry["proposed_by"] in ("alice@test", "alice")
def test_withdraw_by_proposer_works(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
+23 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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
+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=
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rfc-app-frontend",
"version": "0.2.1",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
"version": "0.2.1",
"version": "0.6.0",
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.2.2",
"version": "0.6.0",
"type": "module",
"scripts": {
"dev": "vite",
+175 -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 {
@@ -1690,3 +1776,91 @@
.grad-queue-link:hover strong { text-decoration: underline; }
.muted { color: #6b7280; }
.error { color: #b91c1c; }
/* v0.5.0 PR-less per-RFC discussion panel (RFCDiscussionPanel.jsx).
Visual neighbor of .chat-panel but distinct: discussion lives on the
RFC, branch chat lives on the branch. Same flex column shape so it
slots cleanly into the existing .right-panel container.
*/
.discussion-panel {
flex: 1; display: flex; flex-direction: column;
overflow: hidden; min-height: 0;
}
.discussion-header {
padding: 10px 14px;
border-bottom: 1px solid #f0f0ee;
background: #fafafa;
display: flex; flex-direction: column; gap: 4px;
}
.discussion-header-title { font-size: 12px; color: #555; font-weight: 600; }
.discussion-header-meta { font-size: 11px; color: #888; }
.discussion-thread-tabs {
display: flex; gap: 4px; flex-wrap: wrap;
padding: 6px 14px;
border-bottom: 1px solid #f0f0ee;
background: #fcfcfb;
}
.discussion-thread-tab {
background: #fff; border: 1px solid #e5e5e0; cursor: pointer;
font-size: 11px; color: #555;
padding: 3px 8px; border-radius: 999px;
}
.discussion-thread-tab.active {
background: #eef2ff; border-color: #5b5bd6; color: #3737a0;
}
.discussion-thread-tab.resolved { opacity: 0.6; }
.discussion-messages {
flex: 1; overflow-y: auto;
padding: 14px;
display: flex; flex-direction: column; gap: 10px;
}
.discussion-empty {
flex: 1; display: flex; align-items: center; justify-content: center;
text-align: center; padding: 24px;
}
.discussion-empty p {
font-size: 13px; color: #999; line-height: 1.6; max-width: 280px;
}
.discussion-error {
background: #fee; border: 1px solid #fcc; color: #b91c1c;
padding: 8px 10px; border-radius: 4px; font-size: 12px;
}
.discussion-message { display: flex; flex-direction: column; gap: 3px; }
.discussion-message-meta {
display: flex; gap: 8px; font-size: 11px; color: #888;
}
.discussion-message-author { color: #5b5bd6; font-weight: 500; }
.discussion-message-quote {
font-size: 11px; color: #666; font-style: italic;
border-left: 2px solid #ddd; padding-left: 8px; margin-bottom: 2px;
}
.discussion-message-body {
font-size: 13px; color: #222; line-height: 1.5;
white-space: pre-wrap; word-wrap: break-word;
background: #f7f7f5; padding: 8px 10px; border-radius: 6px;
}
.discussion-message.system .discussion-system-bubble {
font-size: 12px; color: #888; font-style: italic;
text-align: center; padding: 4px 0;
}
.discussion-composer {
border-top: 1px solid #f0f0ee;
padding: 10px 14px;
background: #fafafa;
display: flex; flex-direction: column; gap: 6px;
}
.discussion-composer-textarea {
width: 100%; resize: vertical; min-height: 60px;
font-family: inherit; font-size: 13px;
border: 1px solid #ddd; border-radius: 4px;
padding: 6px 8px;
}
.discussion-composer-textarea:focus {
outline: none; border-color: #5b5bd6;
}
.discussion-composer-actions {
display: flex; gap: 8px; align-items: center; justify-content: flex-end;
}
.discussion-readonly {
font-size: 12px; color: #666; padding: 4px 0;
}
+82 -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,60 +86,80 @@ 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
viewer={viewer}
onClose={() => setProposeOpen(false)}
onSubmitted={({ pr_number }) => {
setProposeOpen(false)
@@ -150,7 +168,7 @@ export default function App() {
}}
/>
)}
{inboxOpen && (
{inboxOpen && viewer && (
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
)}
<ToastHost />
@@ -158,14 +176,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 +205,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>
+64
View File
@@ -197,6 +197,52 @@ export async function resolveThread(slug, branch, threadId) {
return jsonOrThrow(res)
}
// ── v0.5.0: PR-less per-RFC discussion (§5 / §10) ────────────────────────
//
// The substrate is `threads.branch_name IS NULL` — the same threads
// table the branch chat uses, with a null branch the schema already
// supported. Contribution still requires a PR (api_prs / openPR), so
// these endpoints are read+write for discussion only.
export async function listDiscussionThreads(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/discussion/threads`))
}
export async function createDiscussionThread(slug, { label = null, message = null } = {}) {
const res = await fetch(`/api/rfcs/${slug}/discussion/threads`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label, message }),
})
return jsonOrThrow(res)
}
export async function getDiscussionThreadMessages(slug, threadId) {
return jsonOrThrow(await fetch(
`/api/rfcs/${slug}/discussion/threads/${threadId}/messages`,
))
}
export async function postDiscussionMessage(slug, threadId, { text, quote = null }) {
const res = await fetch(
`/api/rfcs/${slug}/discussion/threads/${threadId}/messages`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, quote }),
},
)
return jsonOrThrow(res)
}
export async function resolveDiscussionThread(slug, threadId) {
const res = await fetch(
`/api/rfcs/${slug}/discussion/threads/${threadId}/resolve`,
{ method: 'POST' },
)
return jsonOrThrow(res)
}
// ── Slice 4: super-draft body editing (§9.5) ─────────────────────────────
export async function startEditBranch(slug, body = {}) {
@@ -534,6 +580,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>
)
+7 -1
View File
@@ -20,7 +20,7 @@ function slugify(title) {
.replace(/^-+|-+$/g, '')
}
export default function ProposeModal({ onClose, onSubmitted }) {
export default function ProposeModal({ viewer, onClose, onSubmitted }) {
const [title, setTitle] = useState('')
const [slug, setSlug] = useState('')
const [slugEdited, setSlugEdited] = useState(false)
@@ -131,6 +131,12 @@ export default function ProposeModal({ onClose, onSubmitted }) {
</div>
)}
{viewer && (
<p className="field-help" style={{ marginTop: 14, marginBottom: 0 }}>
Owner: <strong>{viewer.display_name || viewer.gitea_login}</strong> you'll be the first owner of this super-draft. Additional owners can claim later (§13.1).
</p>
)}
{error && <p className="field-error">{error}</p>}
</div>
<div className="modal-actions">
@@ -0,0 +1,290 @@
// RFCDiscussionPanel.jsx v0.5.0's PR-less per-RFC discussion surface.
//
// Roadmap item #3: an RFC's main view now has a discussion surface
// distinct from PR comments and from branch chat. The substrate is the
// existing threads/thread_messages tables rows with
// `threads.branch_name IS NULL` scope to "the RFC, no branch yet."
//
// Reused as the right-column panel on `branchParam === 'main'`. Branch
// chat (ChatPanel.jsx) keeps its existing role for branch-scoped work,
// including PRs. Contribution remains gated behind opening a PR
// nothing here writes to the document.
import { useCallback, useEffect, useRef, useState } from 'react'
import {
createDiscussionThread,
getDiscussionThreadMessages,
listDiscussionThreads,
postDiscussionMessage,
resolveDiscussionThread,
} from '../api'
export default function RFCDiscussionPanel({ slug, viewer }) {
const [threads, setThreads] = useState([])
const [messagesByThread, setMessagesByThread] = useState({})
const [composer, setComposer] = useState('')
const [activeThreadId, setActiveThreadId] = useState(null)
const [error, setError] = useState(null)
const [sending, setSending] = useState(false)
const bottomRef = useRef(null)
// Pull threads + messages on mount / slug change.
useEffect(() => {
if (!slug) return
let cancelled = false
setError(null)
setThreads([])
setMessagesByThread({})
setActiveThreadId(null)
listDiscussionThreads(slug)
.then(async ({ items }) => {
if (cancelled) return
setThreads(items || [])
// Pre-load messages for each thread. The list is small (per-RFC,
// not per-branch) so a fan-out fetch is fine; §19.2 candidate
// for paging if a hot RFC accumulates lots of threads.
const collected = {}
for (const t of items || []) {
try {
const { messages } = await getDiscussionThreadMessages(slug, t.id)
collected[t.id] = messages
} catch {
collected[t.id] = []
}
}
if (!cancelled) {
setMessagesByThread(collected)
// Default the active thread to the system's lazy whole-doc
// default (the first row with anchor_kind='whole-doc' and
// no label) so the composer wires to a real id immediately.
const dflt = (items || []).find(
t => t.anchor_kind === 'whole-doc' && !t.label,
)
setActiveThreadId(dflt?.id || items?.[0]?.id || null)
}
})
.catch(err => { if (!cancelled) setError(err.message) })
return () => { cancelled = true }
}, [slug])
// Scroll to bottom when messages land in the active thread.
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [activeThreadId, messagesByThread[activeThreadId]?.length])
const handleSend = useCallback(async () => {
if (!viewer) { window.location.href = '/auth/login'; return }
const text = composer.trim()
if (!text || sending) return
setSending(true)
setError(null)
try {
// If no thread yet, mint one with the message as its first turn.
if (!activeThreadId) {
const { thread_id, message_id } = await createDiscussionThread(slug, { message: text })
// Re-pull authoritative state the default whole-doc thread
// existed pre-this call (the GET creates it lazily), so we
// either get the existing default's id back from the new
// thread's row or the prior default; either way the list call
// is the source of truth.
const { items } = await listDiscussionThreads(slug)
setThreads(items || [])
const { messages } = await getDiscussionThreadMessages(slug, thread_id)
setMessagesByThread(prev => ({ ...prev, [thread_id]: messages }))
setActiveThreadId(thread_id)
void message_id
} else {
const { message_id } = await postDiscussionMessage(slug, activeThreadId, { text })
const { messages } = await getDiscussionThreadMessages(slug, activeThreadId)
setMessagesByThread(prev => ({ ...prev, [activeThreadId]: messages }))
void message_id
}
setComposer('')
} catch (err) {
setError(err.message)
} finally {
setSending(false)
}
}, [composer, sending, viewer, slug, activeThreadId])
const handleNewThread = useCallback(async () => {
if (!viewer) { window.location.href = '/auth/login'; return }
setError(null)
try {
const { thread_id } = await createDiscussionThread(slug, { label: null, message: null })
const { items } = await listDiscussionThreads(slug)
setThreads(items || [])
setActiveThreadId(thread_id)
setMessagesByThread(prev => ({ ...prev, [thread_id]: [] }))
} catch (err) {
setError(err.message)
}
}, [viewer, slug])
const handleResolve = useCallback(async (threadId) => {
if (!viewer) return
setError(null)
try {
await resolveDiscussionThread(slug, threadId)
const { items } = await listDiscussionThreads(slug)
setThreads(items || [])
} catch (err) {
setError(err.message)
}
}, [viewer, slug])
const onKeyDown = useCallback((e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
handleSend()
}
}, [handleSend])
const activeThread = threads.find(t => t.id === activeThreadId) || null
const activeMessages = messagesByThread[activeThreadId] || []
const openThreads = threads.filter(t => t.state === 'open')
return (
<div className="discussion-panel">
<div className="discussion-header">
<span className="discussion-header-title">
Discussion <span className="beta-chip">Beta</span>
</span>
<span className="discussion-header-meta">
{openThreads.length} open thread{openThreads.length === 1 ? '' : 's'}
{' · '}contribution requires a PR
</span>
</div>
{threads.length > 1 && (
<div className="discussion-thread-tabs">
{threads.map(t => (
<button
key={t.id}
type="button"
className={`discussion-thread-tab ${t.id === activeThreadId ? 'active' : ''} ${t.state === 'resolved' ? 'resolved' : ''}`}
onClick={() => setActiveThreadId(t.id)}
title={t.label || (t.id === activeThreadId ? 'Current thread' : 'Open thread')}
>
{t.label || (t.anchor_kind === 'whole-doc' && !t.label ? 'General' : `Thread ${t.id}`)}
{t.state === 'resolved' && ' ✓'}
</button>
))}
</div>
)}
<div className="discussion-messages">
{error && <div className="discussion-error">{error}</div>}
{activeMessages.length === 0 && !error && (
<div className="discussion-empty">
<p>
{viewer
? 'No discussion yet. Be the first to comment — discussion lives here without opening a PR. To propose an edit, use Start Contributing above.'
: 'No discussion yet. Sign in to comment. Discussion lives here without opening a PR; proposed edits still flow through PRs.'}
</p>
</div>
)}
{activeMessages.map(msg => (
<DiscussionMessage key={msg.id} message={msg} />
))}
<div ref={bottomRef} />
</div>
<div className="discussion-composer">
{viewer ? (
<>
<textarea
className="discussion-composer-textarea"
value={composer}
onChange={e => setComposer(e.target.value)}
onKeyDown={onKeyDown}
placeholder={
activeThread?.label
? `Reply in "${activeThread.label}" — Cmd/Ctrl+Enter to send`
: 'Discuss this RFC — Cmd/Ctrl+Enter to send'
}
disabled={sending}
rows={3}
/>
<div className="discussion-composer-actions">
<button
type="button"
className="btn-secondary"
onClick={handleNewThread}
disabled={sending}
title="Open a fresh discussion thread on this RFC"
>
New thread
</button>
{activeThread
&& activeThread.state === 'open'
&& (activeThread.created_by === viewer.user_id
|| viewer.role === 'owner'
|| viewer.role === 'admin') && (
<button
type="button"
className="btn-link"
onClick={() => handleResolve(activeThread.id)}
disabled={sending}
title="Mark this discussion thread resolved"
>
Resolve
</button>
)}
<button
type="button"
className="btn-primary"
onClick={handleSend}
disabled={sending || !composer.trim()}
>
{sending ? 'Sending…' : 'Send'}
</button>
</div>
</>
) : (
<div className="discussion-readonly">
Read-only <a href="/auth/login">sign in</a> to join the discussion.
Discussion is in private <strong>Beta</strong>.
</div>
)}
</div>
</div>
)
}
function DiscussionMessage({ message }) {
const isSystem = message.role === 'system'
if (isSystem) {
return (
<div className="discussion-message system">
<div className="discussion-system-bubble">{message.text}</div>
</div>
)
}
return (
<div className={`discussion-message ${message.role}`}>
<div className="discussion-message-meta">
<span className="discussion-message-author">
@{message.author_login || '—'}
</span>
<span className="discussion-message-time">
{formatTimestamp(message.created_at)}
</span>
</div>
{message.quote && (
<div className="discussion-message-quote">"{message.quote}"</div>
)}
<div className="discussion-message-body">{message.text}</div>
</div>
)
}
function formatTimestamp(ts) {
if (!ts) return ''
try {
const d = new Date(ts + (ts.endsWith('Z') ? '' : 'Z'))
return d.toLocaleString()
} catch {
return ts
}
}
+28 -14
View File
@@ -39,6 +39,7 @@ import MarkdownPreview from './MarkdownPreview.jsx'
import SelectionTooltip from './SelectionTooltip.jsx'
import PromptBar from './PromptBar.jsx'
import ChatPanel from './ChatPanel.jsx'
import RFCDiscussionPanel from './RFCDiscussionPanel.jsx'
import ChangePanel, { diffWords } from './ChangePanel.jsx'
import PRModal from './PRModal.jsx'
import GraduateDialog from './GraduateDialog.jsx'
@@ -535,9 +536,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 +549,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 +747,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>
@@ -754,17 +760,25 @@ export default function RFCView({ viewer }) {
data-open={drawerOpen ? 'true' : 'false'}
/>
<div className={`right-panel${drawerOpen ? ' drawer-open' : ''}`} role="complementary">
<ChatPanel
messages={messages}
threads={branchView.threads || []}
changes={changes}
branchName={branchParam}
isStreaming={isStreaming}
contributionMode={mode === 'contribute'}
onStartContribution={handleStartContributing}
onScrollToChange={setFocusedChangeId}
onResolveThread={handleResolveThread}
/>
{/* v0.5.0 on main, the right panel is the PR-less discussion
* surface (threads.branch_name IS NULL). Branches keep their
* existing branch-chat panel; contribution still requires
* opening a PR from a branch via the Open PR affordance above. */}
{branchParam === 'main' ? (
<RFCDiscussionPanel slug={slug} viewer={viewer} />
) : (
<ChatPanel
messages={messages}
threads={branchView.threads || []}
changes={changes}
branchName={branchParam}
isStreaming={isStreaming}
contributionMode={mode === 'contribute'}
onStartContribution={handleStartContributing}
onScrollToChange={setFocusedChangeId}
onResolveThread={handleResolveThread}
/>
)}
{mode === 'contribute' && (changes.length > 0 || manualPending) && (
<ChangePanel
changes={changes}