Compare commits

...

6 Commits

Author SHA1 Message Date
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
Ben Stull 018e323ed4 Release 0.2.2: Philosophy.jsx now renders mermaid (uses MarkdownPreview)
Philosophy.jsx parsed PHILOSOPHY.md with bare marked + dangerouslySet
InnerHTML, so ```mermaid fences fell through as <pre> blocks. Swapped
to MarkdownPreview, which already carries the lazy mermaid loader +
SVG render path used by the RFC body view. Pre-existing gap, surfaced
when an OHM deployment authored a mermaid block in its PHILOSOPHY_PATH
override.

PATCH per SPEC §20.2 — additive, no operator action required beyond
the routine rebuild + restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 13:31:49 -07:00
Ben Stull 0e1805b8ce Release 0.2.1: PRView Rules-of-Hooks fix (React #310 blank page)
PRView.jsx declared its threadsByKind useMemo after the early-return
guard for the loading state. First mount: pr is null, early return
fires, 14 hooks called. Second render: pr populated, execution
reaches the useMemo, 15 hooks — React #310, page goes blank.

Move the useMemo above the early returns and null-safe the
pr?.threads access so the hook count stays stable across renders.

Pre-existing bug in the post-Contribute-rewrite PR view; surfaced
in production by 0.2.0's graduation merge race fix enabling the
workflow that opens this code path. Patch per §20.2 — no operator
action required beyond rebuilding the frontend and restarting.
2026-05-26 08:50:48 -07:00
33 changed files with 2345 additions and 130 deletions
+288
View File
@@ -23,6 +23,294 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond release's steps in order — no A-to-B path is pre-computed beyond
that. that.
## 0.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
restart per the routine deploy steps; no `.env` changes, no schema
changes, no behavior changes a deployment would notice in steady
state beyond the fix below.
### Fixed
- **Mermaid blocks rendered as raw code on `/philosophy`.**
`frontend/src/components/Philosophy.jsx` parsed PHILOSOPHY.md with
the bare `marked` import and `dangerouslySetInnerHTML`, so
```` ```mermaid ```` fences fell through as `<pre>` blocks rather
than rendered diagrams. Swapped to `MarkdownPreview`, which already
carries the lazy mermaid loader + SVG render path used by the RFC
body view, so the philosophy surface now renders mermaid the same
way RFC bodies do. Pre-existing gap, surfaced when a deployment
authored a mermaid block in its `PHILOSOPHY_PATH` override.
## 0.2.1 — 2026-05-26
**Patch — no operator action required.** Rebuild the frontend and
restart per the routine deploy steps; no `.env` changes, no schema
changes, no behavior changes a deployment would notice in steady
state.
### Fixed
- **PR view blank page (React #310).** `frontend/src/components/PRView.jsx`
declared its `threadsByKind` `useMemo` *after* the early-return
guard for the loading state (`if (!pr) return …`). On first mount
`pr` was null so the early return fired and 14 hooks were called;
on the second render `pr` was populated and execution reached the
`useMemo`, calling 15 hooks — violating the Rules of Hooks and
unmounting the page subtree (blank screen). The `useMemo` is now
declared above the early returns with optional-chaining on `pr`,
keeping the hook count stable between the loading and loaded
renders. Pre-existing bug in the post-Contribute-rewrite PR view;
surfaced in production by 0.2.0's graduation merge race fix
enabling the workflow that opens this code path.
## 0.2.0 — 2026-05-26 ## 0.2.0 — 2026-05-26
**Breaking config change.** The frontend now requires **Breaking config change.** The frontend now requires
+198 -11
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 - `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: main view, a branch, or a span within a branch's document. Columns:
`id`, `rfc_slug`, `branch_name` (nullable — null means scoped to the `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), `anchor_payload` (JSON: serialized ProseMirror range or paragraph id),
`thread_kind` (`chat` | `flag` | `review``review` is the diff-anchored `thread_kind` (`chat` | `flag` | `review``review` is the diff-anchored
PR-review thread defined in §10.4), `label` (short human-authored summary; PR-review thread defined in §10.4), `label` (short human-authored summary;
for flags this is the entire content), `state` (`open` | `resolved` | for flags this is the entire content), `state` (`open` | `resolved` |
`stale`), `created_by`, `created_at`, `resolved_at`, `resolved_by`. `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. - `thread_messages` — the actual chat content for `chat`-kind threads.
Columns: `id`, `thread_id`, `role` (`user` | `assistant` | `system`), Columns: `id`, `thread_id`, `role` (`user` | `assistant` | `system`),
`author_user_id` (nullable; null for assistant), `model_id` (nullable; `author_user_id` (nullable; null for assistant), `model_id` (nullable;
@@ -1120,12 +1125,20 @@ The modal collects four fields:
type their own. type their own.
No proposer name or email — the logged-in identity is canonical and No proposer name or email — the logged-in identity is canonical and
need not be retyped. No proposed-owner or working-group fields — need not be retyped. No proposer-owner field either: the proposer is
ownership flows through the post-merge claim flow (§13.1), and 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 arbiters are admin work, not the proposer's call. AI's drafting role
is intentionally narrow: tag suggestions only. The proposer is is intentionally narrow: tag suggestions only. The proposer is
making a specific claim about a specific word, and having AI propose 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 Primary action: **"Open proposal PR"** — naming the actual Git
artifact produced, consistent with §10.1's *Open PR* and §13's artifact produced, consistent with §10.1's *Open PR* and §13's
@@ -1141,7 +1154,13 @@ is populated from the modal and session:
- `state: super-draft`, `id: null`, `repo: null`, - `state: super-draft`, `id: null`, `repo: null`,
`graduated_at: null`, `graduated_by: null` — fixed at creation. `graduated_at: null`, `graduated_by: null` — fixed at creation.
- `proposed_by: <session email>`, `proposed_at: <today>` — auto. - `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 - `arbiters: []` — empty; arbiters are admin work, not the
proposer's call. proposer's call.
@@ -1625,6 +1644,40 @@ framework's evidence unit; admitting plumbing commits — "fix merge
conflict with main" — into that timeline would dilute the signal each conflict with main" — into that timeline would dilute the signal each
commit is meant to carry. 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 implements the gate; v0.6.0 — item #4 — hardens
adjacent surfaces to match). 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 ## 11. Branches and PRs: visibility, contribute, lifecycle
@@ -1718,11 +1771,20 @@ PR do not block graduation; they remain on the meta repo subject to
### 13.1 Claim ownership (prerequisite) ### 13.1 Claim ownership (prerequisite)
If a super-draft has no owner, any signed-in contributor can click The proposer is already the first owner of any super-draft they
"Claim ownership," which opens a PR against the meta repo adding their proposed (per §9.2; auto-set at propose time). The claim flow exists
username to the `owners:` field of the entry. Owners and admins can for *additional* contributors to become owners on an existing super-
merge. (A self-merge window for un-acted claims is not enabled in v1; draft — typically a draft whose proposer has stepped away, or a draft
configurable later if needed.) Multiple claims simply append. 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 ### 13.2 The Graduate dialog
@@ -2416,6 +2478,17 @@ specified* and what is intentionally out of scope for v1.
The follow-up session will refine this. A minimal starting set: 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, - `GET /api/rfcs` — list entries with state, id, title, slug, repo,
owners, last_active_at, has_open_prs, starred-by-me. Supports owners, last_active_at, has_open_prs, starred-by-me. Supports
search, sort, filter chips, and the `unclaimed` predicate. search, sort, filter chips, and the `unclaimed` predicate.
@@ -2518,6 +2591,26 @@ The follow-up session will refine this. A minimal starting set:
- `POST /api/rfcs/<slug>/branches/<branch>/threads/<thread_id>/resolve` - `POST /api/rfcs/<slug>/branches/<branch>/threads/<thread_id>/resolve`
— resolve a thread per §8.12; permission per the rules in that — resolve a thread per §8.12; permission per the rules in that
section. 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 - `POST /api/rfcs/<slug>/branches/<branch>/open-pr` — open a PR per
§10.1; body carries the AI-drafted (and possibly edited) title and §10.1; body carries the AI-drafted (and possibly edited) title and
description. description.
@@ -3208,6 +3301,100 @@ binding.
read-only relationship counts as active). A future session may read-only relationship counts as active). A future session may
settle a tighter definition (e.g., has any `actions` row on the settle a tighter definition (e.g., has any `actions` row on the
RFC) if the generous proxy refuses too many legitimate mutes. 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 - **Deployment-supplied subject framing.** The framework was built
with one deployment in mind (OHM, standardizing natural-language with one deployment in mind (OHM, standardizing natural-language
vocabulary), but the substrate generalizes to any domain that vocabulary), but the substrate generalizes to any domain that
+1 -1
View File
@@ -1 +1 @@
0.2.0 0.5.0
+23 -1
View File
@@ -20,6 +20,7 @@ from pydantic import BaseModel, Field
from . import ( from . import (
api_admin, api_admin,
api_branches, api_branches,
api_discussion,
api_graduation, api_graduation,
api_notifications, api_notifications,
api_prs, api_prs,
@@ -28,6 +29,7 @@ from . import (
entry as entry_mod, entry as entry_mod,
cache, cache,
funder, funder,
health,
philosophy, philosophy,
providers as providers_mod, providers as providers_mod,
) )
@@ -80,6 +82,22 @@ def make_router(
# the §15.8 mute typeahead) and the §6/§17 admin surfaces # the §15.8 mute typeahead) and the §6/§17 admin surfaces
# (role, write-mute, audit-log, graduation-readiness queue). # (role, write-mute, audit-log, graduation-readiness queue).
router.include_router(api_admin.make_router(config)) 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. # §14.2: /api/philosophy — PHILOSOPHY.md served verbatim.
@@ -287,7 +305,11 @@ def make_router(
proposed_at=entry_mod.today(), proposed_at=entry_mod.today(),
graduated_at=None, graduated_at=None,
graduated_by=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=[], arbiters=[],
tags=[t.strip() for t in payload.tags if t.strip()], tags=[t.strip() for t in payload.tags if t.strip()],
body=payload.pitch.strip() + "\n", body=payload.pitch.strip() + "\n",
+102
View File
@@ -50,6 +50,11 @@ class MuteBody(BaseModel):
muted: bool muted: bool
class AllowlistAddBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
note: str | None = Field(default=None, max_length=200)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Router # Router
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -385,6 +390,103 @@ def make_router(config: Config) -> APIRouter:
] ]
} }
# ----- Private-beta allowlist (`migrations/011_allowlist.sql`) -----
@router.get("/api/admin/allowlist")
async def list_allowlist(request: Request) -> dict[str, Any]:
auth.require_admin(request)
rows = db.conn().execute(
"""
SELECT a.email, a.note, a.created_at,
u.gitea_login AS added_by_login,
u.display_name AS added_by_display
FROM allowed_emails a
LEFT JOIN users u ON u.id = a.added_by_user_id
ORDER BY a.created_at DESC
"""
).fetchall()
return {
"active": len(rows) > 0,
"items": [
{
"email": r["email"],
"note": r["note"] or "",
"added_by_login": r["added_by_login"],
"added_by_display": r["added_by_display"],
"created_at": r["created_at"],
}
for r in rows
],
}
@router.post("/api/admin/allowlist")
async def add_allowlist(body: AllowlistAddBody, request: Request) -> dict[str, Any]:
viewer = auth.require_admin(request)
email = body.email.strip()
if "@" not in email or len(email.split("@")[-1]) < 2:
raise HTTPException(422, "Email looks malformed")
existing = db.conn().execute(
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
).fetchone()
if existing is not None:
raise HTTPException(409, "Email already on the allowlist")
db.conn().execute(
"""
INSERT INTO allowed_emails (email, added_by_user_id, note)
VALUES (?, ?, ?)
""",
(email, viewer.user_id, body.note),
)
# Audit trail: when the email already maps to a known user, emit a
# permission_events row so §6.5's log stays the single place to
# look for "who let this person in." For brand-new emails the
# allowed_emails row itself carries (added_by_user_id, created_at)
# which is sufficient until the user actually signs in.
subject = db.conn().execute(
"SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1", (email,)
).fetchone()
if subject is not None:
db.conn().execute(
"""
INSERT INTO permission_events
(actor_user_id, subject_user_id, event_kind, details)
VALUES (?, ?, 'allowlist_added', ?)
""",
(
viewer.user_id,
subject["id"],
json.dumps({"email": email, "note": body.note or ""}),
),
)
return {"ok": True, "email": email}
@router.delete("/api/admin/allowlist/{email}")
async def remove_allowlist(email: str, request: Request) -> dict[str, Any]:
viewer = auth.require_admin(request)
existing = db.conn().execute(
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
).fetchone()
if existing is None:
raise HTTPException(404, "Email not on the allowlist")
db.conn().execute("DELETE FROM allowed_emails WHERE email = ?", (email,))
subject = db.conn().execute(
"SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1", (email,)
).fetchone()
if subject is not None:
db.conn().execute(
"""
INSERT INTO permission_events
(actor_user_id, subject_user_id, event_kind, details)
VALUES (?, ?, 'allowlist_removed', ?)
""",
(
viewer.user_id,
subject["id"],
json.dumps({"email": email}),
),
)
return {"ok": True, "email": email}
return router return router
+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"],
}
+37
View File
@@ -77,6 +77,43 @@ async def fetch_user_profile(config: Config, access_token: str) -> dict[str, Any
return resp.json() return resp.json()
def allowlist_is_active() -> bool:
"""The private-beta gate is on iff the `allowed_emails` table has any
rows. Empty list means "open" any successful OAuth provisions a
user; first row added flips the deployment into private-beta mode.
See `migrations/011_allowlist.sql` for the reasoning.
"""
row = db.conn().execute("SELECT 1 FROM allowed_emails LIMIT 1").fetchone()
return row is not None
def is_allowed_sign_in(profile: dict[str, Any]) -> bool:
"""Decide whether a freshly-completed OAuth profile may sign in.
Three accept paths:
1. The allowlist is empty (gate off).
2. The Gitea profile's email is in `allowed_emails` (case-insensitive).
3. A `users` row already exists for this `gitea_id` grandfather
per `migrations/011_allowlist.sql`.
"""
gitea_id = profile.get("id")
if gitea_id is not None:
existing = db.conn().execute(
"SELECT 1 FROM users WHERE gitea_id = ? LIMIT 1", (gitea_id,)
).fetchone()
if existing is not None:
return True
if not allowlist_is_active():
return True
email = (profile.get("email") or "").strip()
if not email:
return False
row = db.conn().execute(
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
).fetchone()
return row is not None
def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser: def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
"""Insert or update the users row for this Gitea profile. """Insert or update the users row for this Gitea profile.
+6 -1
View File
@@ -168,10 +168,15 @@ def _fan_out_chat(thread_id: int, author_user_id: int, message_id: int) -> None:
).fetchone() ).fetchone()
if pr_row: if pr_row:
pr_number = pr_row["pr_number"] 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( notify.fan_out_chat_message(
actor_user_id=author_user_id, actor_user_id=author_user_id,
rfc_slug=row["rfc_slug"], rfc_slug=row["rfc_slug"],
branch_name=row["branch_name"] or "main", branch_name=row["branch_name"],
thread_id=thread_id, thread_id=thread_id,
message_id=message_id, message_id=message_id,
is_review_thread=(row["thread_kind"] == "review"), 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: if not access_token:
raise HTTPException(400, "Token exchange failed") raise HTTPException(400, "Token exchange failed")
profile = await auth.fetch_user_profile(config, access_token) profile = await auth.fetch_user_profile(config, access_token)
if not auth.is_allowed_sign_in(profile):
# Private-beta gate: clear any partial OAuth state and bounce to
# the public /beta-pending page. The session is left empty so the
# rejected viewer continues as anonymous read-only.
request.session.pop(auth.SESSION_STATE_KEY, None)
return RedirectResponse("/beta-pending")
user = auth.provision_user(config, profile) user = auth.provision_user(config, profile)
auth.store_session(request, user) auth.store_session(request, user)
return RedirectResponse("/") return RedirectResponse("/")
+9 -1
View File
@@ -212,7 +212,7 @@ def fan_out_chat_message(
*, *,
actor_user_id: int, actor_user_id: int,
rfc_slug: str, rfc_slug: str,
branch_name: str, branch_name: str | None,
thread_id: int, thread_id: int,
message_id: int, message_id: int,
is_review_thread: bool = False, is_review_thread: bool = False,
@@ -227,6 +227,14 @@ def fan_out_chat_message(
(state='watching', i.e. full stream) get a churn-class (state='watching', i.e. full stream) get a churn-class
`chat_message_in_participated_thread`. The two are union'd so a user `chat_message_in_participated_thread`. The two are union'd so a user
who is both gets only the personal-direct row. 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) _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'))
);
+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() proposal = r.json()
assert proposal["entry"]["title"] == "Open Human Model" assert proposal["entry"]["title"] == "Open Human Model"
assert proposal["entry"]["state"] == "super-draft" 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 assert proposal["affordances"]["merge"] is True
# Owner merges. The catalog picks up the new super-draft. # 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() view = r.json()
assert view["state"] == "super-draft" assert view["state"] == "super-draft"
assert "shared definition" in view["body"] 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. # The pending-ideas list no longer carries the merged proposal.
r = client.get("/api/proposals") r = client.get("/api/proposals")
@@ -568,6 +577,37 @@ def test_anonymous_cannot_propose(app_with_fake_gitea):
assert r.status_code == 401 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): def test_withdraw_by_proposer_works(app_with_fake_gitea):
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea app, _fake = app_with_fake_gitea
+23 -17
View File
@@ -1,7 +1,7 @@
# RFC App — Deployment Reference & New-Session Prompt # RFC App — Deployment Reference & New-Session Prompt
Use this document as: Use this document as:
1. A reference for the current `rfc.wiggleverse.org` deployment 1. A reference for the current `ohm.wiggleverse.org` deployment
2. A prompt to paste into a new Claude session to deploy a new version 2. A prompt to paste into a new Claude session to deploy a new version
--- ---
@@ -36,10 +36,10 @@ For reference, the separate Gitea VM is `wiggleverse` project / `gitea` VM / 34.
| Record | Type | Value | Proxy | | Record | Type | Value | Proxy |
|--------|------|-------|-------| |--------|------|-------|-------|
| `rfc.wiggleverse.org` | A | 34.132.29.41 | DNS-only (gray cloud) | | `ohm.wiggleverse.org` | A | 34.132.29.41 | DNS-only (gray cloud) |
| `_dmarc.wiggleverse.org` | TXT | `v=DMARC1; p=none; rua=mailto:ben@wiggleverse.org` | n/a | | `_dmarc.wiggleverse.org` | TXT | `v=DMARC1; p=none; rua=mailto:ben@wiggleverse.org` | n/a |
> Note: `rfc.wiggleverse.org` uses **Let's Encrypt via certbot** directly on the VM. Keep the A record **DNS only (gray cloud)** — Cloudflare Flexible SSL would conflict with certbot. > Note: `ohm.wiggleverse.org` uses **Let's Encrypt via certbot** directly on the VM. Keep the A record **DNS only (gray cloud)** — Cloudflare Flexible SSL would conflict with certbot.
SPF (`v=spf1 include:_spf.google.com ~all`) and DKIM (`google._domainkey`) for `wiggleverse.org` are already in place via Workspace. SPF (`v=spf1 include:_spf.google.com ~all`) and DKIM (`google._domainkey`) for `wiggleverse.org` are already in place via Workspace.
@@ -64,7 +64,7 @@ SPF (`v=spf1 include:_spf.google.com ~all`) and DKIM (`google._domainkey`) for `
| `/opt/rfc-app/backend/.env` | All secrets and config (mode 0600) | | `/opt/rfc-app/backend/.env` | All secrets and config (mode 0600) |
| `/opt/rfc-app/backend/data/rfc-app.db` | SQLite database | | `/opt/rfc-app/backend/data/rfc-app.db` | SQLite database |
| `/opt/rfc-app/frontend/dist/` | Built React SPA (served by nginx) | | `/opt/rfc-app/frontend/dist/` | Built React SPA (served by nginx) |
| `/etc/nginx/sites-available/rfc.wiggleverse.org` | nginx vhost config | | `/etc/nginx/sites-available/ohm.wiggleverse.org` | nginx vhost config |
| `/etc/systemd/system/rfc-app.service` | systemd unit | | `/etc/systemd/system/rfc-app.service` | systemd unit |
--- ---
@@ -79,7 +79,7 @@ SPF (`v=spf1 include:_spf.google.com ~all`) and DKIM (`google._domainkey`) for `
## Gitea Setup (one-time) ## Gitea Setup (one-time)
These are already done for `rfc.wiggleverse.org`. Document here for replication. These are already done for `ohm.wiggleverse.org`. Document here for replication.
### Bot service account ### Bot service account
@@ -91,13 +91,13 @@ Created in Gitea as `rfc-bot`. Token scopes: `write:repository`, `write:user`, `
### Meta repo ### Meta repo
`wiggleverse/meta` — seeded by `scripts/seed_meta_repo.py`. Contains `PHILOSOPHY.md`, `README.md`, `CONTRIBUTING.md`, and `rfcs/` directory. Gitea webhook registered to `https://rfc.wiggleverse.org/api/webhooks/gitea`. `wiggleverse/meta` — seeded by `scripts/seed_meta_repo.py`. Contains `PHILOSOPHY.md`, `README.md`, `CONTRIBUTING.md`, and `rfcs/` directory. Gitea webhook registered to `https://ohm.wiggleverse.org/api/webhooks/gitea`.
### OAuth2 app ### OAuth2 app
Registered in Gitea Site Administration → Integrations → OAuth2 Applications: Registered in Gitea Site Administration → Integrations → OAuth2 Applications:
- Name: `RFC App` - Name: `RFC App`
- Redirect URI: `https://rfc.wiggleverse.org/auth/callback` - Redirect URI: `https://ohm.wiggleverse.org/auth/callback`
- Client ID and secret stored in `.env` - Client ID and secret stored in `.env`
--- ---
@@ -133,7 +133,7 @@ OAUTH_CLIENT_ID=<from Gitea OAuth app>
OAUTH_CLIENT_SECRET=<from Gitea OAuth app> OAUTH_CLIENT_SECRET=<from Gitea OAuth app>
# App # App
APP_URL=https://rfc.wiggleverse.org APP_URL=https://ohm.wiggleverse.org
SECRET_KEY=<openssl rand -hex 32> SECRET_KEY=<openssl rand -hex 32>
DATABASE_PATH=/opt/rfc-app/backend/data/rfc-app.db DATABASE_PATH=/opt/rfc-app/backend/data/rfc-app.db
OWNER_GITEA_LOGIN=ben.stull OWNER_GITEA_LOGIN=ben.stull
@@ -182,10 +182,16 @@ sudo systemctl restart rfc-app
For frontend changes, build on the VM directly (Node 20+ is already there): For frontend changes, build on the VM directly (Node 20+ is already there):
```bash ```bash
cd /opt/rfc-app/frontend && sudo -u rfc-app npm install cd /opt/rfc-app/frontend && sudo -u rfc-app npm ci
sudo -u rfc-app npm run build sudo -u rfc-app npm run build
``` ```
`npm ci` installs strictly from the committed `package-lock.json` and
will not regenerate it. Using `npm install` here causes the VM's npm
to rewrite the lockfile in place (notably stripping `libc` fields on
optional rollup native packages), which then conflicts with `git
checkout <tag>` on the next deploy.
The output lands in `/opt/rfc-app/frontend/dist/` owned by `rfc-app` — nginx serves it directly, no copy step needed. The output lands in `/opt/rfc-app/frontend/dist/` owned by `rfc-app` — nginx serves it directly, no copy step needed.
(Building locally and `gcloud compute scp`-ing the dist also works. Plain `rsync -e ssh` from the Mac fails because OS Login uses short-lived SSH certs that only the gcloud wrapper can mint interactively.) (Building locally and `gcloud compute scp`-ing the dist also works. Plain `rsync -e ssh` from the Mac fails because OS Login uses short-lived SSH certs that only the gcloud wrapper can mint interactively.)
@@ -197,7 +203,7 @@ Schema migrations run automatically on restart (append-only, safe to re-run).
## First-Time Deployment (new server) ## First-Time Deployment (new server)
### 1. Add DNS record ### 1. Add DNS record
Add `rfc.wiggleverse.org` → 34.132.29.41 as an A record in Cloudflare, **DNS only (gray cloud)**. Do not proxy — certbot needs to reach the VM directly. Add `ohm.wiggleverse.org` → 34.132.29.41 as an A record in Cloudflare, **DNS only (gray cloud)**. Do not proxy — certbot needs to reach the VM directly.
### 2. Host prep ### 2. Host prep
```bash ```bash
@@ -230,15 +236,15 @@ sudo -u rfc-app -H bash -c \
### 6. Build the frontend (on the VM) ### 6. Build the frontend (on the VM)
```bash ```bash
cd /opt/rfc-app/frontend && sudo -u rfc-app npm install cd /opt/rfc-app/frontend && sudo -u rfc-app npm ci
sudo -u rfc-app npm run build sudo -u rfc-app npm run build
``` ```
### 7. nginx ### 7. nginx
```bash ```bash
sudo cp /opt/rfc-app/deploy/nginx/rfc.wiggleverse.org.conf \ sudo cp /opt/rfc-app/deploy/nginx/ohm.wiggleverse.org.conf \
/etc/nginx/sites-available/rfc.wiggleverse.org /etc/nginx/sites-available/ohm.wiggleverse.org
sudo ln -s /etc/nginx/sites-available/rfc.wiggleverse.org \ sudo ln -s /etc/nginx/sites-available/ohm.wiggleverse.org \
/etc/nginx/sites-enabled/ /etc/nginx/sites-enabled/
sudo usermod -a -G rfc-app www-data sudo usermod -a -G rfc-app www-data
sudo chmod -R g+rX /opt/rfc-app/frontend/dist sudo chmod -R g+rX /opt/rfc-app/frontend/dist
@@ -247,7 +253,7 @@ sudo nginx -t && sudo systemctl reload nginx
### 8. Let's Encrypt ### 8. Let's Encrypt
```bash ```bash
sudo certbot --nginx -d rfc.wiggleverse.org sudo certbot --nginx -d ohm.wiggleverse.org
``` ```
### 9. systemd ### 9. systemd
@@ -259,7 +265,7 @@ sudo systemctl status rfc-app
``` ```
### 10. Smoke test ### 10. Smoke test
Visit `https://rfc.wiggleverse.org`: Visit `https://ohm.wiggleverse.org`:
1. Landing page renders with sign-in button 1. Landing page renders with sign-in button
2. Sign in with Gitea OAuth → catalog loads 2. Sign in with Gitea OAuth → catalog loads
3. `+ Propose New RFC` opens the propose modal 3. `+ Propose New RFC` opens the propose modal
@@ -301,7 +307,7 @@ Paste the following into a new Claude session to continue development:
--- ---
> I'm working on the **Wiggleverse RFC App** — a FastAPI + SQLite + React + Vite application deployed at `rfc.wiggleverse.org` on a GCP e2-small VM (`rfc-app` in the `wiggleverse-rfc` project; separate from the `gitea` VM in `wiggleverse` that runs Gitea at `git.wiggleverse.org`). The app is the primary interface for the Open Human Model (OHM) RFC working group. > I'm working on the **Wiggleverse RFC App** — a FastAPI + SQLite + React + Vite application deployed at `ohm.wiggleverse.org` on a GCP e2-small VM (`rfc-app` in the `wiggleverse-rfc` project; separate from the `gitea` VM in `wiggleverse` that runs Gitea at `git.wiggleverse.org`). The app is the primary interface for the Open Human Model (OHM) RFC working group.
> >
> **Stack:** > **Stack:**
> - Backend: Python 3.11, FastAPI, uvicorn (single process), SQLite WAL mode > - Backend: Python 3.11, FastAPI, uvicorn (single process), SQLite WAL mode
+18 -12
View File
@@ -1,6 +1,6 @@
# Runbook # Runbook
Single-host deployment of the RFC app at `rfc.wiggleverse.org`, sharing Single-host deployment of the RFC app at `ohm.wiggleverse.org`, sharing
infrastructure with `git.wiggleverse.org` (same Gitea instance, same nginx, infrastructure with `git.wiggleverse.org` (same Gitea instance, same nginx,
same Let's Encrypt). The shape matches §4.2: one process, one SQLite file, same Let's Encrypt). The shape matches §4.2: one process, one SQLite file,
no separate worker. no separate worker.
@@ -18,7 +18,7 @@ recover from a partial install is safe.
- Ubuntu/Debian-style host with nginx and certbot already serving - Ubuntu/Debian-style host with nginx and certbot already serving
`git.wiggleverse.org` over HTTPS. `git.wiggleverse.org` over HTTPS.
- DNS: an `A` record for `rfc.wiggleverse.org` pointing at the same IP as - DNS: an `A` record for `ohm.wiggleverse.org` pointing at the same IP as
`git.wiggleverse.org`. `git.wiggleverse.org`.
- Python 3.11+ available system-wide (the project has no `requires-python` - Python 3.11+ available system-wide (the project has no `requires-python`
pin; the current production VM runs 3.11 on Debian bookworm). Node 20+ pin; the current production VM runs 3.11 on Debian bookworm). Node 20+
@@ -75,7 +75,7 @@ Invite → rfc-bot → Owner**.
Integrations → OAuth2 Applications → Create Application**: Integrations → OAuth2 Applications → Create Application**:
- Name: `RFC App` - Name: `RFC App`
- Redirect URI: `https://rfc.wiggleverse.org/auth/callback` - Redirect URI: `https://ohm.wiggleverse.org/auth/callback`
Copy the client ID and client secret. They go into `.env`. Copy the client ID and client secret. They go into `.env`.
@@ -93,7 +93,7 @@ sudo -u rfc-app /opt/rfc-app/backend/.venv/bin/pip install \
```sh ```sh
# On your laptop: # On your laptop:
cd frontend && npm install && npm run build cd frontend && npm ci && npm run build
rsync -a dist/ ben.stull@<host>:/tmp/rfc-app-dist/ rsync -a dist/ ben.stull@<host>:/tmp/rfc-app-dist/
# On the host: # On the host:
sudo -u rfc-app mkdir -p /opt/rfc-app/frontend/dist sudo -u rfc-app mkdir -p /opt/rfc-app/frontend/dist
@@ -104,10 +104,16 @@ sudo chown -R rfc-app:rfc-app /opt/rfc-app/frontend/dist
Or build on the host directly if Node is installed there: Or build on the host directly if Node is installed there:
```sh ```sh
cd /opt/rfc-app/frontend && sudo -u rfc-app npm install cd /opt/rfc-app/frontend && sudo -u rfc-app npm ci
sudo -u rfc-app npm run build sudo -u rfc-app npm run build
``` ```
`npm ci` installs strictly from the committed `package-lock.json` and
refuses to mutate it. `npm install` was previously used here but can
regenerate the lockfile in place (e.g. stripping `libc` fields from
optional rollup native packages), which then collides with `git
checkout <tag>` on the next deploy.
**1.3.3 Write `.env`.** **1.3.3 Write `.env`.**
```sh ```sh
@@ -128,7 +134,7 @@ META_REPO=meta
OAUTH_CLIENT_ID=<from 1.2.3> OAUTH_CLIENT_ID=<from 1.2.3>
OAUTH_CLIENT_SECRET=<from 1.2.3> OAUTH_CLIENT_SECRET=<from 1.2.3>
APP_URL=https://rfc.wiggleverse.org APP_URL=https://ohm.wiggleverse.org
SECRET_KEY=<openssl rand -hex 32> SECRET_KEY=<openssl rand -hex 32>
OWNER_GITEA_LOGIN=ben.stull OWNER_GITEA_LOGIN=ben.stull
GITEA_WEBHOOK_SECRET=<openssl rand -hex 32> GITEA_WEBHOOK_SECRET=<openssl rand -hex 32>
@@ -182,9 +188,9 @@ Re-running is safe; every step is upsert-shaped.
**1.4.1 nginx vhost.** **1.4.1 nginx vhost.**
```sh ```sh
sudo cp /opt/rfc-app/deploy/nginx/rfc.wiggleverse.org.conf \ sudo cp /opt/rfc-app/deploy/nginx/ohm.wiggleverse.org.conf \
/etc/nginx/sites-available/rfc.wiggleverse.org /etc/nginx/sites-available/ohm.wiggleverse.org
sudo ln -s /etc/nginx/sites-available/rfc.wiggleverse.org \ sudo ln -s /etc/nginx/sites-available/ohm.wiggleverse.org \
/etc/nginx/sites-enabled/ /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx sudo nginx -t && sudo systemctl reload nginx
``` ```
@@ -200,7 +206,7 @@ sudo systemctl reload nginx
**1.4.2 Let's Encrypt cert.** **1.4.2 Let's Encrypt cert.**
```sh ```sh
sudo certbot --nginx -d rfc.wiggleverse.org sudo certbot --nginx -d ohm.wiggleverse.org
``` ```
### 1.5 systemd ### 1.5 systemd
@@ -226,7 +232,7 @@ RFC app started — meta repo wiggleverse/meta
### 1.6 Smoke test ### 1.6 Smoke test
In a browser at `https://rfc.wiggleverse.org`: In a browser at `https://ohm.wiggleverse.org`:
1. The landing page renders (§14.1 — title, pitch, three-item deck, 1. The landing page renders (§14.1 — title, pitch, three-item deck,
sign-in affordance). sign-in affordance).
@@ -384,7 +390,7 @@ say), restore from the most recent backup per §2.2.
`rfc-app`. `rfc-app`.
- **OAuth callback returns "Invalid state".** The redirect URI in Gitea - **OAuth callback returns "Invalid state".** The redirect URI in Gitea
must match `APP_URL/auth/callback` exactly. Confirm it's must match `APP_URL/auth/callback` exactly. Confirm it's
`https://rfc.wiggleverse.org/auth/callback`. `https://ohm.wiggleverse.org/auth/callback`.
- **The catalog stays empty after a merge.** Check the webhook: - **The catalog stays empty after a merge.** Check the webhook:
`journalctl -u rfc-app | grep webhook`. Gitea's **Settings → Webhooks `journalctl -u rfc-app | grep webhook`. Gitea's **Settings → Webhooks
→ Recent Deliveries** on the meta repo shows the delivery status; the → Recent Deliveries** on the meta repo shows the delivery status; the
@@ -2,21 +2,21 @@
# frontend served as static files from the Vite build output. # frontend served as static files from the Vite build output.
# #
# Install: # Install:
# sudo cp deploy/nginx/rfc.wiggleverse.org.conf \ # sudo cp deploy/nginx/ohm.wiggleverse.org.conf \
# /etc/nginx/sites-available/rfc.wiggleverse.org # /etc/nginx/sites-available/ohm.wiggleverse.org
# sudo ln -s /etc/nginx/sites-available/rfc.wiggleverse.org \ # sudo ln -s /etc/nginx/sites-available/ohm.wiggleverse.org \
# /etc/nginx/sites-enabled/ # /etc/nginx/sites-enabled/
# sudo nginx -t && sudo systemctl reload nginx # sudo nginx -t && sudo systemctl reload nginx
# #
# Then add the Let's Encrypt cert: # Then add the Let's Encrypt cert:
# sudo certbot --nginx -d rfc.wiggleverse.org # sudo certbot --nginx -d ohm.wiggleverse.org
# Certbot will rewrite this file to add the 443 listener and certificate # Certbot will rewrite this file to add the 443 listener and certificate
# directives; the rest of the config below stays as written. # directives; the rest of the config below stays as written.
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name rfc.wiggleverse.org; server_name ohm.wiggleverse.org;
# Static SPA assets live in the Vite build output. The systemd unit # Static SPA assets live in the Vite build output. The systemd unit
# runs as user `rfc-app`; make sure nginx (usually `www-data`) can # runs as user `rfc-app`; make sure nginx (usually `www-data`) can
+31 -2
View File
@@ -69,7 +69,7 @@ The shortest path from scratch:
any deployment-identifying value; if a required variable is any deployment-identifying value; if a required variable is
missing, the build fails loudly. missing, the build fails loudly.
6. **Build and run.** `cd frontend && npm install && npm run 6. **Build and run.** `cd frontend && npm ci && npm run
build`, then start the backend per `deploy/RUNBOOK.md`. The build`, then start the backend per `deploy/RUNBOOK.md`. The
first sign-in is the OWNER login from `backend/.env`. first sign-in is the OWNER login from `backend/.env`.
@@ -166,7 +166,7 @@ The mechanics in practice:
5. **Check out the framework at the target version.** `git 5. **Check out the framework at the target version.** `git
fetch && git checkout <tag>`. fetch && git checkout <tag>`.
6. **Rebuild.** `npm install && npm run build` for the frontend; 6. **Rebuild.** `npm ci && npm run build` for the frontend;
restart the backend. restart the backend.
7. **Smoke-test.** Sign in, check the brand reflects your 7. **Smoke-test.** Sign in, check the brand reflects your
@@ -240,6 +240,35 @@ The deployment repo does **not** hold:
edit a framework file, the right move is to file a change against edit a framework file, the right move is to file a change against
the framework, get a release, and pin to it. the framework, get a release, and pin to it.
## Private-beta gate
From `0.3.0` onward, every deployment ships with an opt-in email
allowlist. The default state is **off**: an empty `allowed_emails`
table behaves exactly like 0.2.x — any successful Gitea OAuth
provisions a user.
To run a closed beta:
1. Sign in once as the deployment operator so your `users` row exists
(you will be grandfathered by `gitea_id` thereafter — adding the
first allowlist row does **not** lock you out).
2. Open `/admin/allowlist` and add the first invited email. As soon as
any row exists, sign-in is restricted to listed emails plus
grandfathered users.
3. Optionally set `VITE_BETA_CONTACT` in `frontend/.env` (an email
address, a URL, or a short instruction). It is shown to rejected
sign-ins on the `/beta-pending` page so visitors know how to
request an invitation.
To re-open the deployment, remove all rows from `allowed_emails` (the
admin tab has a Remove button per row) — the gate flips off as soon
as the last row is gone.
Anonymous viewers see the full app in read-only mode regardless of
allowlist state. Write affordances (Propose, chat, Contribute, Open
PR) are hidden behind a sign-in CTA, and the public read endpoints
behave the same in both states.
## When something goes wrong ## When something goes wrong
If a framework behavior is wrong for your deployment, file it as a If a framework behavior is wrong for your deployment, file it as a
+11
View File
@@ -13,3 +13,14 @@
# VITE_APP_NAME=Wiggleverse RFC # VITE_APP_NAME=Wiggleverse RFC
# VITE_APP_NAME=Wiggleverse Open Human Model # VITE_APP_NAME=Wiggleverse Open Human Model
VITE_APP_NAME= VITE_APP_NAME=
# Optional contact line shown on the /beta-pending page when a deployment
# is in private-beta mode (i.e. the backend's `allowed_emails` table has
# rows). Free-text — an email address, a URL, or a one-line instruction
# tells visitors how to request an invitation. If unset, the page falls
# back to a generic "contact the deployment operator" line.
#
# Examples:
# VITE_BETA_CONTACT=ben@wiggleverse.org
# VITE_BETA_CONTACT=DM @ben on Matrix
VITE_BETA_CONTACT=
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "rfc-app-frontend", "name": "rfc-app-frontend",
"version": "0.1.0", "version": "0.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "rfc-app-frontend", "name": "rfc-app-frontend",
"version": "0.1.0", "version": "0.5.0",
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.10.3", "@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-markdown": "^6.5.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "rfc-app-frontend", "name": "rfc-app-frontend",
"private": true, "private": true,
"version": "0.2.0", "version": "0.5.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+175 -1
View File
@@ -5,7 +5,16 @@
height: 100vh; color: #888; font-size: 14px; height: 100vh; color: #888; font-size: 14px;
} }
.app { height: 100vh; display: flex; flex-direction: column; } /* `100dvh` is the dynamic viewport height adjusts as iOS Safari's
URL bar shows/hides. Without it, `100vh` measures the URL-bar-hidden
("largest") viewport, so the app overflows what's actually visible.
Combined with `body { overflow: hidden }` in index.css, the result
on iOS is that single-finger touches get consumed by the (blocked)
page-level scroll attempt and never reach the nested .chrome-pane;
two-finger touches bypass that and one-finger works thereafter.
The 100vh line stays as a fallback for browsers older than iOS
15.4 / Chrome 108 (early 2022) that don't understand dvh. */
.app { height: 100vh; height: 100dvh; display: flex; flex-direction: column; }
.app-header { .app-header {
height: 48px; flex-shrink: 0; height: 48px; flex-shrink: 0;
@@ -33,6 +42,31 @@
} }
.btn-link:hover { background: rgba(255,255,255,0.25); } .btn-link:hover { background: rgba(255,255,255,0.25); }
.btn-signin-header {
color: #fff; text-decoration: none;
background: rgba(255,255,255,0.15);
border-radius: 6px; padding: 4px 10px;
font-size: 13px;
display: inline-flex; align-items: center; gap: 6px;
}
.btn-signin-header:hover { background: rgba(255,255,255,0.25); }
/* Beta chip small uppercase tag sitting alongside a button label or
* link. Renders well on both dark headers and light surfaces. */
.beta-chip {
font-size: 9px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em;
padding: 1px 5px; border-radius: 3px;
background: #b45309; color: #fff;
line-height: 1.5;
vertical-align: middle;
}
.btn-link .beta-chip,
.btn-mode-toggle .beta-chip,
.btn-start-contribution-header .beta-chip {
margin-left: 5px;
}
.app-body { flex: 1; display: flex; overflow: hidden; } .app-body { flex: 1; display: flex; overflow: hidden; }
/* --- Catalog (left pane, §7) --- */ /* --- Catalog (left pane, §7) --- */
@@ -318,6 +352,37 @@
} }
.landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; } .landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; }
/* --- Beta-pending page (post-OAuth-rejection) --- */
.beta-pending {
min-height: 100vh;
display: flex; align-items: center; justify-content: center;
padding: 40px 20px;
}
.beta-pending-inner {
max-width: 560px;
text-align: center;
}
.beta-pending h1 { font-size: 24px; margin: 0 0 16px; }
.beta-pending p { font-size: 15px; line-height: 1.6; color: #333; margin: 0 0 14px; }
.beta-pending-contact {
background: #fafafa; border: 1px solid #eee; border-radius: 8px;
padding: 14px 18px;
color: #444;
}
.beta-pending-actions {
margin-top: 24px;
display: flex; gap: 18px; justify-content: center; align-items: center;
}
.beta-pending-actions .btn-primary {
background: #1a1a1a; color: #fff;
border-radius: 8px; padding: 9px 18px;
font-size: 14px; font-weight: 600; text-decoration: none;
}
.beta-pending-actions .btn-primary:hover { background: #333; }
.btn-link-quiet { color: #666; text-decoration: none; font-size: 13px; }
.btn-link-quiet:hover { color: #1a1a1a; text-decoration: underline; }
/* ── §8 RFC view: three-column shape ─────────────────────────────────── */ /* ── §8 RFC view: three-column shape ─────────────────────────────────── */
.main-pane { .main-pane {
@@ -1678,6 +1743,27 @@
padding: 1px 5px; border-radius: 3px; padding: 1px 5px; border-radius: 3px;
} }
.allowlist-add {
display: flex; gap: 8px; margin-bottom: 22px; flex-wrap: wrap;
align-items: center;
}
.allowlist-add input[type="email"] {
border: 1px solid #d1d5db; border-radius: 6px;
padding: 6px 10px; font-size: 13px; min-width: 240px;
}
.allowlist-add input[type="text"] {
border: 1px solid #d1d5db; border-radius: 6px;
padding: 6px 10px; font-size: 13px; flex: 1; min-width: 200px;
}
.allowlist-add .btn-primary {
background: #1a1a1a; color: #fff;
border: none; border-radius: 6px;
padding: 6px 14px; font-size: 13px; font-weight: 600;
cursor: pointer;
}
.allowlist-add .btn-primary:hover:not(:disabled) { background: #333; }
.allowlist-add .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.user-cell { display: flex; flex-direction: column; gap: 1px; } .user-cell { display: flex; flex-direction: column; gap: 1px; }
.user-handle { font-weight: 500; color: #111; } .user-handle { font-weight: 500; color: #111; }
.mute-toggle { .mute-toggle {
@@ -1690,3 +1776,91 @@
.grad-queue-link:hover strong { text-decoration: underline; } .grad-queue-link:hover strong { text-decoration: underline; }
.muted { color: #6b7280; } .muted { color: #6b7280; }
.error { color: #b91c1c; } .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;
}
+68 -28
View File
@@ -8,6 +8,7 @@ import PRView from './components/PRView.jsx'
import ProposalView from './components/ProposalView.jsx' import ProposalView from './components/ProposalView.jsx'
import ProposeModal from './components/ProposeModal.jsx' import ProposeModal from './components/ProposeModal.jsx'
import Landing from './components/Landing.jsx' import Landing from './components/Landing.jsx'
import BetaPending from './components/BetaPending.jsx'
import Philosophy from './components/Philosophy.jsx' import Philosophy from './components/Philosophy.jsx'
import NotificationSettings from './components/NotificationSettings.jsx' import NotificationSettings from './components/NotificationSettings.jsx'
import Admin from './components/Admin.jsx' import Admin from './components/Admin.jsx'
@@ -68,17 +69,14 @@ export default function App() {
return <div className="boot">Loading</div> return <div className="boot">Loading</div>
} }
// §14.2: the philosophy route is reachable by anonymous visitors too. // The deployment is in private beta: anonymous visitors get the full
// Resolve it before the authentication gate so a signed-out reader // app in read-only mode (viewer = null is passed through to every
// who follows the §14.1 landing link does not get bounced to sign-in. // component), and write affordances are hidden at the component
if (!me?.authenticated) { // level. /beta-pending is the post-OAuth-rejection page reachable by
return ( // anyone. The original §14.1 Landing surface is retained for the
<Routes> // `/welcome` URL only, in case a deployment wants to link to it.
<Route path="/philosophy" element={<Philosophy authenticated={false} />} /> const viewer = me?.authenticated ? me.user : null
<Route path="*" element={<Landing />} /> const isAdmin = viewer && (viewer.role === 'owner' || viewer.role === 'admin')
</Routes>
)
}
return ( return (
<div className="app"> <div className="app">
@@ -88,20 +86,23 @@ export default function App() {
</div> </div>
<div className="header-right"> <div className="header-right">
{/* §14.3: the persistent About link. One word, no badge, no {/* §14.3: the persistent About link. One word, no badge, no
state visible from every authenticated screen so a state visible from every screen so a viewer mid-PR who
contributor mid-PR who wonders why a conversation is wonders why a conversation is public can reach the answer
public can reach the answer in two clicks. */} in two clicks. Anonymous viewers see it too. */}
<Link to="/philosophy" className="header-about" title="Why this exists (§14)"> <Link to="/philosophy" className="header-about" title="Why this exists (§14)">
About About
</Link> </Link>
{viewer && (
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)"> <Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
Settings Settings
</Link> </Link>
{(me.user.role === 'owner' || me.user.role === 'admin') && ( )}
{isAdmin && (
<Link to="/admin" className="header-admin" title="Admin home base"> <Link to="/admin" className="header-admin" title="Admin home base">
Admin Admin
</Link> </Link>
)} )}
{viewer && (
<button <button
className="inbox-trigger" className="inbox-trigger"
onClick={() => setInboxOpen(o => !o)} onClick={() => setInboxOpen(o => !o)}
@@ -112,36 +113,53 @@ export default function App() {
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span> <span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
)} )}
</button> </button>
<span className="user-name">{me.user.display_name}</span> )}
<span className={`user-role-badge role-${me.user.role}`}>{me.user.role}</span> {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-link" href="/auth/logout">Sign out</a>
</>
) : (
<a className="btn-signin-header" href="/auth/login" title="Private beta — only invited emails can sign in">
Sign in <span className="beta-chip">Beta</span>
</a>
)}
</div> </div>
</header> </header>
<div className="app-body"> <div className="app-body">
<Routes> <Routes>
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={me.user} />} /> <Route path="/welcome" element={<Landing />} />
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={me.user} />} /> <Route path="/beta-pending" element={<BetaPending />} />
<Route path="/admin/*" element={<AdminWithSidebar viewer={me.user} />} /> <Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
{viewer && (
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={viewer} />} />
)}
{isAdmin && (
<Route path="/admin/*" element={<AdminWithSidebar viewer={viewer} />} />
)}
<Route path="*" element={ <Route path="*" element={
<> <>
<Catalog <Catalog
viewer={viewer}
onProposeRFC={() => setProposeOpen(true)} onProposeRFC={() => setProposeOpen(true)}
version={catalogVersion} version={catalogVersion}
/> />
<main className="main-pane"> <main className="main-pane">
<Routes> <Routes>
<Route path="/" element={<Welcome viewer={me.user} />} /> <Route path="/" element={<Welcome viewer={viewer} />} />
<Route path="/rfc/:slug" element={<RFCView viewer={me.user} />} /> <Route path="/rfc/:slug" element={<RFCView viewer={viewer} />} />
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={me.user} />} /> <Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} />
<Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} /> <Route path="/proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} />
</Routes> </Routes>
</main> </main>
</> </>
} /> } />
</Routes> </Routes>
</div> </div>
{proposeOpen && ( {proposeOpen && viewer && (
<ProposeModal <ProposeModal
viewer={viewer}
onClose={() => setProposeOpen(false)} onClose={() => setProposeOpen(false)}
onSubmitted={({ pr_number }) => { onSubmitted={({ pr_number }) => {
setProposeOpen(false) setProposeOpen(false)
@@ -150,7 +168,7 @@ export default function App() {
}} }}
/> />
)} )}
{inboxOpen && ( {inboxOpen && viewer && (
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} /> <Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
)} )}
<ToastHost /> <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) // The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
// all use the full app body no catalog left pane, no propose modal. // all use the full app body no catalog left pane, no propose modal.
// The header carries the navigation back; the body is a single // The header carries the navigation back; the body is a single
// reading surface. // reading surface.
return ( return (
<main className="chrome-pane"> <main className="chrome-pane">
<Philosophy authenticated={true} /> <Philosophy authenticated={!!viewer} />
</main> </main>
) )
} }
@@ -187,6 +205,28 @@ function AdminWithSidebar({ viewer }) {
} }
function Welcome({ viewer }) { function Welcome({ viewer }) {
if (!viewer) {
return (
<div className="welcome">
<h1>Welcome.</h1>
<p>
The catalog on the left lists every super-draft and active RFC in the
framework. Open one to read the canonical body and the public
conversation behind each definition.
</p>
<p>
Discussion and contribution are in private <strong>Beta</strong>
read freely, and <a href="/auth/login">sign in</a> if your email has
been invited.
</p>
<p>
Wondering why a conversation is public, why graduation costs what it
does, or why the model is in the chat? <Link to="/philosophy">Read the
philosophy</Link>.
</p>
</div>
)
}
return ( return (
<div className="welcome"> <div className="welcome">
<h1>Welcome, {viewer.display_name}.</h1> <h1>Welcome, {viewer.display_name}.</h1>
+64
View File
@@ -197,6 +197,52 @@ export async function resolveThread(slug, branch, threadId) {
return jsonOrThrow(res) 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) ───────────────────────────── // ── Slice 4: super-draft body editing (§9.5) ─────────────────────────────
export async function startEditBranch(slug, body = {}) { export async function startEditBranch(slug, body = {}) {
@@ -534,6 +580,24 @@ export async function listGraduationQueue() {
return jsonOrThrow(await fetch('/api/admin/graduation-queue')) return jsonOrThrow(await fetch('/api/admin/graduation-queue'))
} }
export async function listAllowlist() {
return jsonOrThrow(await fetch('/api/admin/allowlist'))
}
export async function addAllowlistEmail(email, note) {
return jsonOrThrow(await fetch('/api/admin/allowlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, note: note || null }),
}))
}
export async function removeAllowlistEmail(email) {
return jsonOrThrow(await fetch(`/api/admin/allowlist/${encodeURIComponent(email)}`, {
method: 'DELETE',
}))
}
export async function searchUsers(q) { export async function searchUsers(q) {
const params = new URLSearchParams() const params = new URLSearchParams()
if (q) params.set('q', q) if (q) params.set('q', q)
+139
View File
@@ -19,10 +19,14 @@ import {
listAuditLog, listAuditLog,
listPermissionEvents, listPermissionEvents,
listGraduationQueue, listGraduationQueue,
listAllowlist,
addAllowlistEmail,
removeAllowlistEmail,
} from '../api.js' } from '../api.js'
const TABS = [ const TABS = [
{ path: 'users', label: 'Users' }, { path: 'users', label: 'Users' },
{ path: 'allowlist', label: 'Allowlist' },
{ path: 'graduation', label: 'Graduation queue' }, { path: 'graduation', label: 'Graduation queue' },
{ path: 'audit', label: 'Audit log' }, { path: 'audit', label: 'Audit log' },
{ path: 'permissions', label: 'Permission events' }, { path: 'permissions', label: 'Permission events' },
@@ -54,6 +58,7 @@ export default function Admin({ viewer }) {
<Routes> <Routes>
<Route index element={<UsersTab />} /> <Route index element={<UsersTab />} />
<Route path="users" element={<UsersTab />} /> <Route path="users" element={<UsersTab />} />
<Route path="allowlist" element={<AllowlistTab />} />
<Route path="graduation" element={<GraduationTab />} /> <Route path="graduation" element={<GraduationTab />} />
<Route path="audit" element={<AuditTab />} /> <Route path="audit" element={<AuditTab />} />
<Route path="permissions" element={<PermissionsTab />} /> <Route path="permissions" element={<PermissionsTab />} />
@@ -174,6 +179,140 @@ function UsersTab() {
) )
} }
// Private-beta allowlist (`migrations/011_allowlist.sql`)
function AllowlistTab() {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [draftEmail, setDraftEmail] = useState('')
const [draftNote, setDraftNote] = useState('')
const [busy, setBusy] = useState(false)
async function refresh() {
setError(null)
try {
setData(await listAllowlist())
} catch (e) {
setError(e.message)
}
}
useEffect(() => { refresh() }, [])
async function handleAdd(event) {
event.preventDefault()
const email = draftEmail.trim()
if (!email) return
setBusy(true); setError(null)
try {
await addAllowlistEmail(email, draftNote.trim() || null)
setDraftEmail(''); setDraftNote('')
await refresh()
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
async function handleRemove(email) {
if (!confirm(`Remove ${email} from the allowlist?`)) return
setBusy(true); setError(null)
try {
await removeAllowlistEmail(email)
await refresh()
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
if (data == null && !error) return <p className="muted">Loading allowlist</p>
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Allowlist</h2>
<p className="muted">
When this list has any rows, OAuth sign-in is restricted: only emails
here (case-insensitive) may sign in. Already-provisioned users are
grandfathered by their Gitea ID and never re-checked. An empty list
turns the gate off entirely.
</p>
<p className="muted">
Status:{' '}
<strong>{data?.active ? 'Private beta — gate active' : 'Open — anyone can sign in'}</strong>
</p>
</header>
{error && <p className="settings-note warning">{error}</p>}
<form className="allowlist-add" onSubmit={handleAdd}>
<input
type="email"
placeholder="email@example.com"
value={draftEmail}
onChange={e => setDraftEmail(e.target.value)}
required
disabled={busy}
/>
<input
type="text"
placeholder="Note (optional)"
value={draftNote}
onChange={e => setDraftNote(e.target.value)}
maxLength={200}
disabled={busy}
/>
<button type="submit" className="btn-primary" disabled={busy || !draftEmail.trim()}>
Add to allowlist
</button>
</form>
{data?.items?.length > 0 ? (
<table className="admin-table">
<thead>
<tr>
<th>Email</th>
<th>Note</th>
<th>Added by</th>
<th>Added at</th>
<th></th>
</tr>
</thead>
<tbody>
{data.items.map(r => (
<tr key={r.email}>
<td><code>{r.email}</code></td>
<td>{r.note || <span className="muted"></span>}</td>
<td>
{r.added_by_login
? <span>@{r.added_by_login}</span>
: <span className="muted"></span>}
</td>
<td className="muted">{r.created_at}</td>
<td>
<button
type="button"
className="btn-link-quiet"
onClick={() => handleRemove(r.email)}
disabled={busy}
>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="muted">
No allow-listed emails yet. Add the first one to put the deployment
into private-beta mode.
</p>
)}
</div>
)
}
// Graduation-readiness queue (§13.2) // Graduation-readiness queue (§13.2)
function GraduationTab() { function GraduationTab() {
+41
View File
@@ -0,0 +1,41 @@
// BetaPending.jsx the post-OAuth-rejection page.
//
// When a deployment is in private-beta mode (i.e. its `allowed_emails`
// table has any rows), the OAuth callback redirects unrecognised users
// here instead of provisioning them. The framework cannot know the
// deployment operator's preferred contact channel so the deployment
// supplies one via VITE_BETA_CONTACT (an email, URL, or short
// instruction). If unset, we render a generic ask-the-operator line.
import { Link } from 'react-router-dom'
export default function BetaPending() {
const contact = import.meta.env.VITE_BETA_CONTACT || ''
return (
<div className="beta-pending">
<div className="beta-pending-inner">
<h1>{import.meta.env.VITE_APP_NAME} is in private Beta.</h1>
<p>
Discussion and contribution are gated to invited emails for now.
Reading is open every super-draft, every active RFC, and every
public conversation is visible without signing in.
</p>
{contact ? (
<p className="beta-pending-contact">
To request access, contact <strong>{contact}</strong> with the
email address you'd like to sign in with.
</p>
) : (
<p className="beta-pending-contact">
To request access, contact the deployment operator with the email
address you'd like to sign in with.
</p>
)}
<div className="beta-pending-actions">
<Link className="btn-primary" to="/">Browse as a guest</Link>
<Link className="btn-link-quiet" to="/philosophy">Read the philosophy </Link>
</div>
</div>
</div>
)
}
+8 -2
View File
@@ -22,7 +22,7 @@ const SORT_OPTIONS = [
{ id: 'state', label: 'State' }, { id: 'state', label: 'State' },
] ]
export default function Catalog({ onProposeRFC, version }) { export default function Catalog({ viewer, onProposeRFC, version }) {
const [rfcs, setRfcs] = useState([]) const [rfcs, setRfcs] = useState([])
const [proposals, setProposals] = useState([]) const [proposals, setProposals] = useState([])
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@@ -93,7 +93,7 @@ export default function Catalog({ onProposeRFC, version }) {
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}> <div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
{rfcs.length === 0 {rfcs.length === 0
? 'No RFCs in the catalog yet. Propose one below.' ? (viewer ? 'No RFCs in the catalog yet. Propose one below.' : 'No RFCs in the catalog yet.')
: 'No matches.'} : 'No matches.'}
</div> </div>
) : ( ) : (
@@ -148,7 +148,13 @@ export default function Catalog({ onProposeRFC, version }) {
</div> </div>
<div className="catalog-footer"> <div className="catalog-footer">
{viewer ? (
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New RFC</button> <button className="btn-propose" onClick={onProposeRFC}>+ Propose New RFC</button>
) : (
<a className="btn-propose" href="/auth/login" title="Private beta — only invited emails can propose">
Sign in to propose <span className="beta-chip">Beta</span>
</a>
)}
</div> </div>
</aside> </aside>
) )
+13 -11
View File
@@ -143,6 +143,19 @@ export default function PRView({ viewer }) {
} }
}, [slug, prNumber, reviewText, reviewDraft, refresh]) }, [slug, prNumber, reviewText, reviewDraft, refresh])
// §10.6: split messages by thread kind for the visual distinction
// §10.4 requires. Within each kind, sort by id for chronological
// order. Declared above the early returns below so the hook count
// stays stable between the loading render (pr == null) and the
// loaded render (Rules of Hooks React #310 otherwise).
const threadsByKind = useMemo(() => {
const out = { chat: [], review: [], flag: [] }
for (const t of pr?.threads || []) {
out[t.thread_kind]?.push(t)
}
return out
}, [pr?.threads])
// render // render
if (error) return <article className="entry-view"><p>Error: {error}</p></article> if (error) return <article className="entry-view"><p>Error: {error}</p></article>
if (!pr) return <article className="entry-view">Loading PR</article> if (!pr) return <article className="entry-view">Loading PR</article>
@@ -154,17 +167,6 @@ export default function PRView({ viewer }) {
const supersededBy = pr.superseded_by_pr_number const supersededBy = pr.superseded_by_pr_number
const supersedes = pr.supersedes_pr_number const supersedes = pr.supersedes_pr_number
// §10.6: split messages by thread kind for the visual distinction
// §10.4 requires. Within each kind, sort by id for chronological
// order.
const threadsByKind = useMemo(() => {
const out = { chat: [], review: [], flag: [] }
for (const t of pr.threads || []) {
out[t.thread_kind]?.push(t)
}
return out
}, [pr.threads])
const seenSha = pr.seen?.last_seen_commit_sha const seenSha = pr.seen?.last_seen_commit_sha
const seenMsgId = pr.seen?.last_seen_message_id || 0 const seenMsgId = pr.seen?.last_seen_message_id || 0
+2 -4
View File
@@ -15,7 +15,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { marked } from 'marked' import MarkdownPreview from './MarkdownPreview.jsx'
import { getPhilosophy } from '../api.js' import { getPhilosophy } from '../api.js'
export default function Philosophy({ authenticated }) { export default function Philosophy({ authenticated }) {
@@ -33,8 +33,6 @@ export default function Philosophy({ authenticated }) {
return () => { active = false } return () => { active = false }
}, []) }, [])
const html = body ? marked.parse(body) : ''
return ( return (
<div className="philosophy-page"> <div className="philosophy-page">
<header className="philosophy-header"> <header className="philosophy-header">
@@ -53,7 +51,7 @@ export default function Philosophy({ authenticated }) {
{loading && <p className="muted">Loading</p>} {loading && <p className="muted">Loading</p>}
{error && <p className="error">Could not load the philosophy: {error}</p>} {error && <p className="error">Could not load the philosophy: {error}</p>}
{!loading && !error && ( {!loading && !error && (
<div dangerouslySetInnerHTML={{ __html: html }} /> <MarkdownPreview content={body} />
)} )}
</article> </article>
</div> </div>
+7 -1
View File
@@ -20,7 +20,7 @@ function slugify(title) {
.replace(/^-+|-+$/g, '') .replace(/^-+|-+$/g, '')
} }
export default function ProposeModal({ onClose, onSubmitted }) { export default function ProposeModal({ viewer, onClose, onSubmitted }) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [slug, setSlug] = useState('') const [slug, setSlug] = useState('')
const [slugEdited, setSlugEdited] = useState(false) const [slugEdited, setSlugEdited] = useState(false)
@@ -131,6 +131,12 @@ export default function ProposeModal({ onClose, onSubmitted }) {
</div> </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>} {error && <p className="field-error">{error}</p>}
</div> </div>
<div className="modal-actions"> <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
}
}
+17 -3
View File
@@ -39,6 +39,7 @@ import MarkdownPreview from './MarkdownPreview.jsx'
import SelectionTooltip from './SelectionTooltip.jsx' import SelectionTooltip from './SelectionTooltip.jsx'
import PromptBar from './PromptBar.jsx' import PromptBar from './PromptBar.jsx'
import ChatPanel from './ChatPanel.jsx' import ChatPanel from './ChatPanel.jsx'
import RFCDiscussionPanel from './RFCDiscussionPanel.jsx'
import ChangePanel, { diffWords } from './ChangePanel.jsx' import ChangePanel, { diffWords } from './ChangePanel.jsx'
import PRModal from './PRModal.jsx' import PRModal from './PRModal.jsx'
import GraduateDialog from './GraduateDialog.jsx' import GraduateDialog from './GraduateDialog.jsx'
@@ -535,9 +536,10 @@ export default function RFCView({ viewer }) {
type="button" type="button"
className={`btn-mode-toggle ${mode}`} className={`btn-mode-toggle ${mode}`}
onClick={() => setMode(mode === 'discuss' ? 'contribute' : 'discuss')} onClick={() => setMode(mode === 'discuss' ? 'contribute' : 'discuss')}
title={mode === 'discuss' ? 'Flip into edit mode' : 'Flip back to read-only discuss'} title={mode === 'discuss' ? 'Flip into edit mode (Beta)' : 'Flip back to read-only discuss (Beta)'}
> >
{mode === 'discuss' ? 'Contribute' : 'Discuss'} {mode === 'discuss' ? 'Contribute' : 'Discuss'}
<span className="beta-chip">Beta</span>
</button> </button>
)} )}
{(branchParam === 'main' || !canContribute) && viewer && ( {(branchParam === 'main' || !canContribute) && viewer && (
@@ -547,10 +549,13 @@ export default function RFCView({ viewer }) {
onClick={handleStartContributing} onClick={handleStartContributing}
> >
Start Contributing Start Contributing
<span className="beta-chip">Beta</span>
</button> </button>
)} )}
{!viewer && ( {!viewer && (
<a className="btn-link" href="/auth/login">Sign in</a> <a className="btn-link" href="/auth/login" title="Private beta — only invited emails can sign in">
Sign in <span className="beta-chip">Beta</span>
</a>
)} )}
{canOpenPR && ( {canOpenPR && (
<button <button
@@ -742,7 +747,8 @@ export default function RFCView({ viewer }) {
/> />
) : ( ) : (
<div className="readonly-bar"> <div className="readonly-bar">
Read-only view. <a href="/auth/login">Sign in</a> to participate. Read-only view. Discussion is in private <strong>Beta</strong> {' '}
<a href="/auth/login">sign in</a> if your email has been invited.
</div> </div>
)} )}
</div> </div>
@@ -754,6 +760,13 @@ export default function RFCView({ viewer }) {
data-open={drawerOpen ? 'true' : 'false'} data-open={drawerOpen ? 'true' : 'false'}
/> />
<div className={`right-panel${drawerOpen ? ' drawer-open' : ''}`} role="complementary"> <div className={`right-panel${drawerOpen ? ' drawer-open' : ''}`} role="complementary">
{/* 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 <ChatPanel
messages={messages} messages={messages}
threads={branchView.threads || []} threads={branchView.threads || []}
@@ -765,6 +778,7 @@ export default function RFCView({ viewer }) {
onScrollToChange={setFocusedChangeId} onScrollToChange={setFocusedChangeId}
onResolveThread={handleResolveThread} onResolveThread={handleResolveThread}
/> />
)}
{mode === 'contribute' && (changes.length > 0 || manualPending) && ( {mode === 'contribute' && (changes.length > 0 || manualPending) && (
<ChangePanel <ChangePanel
changes={changes} changes={changes}