Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c92730a737 | |||
| 0f8b318afa | |||
| 21fcbc92d4 | |||
| 1a9374aa52 | |||
| 018e323ed4 | |||
| 0e1805b8ce |
+288
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+23
-1
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
}
|
||||||
@@ -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
@@ -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"),
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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("/")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Private-beta email allowlist.
|
||||||
|
--
|
||||||
|
-- The framework supports a deployment-gated sign-in mode: when this
|
||||||
|
-- table contains rows, only emails listed here (case-insensitively)
|
||||||
|
-- may sign in via OAuth. Users already provisioned in the `users`
|
||||||
|
-- table are grandfathered in by gitea_id and never re-checked against
|
||||||
|
-- this list — so the operator who allow-listed themselves, signed in
|
||||||
|
-- once, then removed their own email from the list does not lose
|
||||||
|
-- access.
|
||||||
|
--
|
||||||
|
-- An empty `allowed_emails` table is the "open" state: no allowlist
|
||||||
|
-- gate runs, and any successful OAuth sign-in provisions a new user
|
||||||
|
-- as before. This means a fresh framework install behaves exactly as
|
||||||
|
-- prior versions until the operator adds the first row, at which
|
||||||
|
-- point the gate turns on for everyone not yet in `users`.
|
||||||
|
|
||||||
|
CREATE TABLE allowed_emails (
|
||||||
|
email TEXT PRIMARY KEY COLLATE NOCASE,
|
||||||
|
added_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// BetaPending.jsx — the post-OAuth-rejection page.
|
||||||
|
//
|
||||||
|
// When a deployment is in private-beta mode (i.e. its `allowed_emails`
|
||||||
|
// table has any rows), the OAuth callback redirects unrecognised users
|
||||||
|
// here instead of provisioning them. The framework cannot know the
|
||||||
|
// deployment operator's preferred contact channel — so the deployment
|
||||||
|
// supplies one via VITE_BETA_CONTACT (an email, URL, or short
|
||||||
|
// instruction). If unset, we render a generic ask-the-operator line.
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function BetaPending() {
|
||||||
|
const contact = import.meta.env.VITE_BETA_CONTACT || ''
|
||||||
|
return (
|
||||||
|
<div className="beta-pending">
|
||||||
|
<div className="beta-pending-inner">
|
||||||
|
<h1>{import.meta.env.VITE_APP_NAME} is in private Beta.</h1>
|
||||||
|
<p>
|
||||||
|
Discussion and contribution are gated to invited emails for now.
|
||||||
|
Reading is open — every super-draft, every active RFC, and every
|
||||||
|
public conversation is visible without signing in.
|
||||||
|
</p>
|
||||||
|
{contact ? (
|
||||||
|
<p className="beta-pending-contact">
|
||||||
|
To request access, contact <strong>{contact}</strong> with the
|
||||||
|
email address you'd like to sign in with.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="beta-pending-contact">
|
||||||
|
To request access, contact the deployment operator with the email
|
||||||
|
address you'd like to sign in with.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="beta-pending-actions">
|
||||||
|
<Link className="btn-primary" to="/">Browse as a guest</Link>
|
||||||
|
<Link className="btn-link-quiet" to="/philosophy">Read the philosophy →</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ const SORT_OPTIONS = [
|
|||||||
{ id: 'state', label: 'State' },
|
{ 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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user