Compare commits

...

6 Commits

Author SHA1 Message Date
Ben Stull 3ddeb5c1cd Release 0.13.0: cookie/privacy consent banner + policy pages (instrumentation prep)
Roadmap item #11. Ships the non-modal bottom-of-page cookie consent
banner, the default /privacy and /cookies policy pages, the
`cookie_consent` table + two §17 endpoints for server-side persistence,
the localStorage fallback for anonymous viewers, the /settings
"Privacy & cookies" tab for revisiting the choice, and the
`frontend/src/lib/consent.js` helper that roadmap item #13's analytics
SDK (v0.15.0) will gate against. No analytics SDK ships in this release
— the consent infrastructure goes in first so the gate is already in
place. Adds SPEC §14.5 / §14.6, lists two new endpoints in §17, names
the new table in §5, and surfaces four §19.2 candidates (content-repo
file vs env-var policy, GPC / DNT headers, i18n, item-#13 dependency).
Two new optional env vars (`VITE_PRIVACY_POLICY_URL`,
`VITE_COOKIES_POLICY_URL`) — defaults render the framework's stub
pages.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:04:52 -07:00
Ben Stull 0f8b318afa Release 0.4.0: auto-set RFC owner = proposer
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:53:09 -07:00
Ben Stull 21fcbc92d4 Release 0.3.0: private-beta gate + anonymous read mode
Adds an email allowlist (toggleable per deployment) that restricts
OAuth sign-in to listed emails while keeping read paths public.
Anonymous visitors now see the full app shell in read-only mode
instead of the §14.1 landing wall. Empty allowlist = gate off, so
deployments that don't enable it behave exactly as 0.2.3.

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

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

See CHANGELOG.md for full details + upgrade steps.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:40:40 -07:00
Ben Stull 018e323ed4 Release 0.2.2: Philosophy.jsx now renders mermaid (uses MarkdownPreview)
Philosophy.jsx parsed PHILOSOPHY.md with bare marked + dangerouslySet
InnerHTML, so ```mermaid fences fell through as <pre> blocks. Swapped
to MarkdownPreview, which already carries the lazy mermaid loader +
SVG render path used by the RFC body view. Pre-existing gap, surfaced
when an OHM deployment authored a mermaid block in its PHILOSOPHY_PATH
override.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 13:31:49 -07:00
40 changed files with 3654 additions and 119 deletions
+364
View File
@@ -23,6 +23,370 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond
that.
## 0.13.0 — 2026-05-28
**Minor — schema migration required; new optional env vars.** This
release ships the cookie / privacy consent surface (roadmap item #11,
SPEC §14.5 / §14.6). Every viewer — authenticated and anonymous alike —
now sees a non-modal bottom-of-page banner on first visit asking which
categories of cookies they allow (essential / essential + analytics /
essential + analytics + other). The choice persists in `localStorage`
for anonymous viewers and in a new `cookie_consent` table for
authenticated viewers, with server-side overriding local on sign-in.
The framework also ships default `/privacy` and `/cookies` policy pages
that deployments can layer their own policy URL on top of via two new
optional env vars. No analytics SDK ships in this release — the
consent infrastructure is wired so item #13 (v0.15.0) can read from
`frontend/src/lib/consent.js` when the SDK lands.
### Added
- **Cookie consent banner** (`frontend/src/components/CookieConsentBanner.jsx`).
Non-modal, bottom of viewport. Three single-select choices with
inline descriptions. Visible until the user makes a choice; hides
thereafter. Reachable for revision via the settings surface.
- **Consent helper** (`frontend/src/lib/consent.js`). Exports
`getConsent()`, `hasChosen()`, `onConsentChange(cb)`, `setConsent()`,
`hydrateFromServer()`, `clearLocal()`. Cross-tab sync via the
`storage` event. Item #13's analytics SDK reads consent here before
importing.
- **Privacy and cookies policy pages**
(`frontend/src/pages/Privacy.jsx`, `frontend/src/pages/Cookies.jsx`).
Default minimal policies that describe the framework's stance and
list the cookies the framework sets. Deployments override via the
two new env vars below; the framework's stub always renders above
the link so the framework-level contract stays visible.
- **"Privacy & cookies" tab** in `/settings/notifications` showing
the current consent choice, the recorded-at stamp, and a "Change"
button that re-opens the banner via a custom DOM event.
- **`§17` endpoints** —
- `GET /api/users/me/cookie-consent` — read the current consent
record.
- `PUT /api/users/me/cookie-consent` — write a new consent record.
Upserts a single row per user, stamps `recorded_at` to now,
accepts `essential` for symmetry but always persists it as true.
- **Schema migration** `012_cookie_consent.sql` — new
`cookie_consent` table keyed by `user_id`, three flags
(`essential`, `analytics`, `other_cookies`), and `recorded_at`.
- **SPEC `§14.5` Cookie / privacy consent** — settles the banner
shape, the three-category single-select, the storage shape (local
for anon, server row for authenticated), the precedence rule on
sign-in, and the `consent.js` helper surface for downstream
callers including item #13.
- **SPEC `§14.6` Privacy and cookies policy pages** — settles the
`/privacy` and `/cookies` routes, the framework's stub content, and
the `VITE_PRIVACY_POLICY_URL` / `VITE_COOKIES_POLICY_URL` override
shape.
- **SPEC `§5`** — names the `cookie_consent` table in the canonical
app-tables list.
- **SPEC `§17`** — lists the two new cookie-consent endpoints.
- **SPEC `§19.2`** — surfaces four candidates: policy content via
content-repo file vs env var, GPC / DNT headers, multi-language
consent text, and the item #13 analytics-SDK gating dependency.
### Changed
- **`frontend/.env.example`** — documents the two new optional env
vars `VITE_PRIVACY_POLICY_URL` and `VITE_COOKIES_POLICY_URL`. Unset
is supported; defaults render the framework's stub.
- **`backend/app/api_notifications.py`** — module docstring grew two
endpoint lines; the new endpoints sit alongside the existing
`/api/users/me/*` neighbors.
- **`frontend/src/App.jsx`** — registers `/privacy` and `/cookies`
routes (anonymous-reachable), wires `<CookieConsentBanner>` into
the global chrome, and listens for a `rfc-app:cookie-consent-reopen`
custom event to re-open the banner from the settings surface.
### Upgrade steps (from 0.7.0)
- You **MUST** rebuild the frontend and restart the backend after
upgrading. `frontend/package.json#version` and `VERSION` both move
to `0.13.0` and the build embeds the new env-var contract.
- You **MUST** apply schema migration `012_cookie_consent.sql`. The
migration creates a single new table keyed by `user_id` with three
flag columns and a `recorded_at` stamp. The framework runs
migrations automatically at process start; no manual step is
required beyond restarting the backend so the migration runner
picks the file up.
- You **MAY** set `VITE_PRIVACY_POLICY_URL` to an http(s) URL that
points at your deployment's full privacy policy. The framework's
`/privacy` page renders its built-in stub above a link to the
configured URL. Unset is supported — the stub is sufficient for a
default-config deployment.
- You **MAY** set `VITE_COOKIES_POLICY_URL` to an http(s) URL that
points at your deployment's full cookies policy. Same shape as the
privacy URL.
- You **MAY** announce the new consent banner to your users. Existing
authenticated users will see the banner on their next visit
(because their `cookie_consent` row does not yet exist); their
current sessions remain valid.
## 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
+334 -11
View File
@@ -253,13 +253,18 @@ and exact columns are illustrative; the implementing session can adjust.
- `threads` — every conversation in the system, whether scoped to an RFC's
main view, a branch, or a span within a branch's document. Columns:
`id`, `rfc_slug`, `branch_name` (nullable — null means scoped to the
RFC's main view), `anchor_kind` (`whole-doc` | `range` | `paragraph`),
RFC's main view, the PR-less per-RFC discussion surface per §10's
closing note; non-null means scoped to a branch's work, including
PR-comment threads), `anchor_kind` (`whole-doc` | `range` | `paragraph`),
`anchor_payload` (JSON: serialized ProseMirror range or paragraph id),
`thread_kind` (`chat` | `flag` | `review``review` is the diff-anchored
PR-review thread defined in §10.4), `label` (short human-authored summary;
for flags this is the entire content), `state` (`open` | `resolved` |
`stale`), `created_by`, `created_at`, `resolved_at`, `resolved_by`.
Visibility is derived from the underlying branch (§11.1).
Visibility is derived from the underlying branch (§11.1); for the
null-branch PR-less discussion surface, visibility follows the RFC
(anonymous read open per the §14 / v0.3.0 contract, write requires
contributor per §6.1, tightened toward anon-write-refused in v0.6.0).
- `thread_messages` — the actual chat content for `chat`-kind threads.
Columns: `id`, `thread_id`, `role` (`user` | `assistant` | `system`),
`author_user_id` (nullable; null for assistant), `model_id` (nullable;
@@ -327,6 +332,13 @@ and exact columns are illustrative; the implementing session can adjust.
- `actions` — append-only audit log for every state transition, every
graduation, every grant change. Includes the acting user, the bot
commit hash if any, and the on-behalf-of trailer applied.
- `cookie_consent` — per-user record of the §14.5 cookie consent
choice. One row per user. Columns: `user_id` (PK, FK users), three
flags (`essential`, `analytics`, `other_cookies`), and
`recorded_at`. `essential` is permanently 1; `recorded_at` is set
on first write and updated on every change. Absence of a row means
"no choice yet" — the banner shows. Anonymous viewers persist their
choice in `localStorage` only, with no corresponding row here.
**Super-draft scoping.** For rows in `threads` and `changes` where the
entry referenced by `rfc_slug` is in state `super-draft`, `branch_name`
@@ -1120,12 +1132,20 @@ The modal collects four fields:
type their own.
No proposer name or email — the logged-in identity is canonical and
need not be retyped. No proposed-owner or working-group fields —
ownership flows through the post-merge claim flow (§13.1), and
need not be retyped. No proposer-owner field either: the proposer is
implicitly the first owner of their own RFC, set automatically at
submit-time from the session user (see §9.2); the post-merge claim
flow (§13.1) remains for *other* contributors to add themselves as
owners on an RFC they didn't propose. No working-group field —
arbiters are admin work, not the proposer's call. AI's drafting role
is intentionally narrow: tag suggestions only. The proposer is
making a specific claim about a specific word, and having AI propose
the claim for them would undercut the gesture.
the claim for them would undercut the gesture. (§19.3 rule-2
correction: 0.4.0 narrowed this paragraph; the prior version asserted
"no proposed-owner field" without qualification, but running code
revealed that forcing a separate claim-flow gesture for the
proposer's own RFC was UX friction with no benefit — the proposer
already self-identified by proposing.)
Primary action: **"Open proposal PR"** — naming the actual Git
artifact produced, consistent with §10.1's *Open PR* and §13's
@@ -1141,7 +1161,13 @@ is populated from the modal and session:
- `state: super-draft`, `id: null`, `repo: null`,
`graduated_at: null`, `graduated_by: null` — fixed at creation.
- `proposed_by: <session email>`, `proposed_at: <today>` — auto.
- `owners: []` — empty; the claim flow (§13.1) fills this.
- `owners: [<proposer.gitea_login>]` — the proposer is the first
owner at propose time, set automatically from the session user.
The §13.1 claim flow remains available for *additional* owners on
the same entry. (§19.3 rule-2 correction: 0.4.0 changed this from
`owners: []`; the prior shape required a separate claim-flow
gesture for the proposer's own RFC, which running code surfaced
as needless UX friction.)
- `arbiters: []` — empty; arbiters are admin work, not the
proposer's call.
@@ -1625,6 +1651,40 @@ framework's evidence unit; admitting plumbing commits — "fix merge
conflict with main" — into that timeline would dilute the signal each
commit is meant to carry.
### 10.10 PR-less discussion vs. contribution
PR comments and branch chat (§10.4, §8.4) are PR-scoped: they live on
the `threads` rows whose `branch_name` names a branch (the PR's head
or, pre-PR, a feature branch). They are the right surface for *this
specific proposed change*. They are not the right surface for "what
about this part of the RFC overall?" or "have we considered…?" — a
question that doesn't yet warrant cutting a branch and that would
distort a PR's review timeline if it landed there.
The RFC view carries a **discussion surface** distinct from PR
comments. Its substrate is `threads` rows whose `branch_name IS NULL`
(§5) — the same conversation table used by branch chat, with the
nullable column doing the segregating. Posting a discussion thread or
message does not open a PR; the §1 chokepoint is unaffected because
chat messages never produced Git writes. Contribution — proposing
edits the document will land — still requires opening a PR via the
§10.1 affordance.
The distinction in one line: **discussion is what the RFC is for;
contribution is how the RFC changes.** Either is honest; conflating
them was the failure mode of generic-PR-comments-as-only-conversation.
Reads on the discussion surface follow §14 / the v0.3.0 anonymous-read
contract: anyone can see the conversation. Writes require contributor
role per §6.1 (v0.5.0 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
@@ -1718,11 +1778,20 @@ PR do not block graduation; they remain on the meta repo subject to
### 13.1 Claim ownership (prerequisite)
If a super-draft has no owner, any signed-in contributor can click
"Claim ownership," which opens a PR against the meta repo adding their
username to the `owners:` field of the entry. Owners and admins can
merge. (A self-merge window for un-acted claims is not enabled in v1;
configurable later if needed.) Multiple claims simply append.
The proposer is already the first owner of any super-draft they
proposed (per §9.2; auto-set at propose time). The claim flow exists
for *additional* contributors to become owners on an existing super-
draft — typically a draft whose proposer has stepped away, or a draft
graduating with a working group. Any signed-in contributor can click
"Claim ownership," which opens a PR against the meta repo adding
their username to the `owners:` field of the entry. Owners and admins
can merge. (A self-merge window for un-acted claims is not enabled in
v1; configurable later if needed.) Multiple claims simply append.
(§19.3 rule-2 correction: 0.4.0 narrowed this section's framing; the
prior version implied owners always started empty and the claim flow
was a hard prerequisite to graduation, but with the proposer now
auto-set as first owner, the claim flow is a graduation-time
broadening rather than a precondition for the proposer's own RFC.)
### 13.2 The Graduate dialog
@@ -1911,6 +1980,92 @@ The visual design of the landing page and the `/philosophy` route —
typography, layout, illustrations if any — is deferred. The structural
decisions above are the binding part.
### 14.5 Cookie / privacy consent (v0.13.0)
The framework ships a non-modal cookie consent banner reachable by
every viewer — authenticated and anonymous alike. The banner appears
at the bottom of the viewport on first load and stays visible until
the user makes a choice, after which it hides and the choice is
persisted. The `/settings/notifications` page carries a "Privacy &
cookies" tab that surfaces the current choice and re-opens the banner
on demand.
The choice has three categories, presented as a single-select:
- **Essential only** — the framework's strictly-necessary cookies
(sign-in session, signed payloads, the consent-choice record
itself). Always on; the user cannot switch this off because the
app cannot function without it.
- **Essential + analytics** — adds the optional analytics layer
gated by this choice. As of v0.13.0 no analytics SDK ships in the
framework; roadmap item #13 (v0.15.0) lands one behind this gate.
Off by default — the user has to opt in.
- **Essential + analytics + other** — adds third-party embeds or
social widgets a deployment may configure. The framework ships no
such cookies by default; this category exists so deployments that
add them have a categorized opt-in to wire them behind.
Storage shape:
- **Anonymous viewer** — choice persists in `localStorage` only
(`rfc-app.cookie-consent.v1`). The same browser carries the choice
forward; a different browser, or cleared storage, re-prompts.
- **Authenticated viewer** — choice persists in the `cookie_consent`
row keyed by `user_id`. On sign-in, the server row (if present)
overrides the local snapshot; if the server has no row, the local
choice is uploaded.
The `essential` flag is permanently true at the API surface. The
endpoint accepts it for symmetry but never persists a false value.
A deployment that wants strictly-necessary cookies to be optional
must change the framework contract, not flip a flag.
The framework exports a small JavaScript helper (`frontend/src/lib/
consent.js`) for downstream surfaces:
- `getConsent()` — current snapshot.
- `hasChosen()` — true once the user has made a choice.
- `onConsentChange(cb)` — subscribe to updates.
- `setConsent({analytics, other})` — record a new choice locally
(the banner / settings surface handles server persistence on top).
Roadmap item #13's analytics SDK (v0.15.0) will read from this helper:
read consent, then conditionally `import()` the SDK module. The gate
is wired before the SDK lands so the contract is already in place.
### 14.6 Privacy and cookies policy pages
The framework ships two policy routes:
- `/privacy` — a minimal default privacy policy that describes the
framework's stance (what is stored, why, how to revoke consent,
how to reach the deployment operator). The page is reachable by
anonymous and authenticated viewers alike.
- `/cookies` — the framework's cookies policy, listing exactly which
cookies the framework sets, by category. Self-documenting: a future
framework release that adds or removes a cookie updates this page
as part of the change.
Each page links to the other and to the §14.5 banner. The consent
banner links to both.
Deployments override the policy content via two optional build-time
env vars documented in `frontend/.env.example`:
- `VITE_PRIVACY_POLICY_URL` — an http(s) URL the `/privacy` page
links to as the "full deployment policy". The framework's stub
always renders above the link so the framework-level contract is
always visible; the link layers deployment-specific content on
top.
- `VITE_COOKIES_POLICY_URL` — same shape for `/cookies`.
Both are optional. Unset is the supported default; the stub pages are
sufficient for a default-config deployment that has nothing
deployment-specific to add. The framework chose the env-var path over
a content-repo file because it composes with the existing build-time
config layer; the content-repo-file alternative is the §19.2
candidate.
---
## 15. Notifications
@@ -2416,6 +2571,17 @@ specified* and what is intentionally out of scope for v1.
The follow-up session will refine this. A minimal starting set:
- `GET /api/health` — unauthenticated. Returns JSON
`{version, status}` where `version` is the running framework
version (the contents of `VERSION` per §20.1, read at process
start) and `status` is `"ok"` (HTTP 200) or `"degraded"`
(HTTP 503). v1 always reports `"ok"`; the 503 / `"degraded"`
path is reserved scaffold for future degradation conditions.
Used by ops tooling (e.g. the flotilla deploy control panel)
as a post-flight probe — the structural check is that the
returned `version` matches the tag the operator just deployed,
catching the failure mode where a restart did not pick up the
new code.
- `GET /api/rfcs` — list entries with state, id, title, slug, repo,
owners, last_active_at, has_open_prs, starred-by-me. Supports
search, sort, filter chips, and the `unclaimed` predicate.
@@ -2518,6 +2684,26 @@ The follow-up session will refine this. A minimal starting set:
- `POST /api/rfcs/<slug>/branches/<branch>/threads/<thread_id>/resolve`
— resolve a thread per §8.12; permission per the rules in that
section.
- `GET /api/rfcs/<slug>/discussion/threads` — list threads on the
RFC's PR-less discussion surface per §10.10 (rows where
`threads.branch_name IS NULL`). Anonymous-readable per the v0.3.0
anonymous-read contract; the default whole-doc chat thread is
materialized lazily on first read, mirroring the §8.12 branch-chat
default.
- `POST /api/rfcs/<slug>/discussion/threads` — open a discussion
thread per §10.10. Body: optional `label` (short summary), optional
first `message`. Writes require contributor role; anonymous viewers
receive 401. Thread is created with `anchor_kind='whole-doc'`,
`thread_kind='chat'`, `branch_name=NULL`.
- `GET /api/rfcs/<slug>/discussion/threads/<thread_id>/messages`
read messages on a discussion thread. Anonymous-readable.
- `POST /api/rfcs/<slug>/discussion/threads/<thread_id>/messages`
post a message into a discussion thread per §10.10. Body: `text`,
optional `quote`. Writes require contributor role.
- `POST /api/rfcs/<slug>/discussion/threads/<thread_id>/resolve`
resolve a discussion thread per §10.10; permission collapses to the
thread creator, any RFC owner / arbiter per §6.3, and any app
admin / owner per §6.1.
- `POST /api/rfcs/<slug>/branches/<branch>/open-pr` — open a PR per
§10.1; body carries the AI-drafted (and possibly edited) title and
description.
@@ -2623,6 +2809,16 @@ The follow-up session will refine this. A minimal starting set:
short confirmation page.
- `POST /api/webhooks/email-bounce` — bounce and complaint receiver
per §15.4; sets the recipient's global email opt-out.
- `GET /api/users/me/cookie-consent` — read the signed-in user's
cookie consent record per §14.5. Returns `{essential, analytics,
other, recorded_at}`. `recorded_at: null` means "no choice yet"
and the banner should be shown; the framework treats absence of a
row as equivalent to that. `essential` is permanently true.
- `PUT /api/users/me/cookie-consent` — write the signed-in user's
cookie consent record per §14.5. Body: `{essential, analytics,
other}`. The `essential` flag is accepted for symmetry but always
persisted as true. Upserts (a single row per user) and stamps
`recorded_at` to now.
Plus all the chat / streaming / model-picker endpoints, scoped to
per-RFC and per-branch threads.
@@ -3208,6 +3404,100 @@ binding.
read-only relationship counts as active). A future session may
settle a tighter definition (e.g., has any `actions` row on the
RFC) if the generous proxy refuses too many legitimate mutes.
- **Health-check endpoint for ops tooling.** *Settled in the
post-v1 session that picked it. The flotilla deploy control panel
(a sibling operator-side tool spec'd in flotilla/SPEC.md) needs a
small framework-side endpoint to verify a deploy landed correctly.
Specifically: an unauthenticated `GET /api/health` returning JSON
`{version, status}` where `version` is the running framework
version recorded at startup and `status` is `"ok"` (HTTP 200) or
`"degraded"` (HTTP 503). flotilla uses the endpoint as a
post-flight probe — after `systemctl restart` reports active,
flotilla polls the endpoint and verifies the returned `version`
matches the tag just deployed. The version-match check is the
structural catch for the failure mode where a restart did not
actually pick up the new code. Scope intentionally small: one
endpoint, version + status payload, no auth (no PII), ships in
a patch release as a §17 addition. The endpoint becomes part of
§20.3's versioned surface and is available to any deployment
without operator action — flotilla is one consumer of many
possible. Earns its session next: flotilla v1 is blocked on this
dependency.*
*Settled in this session as follows. The running process reads
the `VERSION` file at the repo root at import time and caches the
string as a module-level constant (`backend/app/health.py`),
mirroring `backend/app/philosophy.py`'s disk-first shape. The
§20.1 invariant guarantees `VERSION` and
`frontend/package.json#version` are equal, so the choice is
cleanliness rather than correctness: `VERSION` is the canonical
source per §20.1, lives at the repo root, is one text line. A
missing `VERSION` file fails loudly per §20.6 — the module raises
at import time rather than serving a placeholder, because the
whole point of the endpoint is the version-match check and a
silent default would defeat it. `"degraded"` is reserved scaffold
for v1: a healthy startup always reports `"ok"` with HTTP 200,
and the `"degraded"` / 503 path stays in the response shape so a
later release can wire real degradation conditions (reconciler
stuck, migrations pending, provider universe empty) without
breaking flotilla's parser. Probing SQLite at request time is
out of scope — per §4.2 the DB is colocated, so a process that
can respond can reach it; the check would add code for no
signal. The §20.4 CHANGELOG treatment is the patch shape — no
operator action required — with an `### Added` section naming
the endpoint so operators discover it and one MAY-language step
("operators MAY configure their monitoring to probe `/api/health`;
the endpoint is unauthenticated by design"). §17 now lists the
endpoint in its illustrative table.*
- **PR-less discussion: range and paragraph anchors.** v0.5.0 lands
the structural discussion surface (§10.10) but constrains every
PR-less thread to `anchor_kind='whole-doc'` — the data model permits
`range` and `paragraph` anchors (§5) but the UI work to surface a
passage-anchored thread on a non-branch view is the deferred half.
The natural follow-on is a margin-icon affordance on the main view
matching §8.12's branch-side surface, with the anchor stored on the
null-branch thread. Earns its session when discussion volume warrants
the precision; v0.5.0's flat surface is sufficient for most "have we
considered…?" gestures. Touches §10.10 and §8.12.
- **PR-less discussion: distinct notification event_kinds.** v0.5.0
routes per-RFC discussion messages through the existing
`chat_message_in_participated_thread` and `chat_reply_to_my_message`
event kinds with `branch_name=null` on the fan-out row. The inbox
prose reads identically whether the chat lives on a branch or on the
RFC's discussion surface, which is honest signal: the conversation
shape is the same; only the scope differs. A future session may
introduce `open_rfc_discussion_thread` / `post_rfc_discussion_message`
if evidence shows contributors want to filter discussion-vs-branch
chat distinctly in the §15.2 inbox. Touches §15.1 (the event_kind
enum), §15.2 (the inbox filter chips), and §10.10.
- **PR-less discussion: chat-seen cursor.** §15.7 commits the
`branch_chat_seen` cursor for branch-scoped chat. The PR-less
discussion surface has no equivalent cursor in v0.5.0; the inbox
reconciler's keying on `(rfc_slug, branch_name)` does match a
null-branch advance, but no write path advances it. A natural
follow-on is a sibling table — `rfc_discussion_seen` or a
null-branch row on `branch_chat_seen` — that the discussion panel
advances on read, closing the §15.7 reconciliation loop for
discussion-surface notifications. Defer-able until inbox volume on
the new surface shows it matters.
- **PR-less discussion: AI participation.** v0.5.0's discussion
surface is human-only — no AI participant invocation, no `<change>`
block parsing, no per-thread model picker. The branch chat (§8.12)
retains the §18 AI surface. The natural follow-on is wiring the AI
participant into discussion threads (the model picker, the
`Ask Claude` button on a selection tooltip) without enabling
document edits — a discussion-only AI turn produces only chat
content, no `changes` row, no commit. Contribution still requires a
PR; the AI's discussion-side help is just better prompts. Touches
§10.10, §8.12, and §18.
- **PR-less discussion: anonymous read polish.** v0.5.0 inherits the
v0.3.0 anonymous-read contract — anyone can read; only signed-in
contributors can write. The composer affordance for anonymous
viewers ("Sign in to comment.") matches the existing read-only-bar
treatment but the surface has not yet been audited for the v0.6.0
hardening that tightens write gates app-wide. The §19.2 "public
face of discuss mode" entry overlaps; this entry is its discussion-
surface variant.
- **Deployment-supplied subject framing.** The framework was built
with one deployment in mind (OHM, standardizing natural-language
vocabulary), but the substrate generalizes to any domain that
@@ -3261,6 +3551,39 @@ the new §15 (Notifications, in full), and §17 (the notification
endpoints — list, mark-read, stream, watch mutation, preferences,
quiet-hours, per-user mute, unsubscribe, bounce webhook).
Candidates surfaced during v0.13.0 (cookie / privacy consent, §14.5
and §14.6):
- **Policy content via content-repo file vs env var.** v0.13.0
shipped the deployment-policy-override path as two env vars
(`VITE_PRIVACY_POLICY_URL`, `VITE_COOKIES_POLICY_URL`) that the
framework's stub pages link out to. The alternative — accepting
a markdown file path the framework renders inline, parallel to
`PHILOSOPHY_PATH` per §14.2 — was deferred. The two compose:
a deployment could carry both an inline file (rendered above
the fold) and an external link (rendered below). Earns its own
topic when a real deployment ships a policy long enough that
the link-out shape bites and renders the link unread.
- **Global Privacy Control / Do-Not-Track headers.** v0.13.0
scoped the consent surface to the in-app banner and did not
honor browser-side GPC or DNT signals. The framework's stance
is that the in-app banner is the authoritative gesture — a
user who clears their consent in the banner has expressed
intent, and the GPC header is a coarser signal layered on top.
Earns its own topic if a regulatory regime emerges that treats
GPC as the legally-binding gesture, in which case the framework
would honor GPC as an automatic "essential only" choice unless
the user explicitly broadened it in-app.
- **Multi-language consent text.** The banner ships English-only.
i18n of the framework's user-facing strings is a broader topic
than the consent banner; carrying the work in that future topic
rather than as a per-surface translation pass.
- **Analytics SDK gating against `consent.js`.** Roadmap item #13
(target v0.15.0) lands the analytics SDK behind
`lib/consent.js`'s `getConsent().analytics` gate. The framework
contract is already in place; the SDK integration is the work
the item ships. Listed here so the dependency is documented.
### 19.3 Working agreement for the queue
Pre-build sessions ran on the queue agreement from prior versions
+1 -1
View File
@@ -1 +1 @@
0.2.1
0.13.0
+23 -1
View File
@@ -20,6 +20,7 @@ from pydantic import BaseModel, Field
from . import (
api_admin,
api_branches,
api_discussion,
api_graduation,
api_notifications,
api_prs,
@@ -28,6 +29,7 @@ from . import (
entry as entry_mod,
cache,
funder,
health,
philosophy,
providers as providers_mod,
)
@@ -80,6 +82,22 @@ def make_router(
# the §15.8 mute typeahead) and the §6/§17 admin surfaces
# (role, write-mute, audit-log, graduation-readiness queue).
router.include_router(api_admin.make_router(config))
# v0.5.0: §5 / §7 / §10 — PR-less per-RFC discussion endpoints.
# The substrate is the existing threads/thread_messages tables;
# rows whose branch_name IS NULL scope to the RFC's main view.
# Contribution still requires a PR (api_prs above); this surface
# is for discussion that does not yet warrant a branch.
router.include_router(api_discussion.make_router())
# ---------------------------------------------------------------
# §17: /api/health — unauthenticated post-flight probe.
# Used by ops tooling (flotilla) to verify a deploy landed via
# version-match. v1 always reports ok; see backend/app/health.py.
# ---------------------------------------------------------------
@router.get("/api/health")
async def get_health() -> dict[str, Any]:
return {"version": health.VERSION, "status": "ok"}
# ---------------------------------------------------------------
# §14.2: /api/philosophy — PHILOSOPHY.md served verbatim.
@@ -287,7 +305,11 @@ def make_router(
proposed_at=entry_mod.today(),
graduated_at=None,
graduated_by=None,
owners=[],
# §9.2: the proposer is the implicit first owner at propose time.
# The §13.1 claim flow exists for *other* contributors to become
# owners on an RFC they didn't propose; the proposer never needs
# to claim their own RFC.
owners=[user.gitea_login],
arbiters=[],
tags=[t.strip() for t in payload.tags if t.strip()],
body=payload.pitch.strip() + "\n",
+102
View File
@@ -50,6 +50,11 @@ class MuteBody(BaseModel):
muted: bool
class AllowlistAddBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
note: str | None = Field(default=None, max_length=200)
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
@@ -385,6 +390,103 @@ def make_router(config: Config) -> APIRouter:
]
}
# ----- Private-beta allowlist (`migrations/011_allowlist.sql`) -----
@router.get("/api/admin/allowlist")
async def list_allowlist(request: Request) -> dict[str, Any]:
auth.require_admin(request)
rows = db.conn().execute(
"""
SELECT a.email, a.note, a.created_at,
u.gitea_login AS added_by_login,
u.display_name AS added_by_display
FROM allowed_emails a
LEFT JOIN users u ON u.id = a.added_by_user_id
ORDER BY a.created_at DESC
"""
).fetchall()
return {
"active": len(rows) > 0,
"items": [
{
"email": r["email"],
"note": r["note"] or "",
"added_by_login": r["added_by_login"],
"added_by_display": r["added_by_display"],
"created_at": r["created_at"],
}
for r in rows
],
}
@router.post("/api/admin/allowlist")
async def add_allowlist(body: AllowlistAddBody, request: Request) -> dict[str, Any]:
viewer = auth.require_admin(request)
email = body.email.strip()
if "@" not in email or len(email.split("@")[-1]) < 2:
raise HTTPException(422, "Email looks malformed")
existing = db.conn().execute(
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
).fetchone()
if existing is not None:
raise HTTPException(409, "Email already on the allowlist")
db.conn().execute(
"""
INSERT INTO allowed_emails (email, added_by_user_id, note)
VALUES (?, ?, ?)
""",
(email, viewer.user_id, body.note),
)
# Audit trail: when the email already maps to a known user, emit a
# permission_events row so §6.5's log stays the single place to
# look for "who let this person in." For brand-new emails the
# allowed_emails row itself carries (added_by_user_id, created_at)
# which is sufficient until the user actually signs in.
subject = db.conn().execute(
"SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1", (email,)
).fetchone()
if subject is not None:
db.conn().execute(
"""
INSERT INTO permission_events
(actor_user_id, subject_user_id, event_kind, details)
VALUES (?, ?, 'allowlist_added', ?)
""",
(
viewer.user_id,
subject["id"],
json.dumps({"email": email, "note": body.note or ""}),
),
)
return {"ok": True, "email": email}
@router.delete("/api/admin/allowlist/{email}")
async def remove_allowlist(email: str, request: Request) -> dict[str, Any]:
viewer = auth.require_admin(request)
existing = db.conn().execute(
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
).fetchone()
if existing is None:
raise HTTPException(404, "Email not on the allowlist")
db.conn().execute("DELETE FROM allowed_emails WHERE email = ?", (email,))
subject = db.conn().execute(
"SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1", (email,)
).fetchone()
if subject is not None:
db.conn().execute(
"""
INSERT INTO permission_events
(actor_user_id, subject_user_id, event_kind, details)
VALUES (?, ?, 'allowlist_removed', ?)
""",
(
viewer.user_id,
subject["id"],
json.dumps({"email": email}),
),
)
return {"ok": True, "email": email}
return router
+330
View File
@@ -0,0 +1,330 @@
"""§5 / §7 / §10 — PR-less per-RFC discussion endpoints (v0.5.0).
This module surfaces the discussion-without-PR shape committed by the
roadmap's item #3. The substrate is the existing `threads` /
`thread_messages` pair from §5: rows whose `branch_name` is NULL are
scoped to the RFC's main view (the schema comment on the column says
exactly this; until now no write path produced such rows). This module
is the read+write surface for those rows.
Contribution still requires a PR: the §10 PR flow is unchanged, the
branch-scoped chat in `api_branches.py` is unchanged, and accept /
decline of AI `<change>` blocks still lives on a branch. What this
module adds is the "discuss freely about the RFC, no branch yet" surface
a place to drop a question, a flag-style observation, or a multi-turn
conversation that does not yet warrant cutting a branch.
Auth shape mirrors the v0.3.0 anonymous-read contract: reads are open,
writes require `auth.require_contributor`. Item #4 ("anon discuss/
contribute off-limits") tightens the read gate in v0.6.0; v0.5.0's
write gate already holds the line.
Notification routing reuses the existing `fan_out_chat_message` path
with `branch_name=None`; the `notifications.branch_name` column is
nullable, and the inbox row prose ("@alice posted a chat message on
<RFC title>") renders identically whether the chat lives on a branch
or on the RFC's discussion surface. The existing
`chat_message_in_participated_thread` / `chat_reply_to_my_message`
event kinds carry both shapes; introducing a parallel
`open_rfc_discussion_thread` / `post_rfc_discussion_message` enum pair
would split routing without adding signal. The §15 §19.2 candidate
"distinct event_kinds for PR-less discussion" notes the option for a
future session if evidence demands the split.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import auth, chat as chat_layer, db
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Request bodies
# ---------------------------------------------------------------------------
class DiscussionThreadCreateBody(BaseModel):
"""A discussion thread is a `thread_kind='chat'`, `anchor_kind='whole-doc'`,
`branch_name=NULL` row. Anchored-range / per-paragraph threads on the
RFC discussion surface are a §19.2 candidate the schema supports
them; the UI work to surface a range-anchor on a non-branch view is
the deferred part. v0.5.0 keeps the shape narrow."""
label: str | None = Field(default=None, max_length=400)
message: str | None = Field(default=None, max_length=20_000)
class DiscussionMessageBody(BaseModel):
text: str = Field(min_length=1, max_length=20_000)
quote: str | None = Field(default=None, max_length=2000)
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
def make_router() -> APIRouter:
router = APIRouter()
# -------------------------------------------------------------------
# GET /api/rfcs/<slug>/discussion/threads
# Lists every PR-less thread on the RFC. The default whole-doc thread
# is materialized lazily on first list (mirroring the §8.12 branch-
# chat default-thread treatment) so the UI always has a target for
# the compose-message affordance.
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/discussion/threads")
async def list_discussion_threads(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
_require_rfc_readable(slug)
# Ensure the default whole-doc discussion thread exists. We mint
# it on first read regardless of viewer (anonymous viewers can
# trigger the creation — the row's `created_by` is null in that
# case, mirroring `_ensure_branch_chat_thread`).
_ensure_discussion_thread(slug, viewer)
rows = db.conn().execute(
"""
SELECT id, anchor_kind, anchor_payload, thread_kind, label, state,
created_by, created_at, resolved_at, resolved_by
FROM threads
WHERE rfc_slug = ? AND branch_name IS NULL
ORDER BY id
""",
(slug,),
).fetchall()
return {"items": [_serialize_thread(r) for r in rows]}
# -------------------------------------------------------------------
# POST /api/rfcs/<slug>/discussion/threads
# Open a fresh discussion thread. Writes require require_contributor
# — anonymous viewers can read but cannot open a thread, per item
# #4's hardening anticipated in v0.6.0 (we already enforce it here
# to avoid the open window).
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/discussion/threads")
async def create_discussion_thread(
slug: str, body: DiscussionThreadCreateBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_readable(slug)
cur = db.conn().execute(
"""
INSERT INTO threads
(rfc_slug, branch_name, anchor_kind, anchor_payload,
thread_kind, label, created_by)
VALUES (?, NULL, 'whole-doc', NULL, 'chat', ?, ?)
""",
(slug, body.label, viewer.user_id),
)
thread_id = cur.lastrowid
message_id = None
if body.message:
message_id = chat_layer.append_user_message(
thread_id=thread_id,
author_user_id=viewer.user_id,
text=body.message,
quote=None,
)
return {"thread_id": thread_id, "message_id": message_id}
# -------------------------------------------------------------------
# GET /api/rfcs/<slug>/discussion/threads/<thread_id>/messages
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/discussion/threads/{thread_id}/messages")
async def get_discussion_thread_messages(
slug: str, thread_id: int, request: Request
) -> dict[str, Any]:
_viewer = auth.current_user(request)
_require_rfc_readable(slug)
thread = _require_discussion_thread(slug, thread_id)
rows = db.conn().execute(
"""
SELECT m.id, m.role, m.author_user_id,
u.gitea_login AS author_login,
u.display_name AS author_display,
m.model_id, m.text, m.quote, m.created_at
FROM thread_messages m
LEFT JOIN users u ON u.id = m.author_user_id
WHERE m.thread_id = ?
ORDER BY m.id
""",
(thread_id,),
).fetchall()
return {
"thread": _serialize_thread(thread),
"messages": [_serialize_message(r) for r in rows],
}
# -------------------------------------------------------------------
# POST /api/rfcs/<slug>/discussion/threads/<thread_id>/messages
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/discussion/threads/{thread_id}/messages")
async def post_discussion_message(
slug: str, thread_id: int, body: DiscussionMessageBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_readable(slug)
_require_discussion_thread(slug, thread_id)
message_id = chat_layer.append_user_message(
thread_id=thread_id,
author_user_id=viewer.user_id,
text=body.text,
quote=body.quote,
)
return {"ok": True, "message_id": message_id}
# -------------------------------------------------------------------
# POST /api/rfcs/<slug>/discussion/threads/<thread_id>/resolve
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/discussion/threads/{thread_id}/resolve")
async def resolve_discussion_thread(
slug: str, thread_id: int, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_readable(slug)
thread = _require_discussion_thread(slug, thread_id)
if not _can_resolve(rfc, thread, viewer):
raise HTTPException(
403,
"Only the thread creator, an RFC owner/arbiter, or an app admin/owner may resolve",
)
db.conn().execute(
"""
UPDATE threads
SET state = 'resolved',
resolved_by = ?,
resolved_at = datetime('now')
WHERE id = ?
""",
(viewer.user_id, thread_id),
)
return {"ok": True, "thread_id": thread_id}
return router
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _require_rfc_readable(slug: str):
"""Per the v0.3.0 anonymous-read contract: any cached RFC is readable
by anyone. Withdrawn entries refuse reads of every shape same rule
`_require_rfc_with_repo` in `api_branches.py` follows."""
row = db.conn().execute(
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn")
return row
def _require_discussion_thread(slug: str, thread_id: int):
"""A discussion thread is one whose (rfc_slug, branch_name) = (slug,
NULL). Refuse cleanly if the thread id resolves to a branch-scoped
thread instead that lookup belongs on the branch endpoints."""
row = db.conn().execute(
"""
SELECT * FROM threads
WHERE id = ? AND rfc_slug = ? AND branch_name IS NULL
""",
(thread_id, slug),
).fetchone()
if not row:
raise HTTPException(404, "Discussion thread not found")
return row
def _ensure_discussion_thread(slug: str, viewer) -> int:
"""Per the §8.12 lazy-create pattern, materialize a default whole-doc
chat thread on the RFC's discussion surface on first read. Created_by
is null when an anonymous viewer triggers creation the thread is
structurally owned by the RFC, not by whoever opened the view."""
row = db.conn().execute(
"""
SELECT id FROM threads
WHERE rfc_slug = ? AND branch_name IS NULL
AND anchor_kind = 'whole-doc' AND thread_kind = 'chat'
ORDER BY id LIMIT 1
""",
(slug,),
).fetchone()
if row:
return row["id"]
cur = db.conn().execute(
"""
INSERT INTO threads
(rfc_slug, branch_name, anchor_kind, thread_kind, label, created_by)
VALUES (?, NULL, 'whole-doc', 'chat', NULL, ?)
""",
(slug, viewer.user_id if viewer else None),
)
return cur.lastrowid
def _can_resolve(rfc, thread, viewer) -> bool:
if viewer is None:
return False
if viewer.role in ("owner", "admin"):
return True
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")
if viewer.gitea_login in owners or viewer.gitea_login in arbiters:
return True
if thread["created_by"] == viewer.user_id:
return True
return False
# ---------------------------------------------------------------------------
# Serializers — mirror api_branches.py's shape
# ---------------------------------------------------------------------------
def _serialize_thread(row) -> dict[str, Any]:
payload = row["anchor_payload"]
try:
anchor = json.loads(payload) if payload else None
except Exception:
anchor = None
return {
"id": row["id"],
"anchor_kind": row["anchor_kind"],
"anchor_payload": anchor,
"thread_kind": row["thread_kind"],
"label": row["label"],
"state": row["state"],
"created_by": row["created_by"],
"created_at": row["created_at"],
"resolved_at": row["resolved_at"] if "resolved_at" in row.keys() else None,
"resolved_by": row["resolved_by"] if "resolved_by" in row.keys() else None,
}
def _serialize_message(row) -> dict[str, Any]:
return {
"id": row["id"],
"role": row["role"],
"author_user_id": row["author_user_id"],
"author_login": row["author_login"],
"author_display": row["author_display"],
"model_id": row["model_id"],
"text": row["text"],
"quote": row["quote"],
"created_at": row["created_at"],
}
+79
View File
@@ -14,6 +14,8 @@ The endpoints in this module are:
- `POST /api/users/me/quiet-hours` set / clear
- `POST /api/users/<id>/notification-mute` §15.8
- `DELETE /api/users/<id>/notification-mute` §15.8
- `GET /api/users/me/cookie-consent` §14.5
- `PUT /api/users/me/cookie-consent` §14.5
- `GET /api/email/unsubscribe` §15.4 one-click
- `POST /api/webhooks/email-bounce` §15.4 receiver
@@ -73,6 +75,15 @@ class BounceBody(BaseModel):
kind: str = Field(default="hard") # 'hard' or 'complaint'
class CookieConsentBody(BaseModel):
# `essential` is always true at the surface; we accept it for symmetry
# but never persist a false value (the framework's strictly-necessary
# cookies are not user-optional per SPEC §14.5).
essential: bool = True
analytics: bool = False
other: bool = False
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
@@ -362,6 +373,74 @@ def make_router(config: Config) -> APIRouter:
)
return {"ok": True}
# ----- Cookie consent (v0.13.0 / roadmap item #11; SPEC §14.5) -----
#
# The shape is intentionally small: three flags + a recorded-at stamp.
# The banner's local-vs-server precedence rule lives in the frontend
# (`consent.js`): on sign-in, the server row (if any) overrides local;
# otherwise local is uploaded.
@router.get("/api/users/me/cookie-consent")
async def get_cookie_consent(request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
row = db.conn().execute(
"""
SELECT essential, analytics, other_cookies, recorded_at
FROM cookie_consent WHERE user_id = ?
""",
(viewer.user_id,),
).fetchone()
if row is None:
return {
"essential": True,
"analytics": False,
"other": False,
"recorded_at": None,
}
return {
"essential": bool(row["essential"]),
"analytics": bool(row["analytics"]),
"other": bool(row["other_cookies"]),
"recorded_at": row["recorded_at"],
}
@router.put("/api/users/me/cookie-consent")
async def set_cookie_consent(body: CookieConsentBody, request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
# `essential` is the framework's strictly-necessary set; the
# surface accepts the flag for symmetry but never persists a
# false value. SPEC §14.5: a deployment that wants to make
# session-cookie storage optional must change the framework
# contract, not flip a flag here.
db.conn().execute(
"""
INSERT INTO cookie_consent
(user_id, essential, analytics, other_cookies, recorded_at)
VALUES (?, 1, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
essential = 1,
analytics = excluded.analytics,
other_cookies = excluded.other_cookies,
recorded_at = excluded.recorded_at
""",
(
viewer.user_id,
1 if body.analytics else 0,
1 if body.other else 0,
),
)
row = db.conn().execute(
"SELECT recorded_at FROM cookie_consent WHERE user_id = ?",
(viewer.user_id,),
).fetchone()
return {
"ok": True,
"essential": True,
"analytics": bool(body.analytics),
"other": bool(body.other),
"recorded_at": row["recorded_at"] if row else None,
}
# ----- Email: one-click unsubscribe + bounce webhook -----
@router.get("/api/email/unsubscribe")
+37
View File
@@ -77,6 +77,43 @@ async def fetch_user_profile(config: Config, access_token: str) -> dict[str, Any
return resp.json()
def allowlist_is_active() -> bool:
"""The private-beta gate is on iff the `allowed_emails` table has any
rows. Empty list means "open" any successful OAuth provisions a
user; first row added flips the deployment into private-beta mode.
See `migrations/011_allowlist.sql` for the reasoning.
"""
row = db.conn().execute("SELECT 1 FROM allowed_emails LIMIT 1").fetchone()
return row is not None
def is_allowed_sign_in(profile: dict[str, Any]) -> bool:
"""Decide whether a freshly-completed OAuth profile may sign in.
Three accept paths:
1. The allowlist is empty (gate off).
2. The Gitea profile's email is in `allowed_emails` (case-insensitive).
3. A `users` row already exists for this `gitea_id` grandfather
per `migrations/011_allowlist.sql`.
"""
gitea_id = profile.get("id")
if gitea_id is not None:
existing = db.conn().execute(
"SELECT 1 FROM users WHERE gitea_id = ? LIMIT 1", (gitea_id,)
).fetchone()
if existing is not None:
return True
if not allowlist_is_active():
return True
email = (profile.get("email") or "").strip()
if not email:
return False
row = db.conn().execute(
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
).fetchone()
return row is not None
def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
"""Insert or update the users row for this Gitea profile.
+6 -1
View File
@@ -168,10 +168,15 @@ def _fan_out_chat(thread_id: int, author_user_id: int, message_id: int) -> None:
).fetchone()
if pr_row:
pr_number = pr_row["pr_number"]
# v0.5.0 (§5 / §10 — PR-less discussion): a thread with
# branch_name IS NULL is scoped to the RFC's main view. Pass None
# through to the notify chokepoint so the notifications row keeps
# `branch_name` null — coercing it to "main" would misroute the
# §15.7 chat-seen reconciler (which keys on branch_name).
notify.fan_out_chat_message(
actor_user_id=author_user_id,
rfc_slug=row["rfc_slug"],
branch_name=row["branch_name"] or "main",
branch_name=row["branch_name"],
thread_id=thread_id,
message_id=message_id,
is_review_thread=(row["thread_kind"] == "review"),
+37
View File
@@ -0,0 +1,37 @@
"""§17 health-check endpoint source.
A small unauthenticated probe used by ops tooling (e.g. the flotilla
deploy control panel) to verify a deploy landed correctly. The
structural value is the version-match check after `systemctl
restart` reports active, the operator polls `/api/health` and
verifies the returned `version` equals the tag just deployed,
catching the failure mode where a restart did not pick up the new
code.
The version is read from the `VERSION` file at the repo root at
import time (§20.1's canonical source) and cached as a module-level
constant. A missing `VERSION` fails loudly per §20.6 rather than
serving a placeholder a silent default would defeat the
version-match check that is the entire point of the endpoint.
`status` is always `"ok"` (HTTP 200) in v1. The `"degraded"` / 503
path stays in the response shape so a later release can wire real
degradation conditions (reconciler stuck, migrations pending,
provider universe empty) without breaking the contract.
"""
from __future__ import annotations
from pathlib import Path
_VERSION_PATH = Path(__file__).resolve().parents[2] / "VERSION"
def _read_version() -> str:
text = _VERSION_PATH.read_text(encoding="utf-8").strip()
if not text:
raise RuntimeError(f"VERSION file at {_VERSION_PATH} is empty")
return text
VERSION: str = _read_version()
+6
View File
@@ -107,6 +107,12 @@ def _oauth_router(config) -> APIRouter:
if not access_token:
raise HTTPException(400, "Token exchange failed")
profile = await auth.fetch_user_profile(config, access_token)
if not auth.is_allowed_sign_in(profile):
# Private-beta gate: clear any partial OAuth state and bounce to
# the public /beta-pending page. The session is left empty so the
# rejected viewer continues as anonymous read-only.
request.session.pop(auth.SESSION_STATE_KEY, None)
return RedirectResponse("/beta-pending")
user = auth.provision_user(config, profile)
auth.store_session(request, user)
return RedirectResponse("/")
+9 -1
View File
@@ -212,7 +212,7 @@ def fan_out_chat_message(
*,
actor_user_id: int,
rfc_slug: str,
branch_name: str,
branch_name: str | None,
thread_id: int,
message_id: int,
is_review_thread: bool = False,
@@ -227,6 +227,14 @@ def fan_out_chat_message(
(state='watching', i.e. full stream) get a churn-class
`chat_message_in_participated_thread`. The two are union'd so a user
who is both gets only the personal-direct row.
v0.5.0: `branch_name` may be None that is the PR-less per-RFC
discussion shape (`threads.branch_name IS NULL`, §5). The
notifications row carries the null through; the inbox prose renders
identically whether the chat lives on a branch or on the RFC's
discussion surface, and the §15.7 reconciler keys on
(rfc_slug, branch_name) so a null branch correctly matches the
PR-less discussion's eventual chat-seen-equivalent advance.
"""
_bump_auto_watch(actor_user_id, rfc_slug)
+22
View File
@@ -0,0 +1,22 @@
-- Private-beta email allowlist.
--
-- The framework supports a deployment-gated sign-in mode: when this
-- table contains rows, only emails listed here (case-insensitively)
-- may sign in via OAuth. Users already provisioned in the `users`
-- table are grandfathered in by gitea_id and never re-checked against
-- this list — so the operator who allow-listed themselves, signed in
-- once, then removed their own email from the list does not lose
-- access.
--
-- An empty `allowed_emails` table is the "open" state: no allowlist
-- gate runs, and any successful OAuth sign-in provisions a new user
-- as before. This means a fresh framework install behaves exactly as
-- prior versions until the operator adds the first row, at which
-- point the gate turns on for everyone not yet in `users`.
CREATE TABLE allowed_emails (
email TEXT PRIMARY KEY COLLATE NOCASE,
added_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
+35
View File
@@ -0,0 +1,35 @@
-- v0.13.0 / roadmap item #11 — cookie consent.
--
-- The framework now ships a non-modal cookie consent banner per the
-- privacy-and-cookies UX (SPEC §14.5 / §14.6). Authenticated viewers
-- get their choice persisted server-side so it survives sign-out /
-- sign-in across devices; anonymous viewers persist their choice in
-- localStorage only.
--
-- Shape: a single row per user, three flags, plus a recorded-at stamp.
-- The flags are:
-- - essential: the framework's strictly-necessary cookies (session,
-- itsdangerous-signed payloads, CSRF if any). Permanently
-- true at the API surface — included in the row for
-- symmetry with the analytics / other flags rather than
-- because the user can switch it off.
-- - analytics: reserved for the §13 analytics SDK gating that lands
-- in v0.15.0. Off by default; opt-in via the banner.
-- - other: everything else (third-party embeds, social widgets).
-- Off by default; opt-in via the banner.
--
-- A NULL recorded_at means "no choice yet" — the banner should re-prompt
-- the next time the user signs in on a fresh device. Once recorded_at is
-- set, the banner is hidden until the user re-opens it from the
-- /settings/notifications "Privacy & cookies" tab.
--
-- The row is created lazily on first PUT. Absence of a row is equivalent
-- to NULL recorded_at — the banner shows.
CREATE TABLE cookie_consent (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
essential INTEGER NOT NULL DEFAULT 1 CHECK (essential IN (0, 1)),
analytics INTEGER NOT NULL DEFAULT 0 CHECK (analytics IN (0, 1)),
other_cookies INTEGER NOT NULL DEFAULT 0 CHECK (other_cookies IN (0, 1)),
recorded_at TEXT
);
@@ -0,0 +1,205 @@
"""End-to-end tests for v0.13.0 / roadmap item #11 — cookie / privacy consent.
Covers the §17 endpoints (`GET` / `PUT /api/users/me/cookie-consent`) and
the §14.5 storage contract:
* GET on a fresh user returns no-choice-yet (recorded_at is None,
essential=True, analytics=False, other=False).
* PUT writes a row, stamps recorded_at, and the choice survives.
* PUT with `analytics=true, other=false` round-trips faithfully.
* `essential` is permanently true at the API surface a PUT that
requests essential=false is still persisted with essential=true.
* The endpoint requires authentication (401 for anon).
* A second PUT updates the existing row in place (single row per
user, recorded_at re-stamps).
* Choice persists across sign-out / sign-in.
"""
from __future__ import annotations
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
def test_get_cookie_consent_fresh_user_has_no_choice(app_with_fake_gitea):
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")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.get("/api/users/me/cookie-consent")
assert r.status_code == 200, r.text
body = r.json()
assert body["essential"] is True
assert body["analytics"] is False
assert body["other"] is False
assert body["recorded_at"] is None
def test_put_cookie_consent_records_choice(app_with_fake_gitea):
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")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": True, "other": False},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert body["essential"] is True
assert body["analytics"] is True
assert body["other"] is False
assert body["recorded_at"] is not None
# Round-trip the read endpoint.
r = client.get("/api/users/me/cookie-consent")
body = r.json()
assert body["essential"] is True
assert body["analytics"] is True
assert body["other"] is False
assert body["recorded_at"] is not None
def test_put_cookie_consent_forces_essential_true(app_with_fake_gitea):
"""§14.5: `essential` is permanently true at the API surface. A
request that sets it to false is accepted (for symmetry with the
other two flags) but persisted as true.
"""
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=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.put(
"/api/users/me/cookie-consent",
json={"essential": False, "analytics": False, "other": False},
)
assert r.status_code == 200, r.text
assert r.json()["essential"] is True
# Confirm at the schema layer too — the persisted row has essential=1.
row = db.conn().execute(
"SELECT essential FROM cookie_consent WHERE user_id = ?",
(2,),
).fetchone()
assert row["essential"] == 1
def test_cookie_consent_requires_auth(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/api/users/me/cookie-consent")
assert r.status_code == 401, r.text
r = client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": True, "other": True},
)
assert r.status_code == 401, r.text
def test_put_cookie_consent_upserts_in_place(app_with_fake_gitea):
"""A second PUT updates the existing row rather than inserting a new
one. Verifies the §14.5 single-row-per-user shape.
"""
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=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": True, "other": False},
)
client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": False, "other": True},
)
rows = db.conn().execute(
"SELECT analytics, other_cookies FROM cookie_consent WHERE user_id = ?",
(2,),
).fetchall()
assert len(rows) == 1
assert rows[0]["analytics"] == 0
assert rows[0]["other_cookies"] == 1
def test_cookie_consent_persists_across_sign_out_in(app_with_fake_gitea):
"""§14.5 precedence: the server row survives sign-out / sign-in.
"""
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")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": True, "other": True},
)
# Simulate sign-out by clearing the session cookie.
client.cookies.clear()
# Anonymous viewer cannot read.
r = client.get("/api/users/me/cookie-consent")
assert r.status_code == 401
# Sign back in as Alice. The server row is still there.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.get("/api/users/me/cookie-consent")
body = r.json()
assert body["analytics"] is True
assert body["other"] is True
assert body["recorded_at"] is not None
def test_two_users_have_independent_rows(app_with_fake_gitea):
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")
provision_user_row(user_id=3, login="bob", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": True, "other": False},
)
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": False, "other": False},
)
# Each user reads their own row.
r = client.get("/api/users/me/cookie-consent").json()
assert r["analytics"] is False # Bob's
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.get("/api/users/me/cookie-consent").json()
assert r["analytics"] is True # Alice's
+237
View File
@@ -0,0 +1,237 @@
"""End-to-end integration tests for the v0.5.0 PR-less discussion
surface roadmap item #3, "discussion without PR; contribution requires
PR."
The vertical: an active RFC exists; the discussion endpoints under
`/api/rfcs/<slug>/discussion/...` open threads with
`threads.branch_name IS NULL`, post messages into them, and surface
them on subsequent reads. Branch-scoped threads (the §8.12 surface)
remain segregated. Anonymous viewers can read; only signed-in
contributors can write.
"""
from __future__ import annotations
import pytest
# Reuse the harness from Slice 1 / Slice 2.
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_rfc_view_vertical import seed_active_rfc, SEED_BODY
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_create_and_post_to_pr_less_discussion_thread(app_with_fake_gitea):
"""The vertical: signed-in contributor opens a thread on the RFC's
discussion surface, posts a message, and the thread + message
surface on subsequent reads with branch_name IS NULL."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="alice", display_name="Alice", role="contributor")
# Listing materializes the default whole-doc thread.
r = client.get("/api/rfcs/ohm/discussion/threads")
assert r.status_code == 200, r.text
items = r.json()["items"]
assert len(items) == 1
default_thread_id = items[0]["id"]
assert items[0]["anchor_kind"] == "whole-doc"
assert items[0]["thread_kind"] == "chat"
# Open an additional discussion thread with a first message.
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Question about §3", "message": "Is consent baked into the trait model?"},
)
assert r.status_code == 200, r.text
payload = r.json()
thread_id = payload["thread_id"]
message_id = payload["message_id"]
assert thread_id is not None and message_id is not None
# Confirm the row carries branch_name IS NULL (the PR-less shape).
row = db.conn().execute(
"SELECT rfc_slug, branch_name, thread_kind, anchor_kind, created_by FROM threads WHERE id = ?",
(thread_id,),
).fetchone()
assert row["rfc_slug"] == "ohm"
assert row["branch_name"] is None
assert row["thread_kind"] == "chat"
assert row["anchor_kind"] == "whole-doc"
assert row["created_by"] == 1
# The thread surfaces on the list endpoint alongside the default.
r = client.get("/api/rfcs/ohm/discussion/threads")
ids = [t["id"] for t in r.json()["items"]]
assert default_thread_id in ids
assert thread_id in ids
# Posting a reply on the new thread persists and returns the id.
r = client.post(
f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages",
json={"text": "Following up — see §3.2."},
)
assert r.status_code == 200, r.text
reply_id = r.json()["message_id"]
# The messages read endpoint returns both messages in order.
r = client.get(f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages")
assert r.status_code == 200
messages = r.json()["messages"]
assert [m["id"] for m in messages] == [message_id, reply_id]
assert messages[0]["author_login"] == "alice"
assert messages[0]["text"].startswith("Is consent")
def test_anonymous_can_read_but_cannot_post_discussion(app_with_fake_gitea):
"""Per the v0.3.0 anonymous-read contract: reads on the discussion
surface are open; write attempts return 401. v0.6.0 (item #4) will
tighten the read gate v0.5.0 holds the write line so there is no
open window between releases."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# Seed the discussion thread + first message as Alice.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"message": "First."},
)
assert r.status_code == 200
thread_id = r.json()["thread_id"]
# Drop the session — viewer is anonymous now.
client.cookies.clear()
# Reads are open.
r = client.get("/api/rfcs/ohm/discussion/threads")
assert r.status_code == 200
assert any(t["id"] == thread_id for t in r.json()["items"])
r = client.get(f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages")
assert r.status_code == 200
assert len(r.json()["messages"]) >= 1
# Writes refuse 401.
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"message": "Drive-by."},
)
assert r.status_code == 401
r = client.post(
f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages",
json={"text": "Drive-by reply."},
)
assert r.status_code == 401
def test_discussion_threads_and_branch_threads_are_segregated(app_with_fake_gitea):
"""A branch-scoped thread (the §8.12 surface, branch_name='main' or a
feature branch) MUST NOT surface on the discussion endpoint, which
is keyed on branch_name IS NULL. The two surfaces share a table; the
null-filter is what segregates them."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=3, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice", role="contributor")
# Manually materialize a branch-scoped thread on a feature branch.
db.conn().execute(
"""
INSERT INTO threads
(rfc_slug, branch_name, anchor_kind, thread_kind, label, created_by)
VALUES ('ohm', 'alice-draft-aa00', 'whole-doc', 'chat', NULL, 3)
"""
)
# And one on the discussion surface.
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"message": "Discussion-surface message."},
)
assert r.status_code == 200
discussion_thread_id = r.json()["thread_id"]
# The discussion list contains the null-branch thread (plus the
# default whole-doc) and excludes the feature-branch thread.
r = client.get("/api/rfcs/ohm/discussion/threads")
assert r.status_code == 200
ids = [t["id"] for t in r.json()["items"]]
assert discussion_thread_id in ids
# Feature-branch thread MUST NOT surface.
branch_thread_row = db.conn().execute(
"SELECT id FROM threads WHERE branch_name = 'alice-draft-aa00'"
).fetchone()
assert branch_thread_row is not None
assert branch_thread_row["id"] not in ids
def test_discussion_thread_resolve_permissions(app_with_fake_gitea):
"""A thread's creator can resolve it; an unrelated contributor cannot;
an admin / owner / RFC-owner can. Mirrors §8.12's resolution rule for
branch-scoped threads."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=4, login="alice", role="contributor")
provision_user_row(user_id=5, login="bob", role="contributor")
provision_user_row(user_id=6, login="ben", role="owner")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=4, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Alice's thread", "message": "..."},
)
thread_id = r.json()["thread_id"]
# Unrelated contributor refused.
sign_in_as(client, user_id=5, gitea_login="bob", display_name="Bob", role="contributor")
r = client.post(f"/api/rfcs/ohm/discussion/threads/{thread_id}/resolve")
assert r.status_code == 403
# Creator allowed.
sign_in_as(client, user_id=4, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post(f"/api/rfcs/ohm/discussion/threads/{thread_id}/resolve")
assert r.status_code == 200
# Open another thread, resolve it as the owner.
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Another thread", "message": "..."},
)
thread_id2 = r.json()["thread_id"]
sign_in_as(client, user_id=6, gitea_login="ben", display_name="Ben", role="owner")
r = client.post(f"/api/rfcs/ohm/discussion/threads/{thread_id2}/resolve")
assert r.status_code == 200
def test_discussion_404_on_unknown_rfc(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/api/rfcs/nonexistent/discussion/threads")
assert r.status_code == 404
+68
View File
@@ -0,0 +1,68 @@
"""Integration tests for the §17 `/api/health` endpoint.
The endpoint is the structural framework-side dependency for the
flotilla deploy control panel see SPEC.md §17 and the §19.2
candidate-topic settlement. The tests cover the contract: HTTP 200,
JSON `{version, status}`, `version` matches `VERSION` at the repo
root, `status` is `"ok"` in v1, and no auth gate is in front of it.
"""
from __future__ import annotations
from pathlib import Path
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea,
tmp_env,
)
_VERSION_PATH = Path(__file__).resolve().parents[2] / "VERSION"
def test_health_returns_version_and_ok_status(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
expected_version = _VERSION_PATH.read_text(encoding="utf-8").strip()
with TestClient(app) as client:
r = client.get("/api/health")
assert r.status_code == 200, r.text
payload = r.json()
assert payload == {"version": expected_version, "status": "ok"}
def test_health_is_unauthenticated(app_with_fake_gitea):
"""flotilla polls /api/health without credentials; the endpoint
must not require a session cookie. The §17 entry names it
unauthenticated by design no PII in the payload, version-match
is the whole structural value, and gating it would force ops
tooling to hold a long-lived token for a check that needs none.
"""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# No sign-in step. A fresh client has no session cookie.
r = client.get("/api/health")
assert r.status_code == 200, r.text
assert "version" in r.json()
def test_health_version_matches_VERSION_file(app_with_fake_gitea):
"""The §20.1 invariant: VERSION at the repo root is the canonical
source. The endpoint reports the same string verbatim, so flotilla's
post-flight version-match check can compare against the tag
operators just deployed without a transform step.
"""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
raw = _VERSION_PATH.read_text(encoding="utf-8")
expected = raw.strip()
# The file itself carries no leading "v" per §20.1, and the endpoint
# mirrors that — a flotilla check comparing against `v0.2.3` would
# need to strip the prefix on its side, not ours.
assert not expected.startswith("v"), "VERSION must not carry a leading v per §20.1"
with TestClient(app) as client:
r = client.get("/api/health")
assert r.json()["version"] == expected
+40
View File
@@ -496,6 +496,11 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
proposal = r.json()
assert proposal["entry"]["title"] == "Open Human Model"
assert proposal["entry"]["state"] == "super-draft"
# §9.2: the proposer is the implicit first owner at propose time.
# The owners field is a single-element list containing exactly the
# session user's gitea_login — no request-supplied owner field
# exists or is honored.
assert proposal["entry"]["owners"] == ["alice"]
assert proposal["affordances"]["merge"] is True
# Owner merges. The catalog picks up the new super-draft.
@@ -516,6 +521,10 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
view = r.json()
assert view["state"] == "super-draft"
assert "shared definition" in view["body"]
# §9.2: the auto-set proposer-owner survives the meta-repo round-trip
# — it's in the file's frontmatter on main after merge, not just
# in the pending-PR view above.
assert view["owners"] == ["alice"]
# The pending-ideas list no longer carries the merged proposal.
r = client.get("/api/proposals")
@@ -568,6 +577,37 @@ def test_anonymous_cannot_propose(app_with_fake_gitea):
assert r.status_code == 401
def test_proposer_is_auto_owner_request_payload_ignored(app_with_fake_gitea):
"""§9.2: the owners field on the new entry is always exactly
`[session.gitea_login]`. The propose endpoint never accepts an owner
from the client; a request payload that smuggles one in is ignored
by the Pydantic body model and the auto-set value lands instead.
"""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=11, login="alice", role="contributor")
sign_in_as(client, user_id=11, gitea_login="alice", display_name="Alice", role="contributor")
# Extra unknown fields like `owners` are dropped by the
# ProposeBody model; the session user is the only source of truth.
r = client.post("/api/rfcs/propose", json={
"title": "Spoof attempt",
"slug": "spoof-attempt",
"pitch": "p",
"tags": [],
"owners": ["mallory", "eve"],
"proposed_by": "mallory@test",
})
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
r = client.get(f"/api/proposals/{pr_number}")
assert r.status_code == 200, r.text
entry = r.json()["entry"]
assert entry["owners"] == ["alice"]
# proposed_by also comes from the session, never the body.
assert entry["proposed_by"] in ("alice@test", "alice")
def test_withdraw_by_proposer_works(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
+23 -17
View File
@@ -1,7 +1,7 @@
# RFC App — Deployment Reference & New-Session Prompt
Use this document as:
1. A reference for the current `rfc.wiggleverse.org` deployment
1. A reference for the current `ohm.wiggleverse.org` deployment
2. A prompt to paste into a new Claude session to deploy a new version
---
@@ -36,10 +36,10 @@ For reference, the separate Gitea VM is `wiggleverse` project / `gitea` VM / 34.
| Record | Type | Value | Proxy |
|--------|------|-------|-------|
| `rfc.wiggleverse.org` | A | 34.132.29.41 | DNS-only (gray cloud) |
| `ohm.wiggleverse.org` | A | 34.132.29.41 | DNS-only (gray cloud) |
| `_dmarc.wiggleverse.org` | TXT | `v=DMARC1; p=none; rua=mailto:ben@wiggleverse.org` | n/a |
> Note: `rfc.wiggleverse.org` uses **Let's Encrypt via certbot** directly on the VM. Keep the A record **DNS only (gray cloud)** — Cloudflare Flexible SSL would conflict with certbot.
> Note: `ohm.wiggleverse.org` uses **Let's Encrypt via certbot** directly on the VM. Keep the A record **DNS only (gray cloud)** — Cloudflare Flexible SSL would conflict with certbot.
SPF (`v=spf1 include:_spf.google.com ~all`) and DKIM (`google._domainkey`) for `wiggleverse.org` are already in place via Workspace.
@@ -64,7 +64,7 @@ SPF (`v=spf1 include:_spf.google.com ~all`) and DKIM (`google._domainkey`) for `
| `/opt/rfc-app/backend/.env` | All secrets and config (mode 0600) |
| `/opt/rfc-app/backend/data/rfc-app.db` | SQLite database |
| `/opt/rfc-app/frontend/dist/` | Built React SPA (served by nginx) |
| `/etc/nginx/sites-available/rfc.wiggleverse.org` | nginx vhost config |
| `/etc/nginx/sites-available/ohm.wiggleverse.org` | nginx vhost config |
| `/etc/systemd/system/rfc-app.service` | systemd unit |
---
@@ -79,7 +79,7 @@ SPF (`v=spf1 include:_spf.google.com ~all`) and DKIM (`google._domainkey`) for `
## Gitea Setup (one-time)
These are already done for `rfc.wiggleverse.org`. Document here for replication.
These are already done for `ohm.wiggleverse.org`. Document here for replication.
### Bot service account
@@ -91,13 +91,13 @@ Created in Gitea as `rfc-bot`. Token scopes: `write:repository`, `write:user`, `
### Meta repo
`wiggleverse/meta` — seeded by `scripts/seed_meta_repo.py`. Contains `PHILOSOPHY.md`, `README.md`, `CONTRIBUTING.md`, and `rfcs/` directory. Gitea webhook registered to `https://rfc.wiggleverse.org/api/webhooks/gitea`.
`wiggleverse/meta` — seeded by `scripts/seed_meta_repo.py`. Contains `PHILOSOPHY.md`, `README.md`, `CONTRIBUTING.md`, and `rfcs/` directory. Gitea webhook registered to `https://ohm.wiggleverse.org/api/webhooks/gitea`.
### OAuth2 app
Registered in Gitea Site Administration → Integrations → OAuth2 Applications:
- Name: `RFC App`
- Redirect URI: `https://rfc.wiggleverse.org/auth/callback`
- Redirect URI: `https://ohm.wiggleverse.org/auth/callback`
- Client ID and secret stored in `.env`
---
@@ -133,7 +133,7 @@ OAUTH_CLIENT_ID=<from Gitea OAuth app>
OAUTH_CLIENT_SECRET=<from Gitea OAuth app>
# App
APP_URL=https://rfc.wiggleverse.org
APP_URL=https://ohm.wiggleverse.org
SECRET_KEY=<openssl rand -hex 32>
DATABASE_PATH=/opt/rfc-app/backend/data/rfc-app.db
OWNER_GITEA_LOGIN=ben.stull
@@ -182,10 +182,16 @@ sudo systemctl restart rfc-app
For frontend changes, build on the VM directly (Node 20+ is already there):
```bash
cd /opt/rfc-app/frontend && sudo -u rfc-app npm install
cd /opt/rfc-app/frontend && sudo -u rfc-app npm ci
sudo -u rfc-app npm run build
```
`npm ci` installs strictly from the committed `package-lock.json` and
will not regenerate it. Using `npm install` here causes the VM's npm
to rewrite the lockfile in place (notably stripping `libc` fields on
optional rollup native packages), which then conflicts with `git
checkout <tag>` on the next deploy.
The output lands in `/opt/rfc-app/frontend/dist/` owned by `rfc-app` — nginx serves it directly, no copy step needed.
(Building locally and `gcloud compute scp`-ing the dist also works. Plain `rsync -e ssh` from the Mac fails because OS Login uses short-lived SSH certs that only the gcloud wrapper can mint interactively.)
@@ -197,7 +203,7 @@ Schema migrations run automatically on restart (append-only, safe to re-run).
## First-Time Deployment (new server)
### 1. Add DNS record
Add `rfc.wiggleverse.org` → 34.132.29.41 as an A record in Cloudflare, **DNS only (gray cloud)**. Do not proxy — certbot needs to reach the VM directly.
Add `ohm.wiggleverse.org` → 34.132.29.41 as an A record in Cloudflare, **DNS only (gray cloud)**. Do not proxy — certbot needs to reach the VM directly.
### 2. Host prep
```bash
@@ -230,15 +236,15 @@ sudo -u rfc-app -H bash -c \
### 6. Build the frontend (on the VM)
```bash
cd /opt/rfc-app/frontend && sudo -u rfc-app npm install
cd /opt/rfc-app/frontend && sudo -u rfc-app npm ci
sudo -u rfc-app npm run build
```
### 7. nginx
```bash
sudo cp /opt/rfc-app/deploy/nginx/rfc.wiggleverse.org.conf \
/etc/nginx/sites-available/rfc.wiggleverse.org
sudo ln -s /etc/nginx/sites-available/rfc.wiggleverse.org \
sudo cp /opt/rfc-app/deploy/nginx/ohm.wiggleverse.org.conf \
/etc/nginx/sites-available/ohm.wiggleverse.org
sudo ln -s /etc/nginx/sites-available/ohm.wiggleverse.org \
/etc/nginx/sites-enabled/
sudo usermod -a -G rfc-app www-data
sudo chmod -R g+rX /opt/rfc-app/frontend/dist
@@ -247,7 +253,7 @@ sudo nginx -t && sudo systemctl reload nginx
### 8. Let's Encrypt
```bash
sudo certbot --nginx -d rfc.wiggleverse.org
sudo certbot --nginx -d ohm.wiggleverse.org
```
### 9. systemd
@@ -259,7 +265,7 @@ sudo systemctl status rfc-app
```
### 10. Smoke test
Visit `https://rfc.wiggleverse.org`:
Visit `https://ohm.wiggleverse.org`:
1. Landing page renders with sign-in button
2. Sign in with Gitea OAuth → catalog loads
3. `+ Propose New RFC` opens the propose modal
@@ -301,7 +307,7 @@ Paste the following into a new Claude session to continue development:
---
> I'm working on the **Wiggleverse RFC App** — a FastAPI + SQLite + React + Vite application deployed at `rfc.wiggleverse.org` on a GCP e2-small VM (`rfc-app` in the `wiggleverse-rfc` project; separate from the `gitea` VM in `wiggleverse` that runs Gitea at `git.wiggleverse.org`). The app is the primary interface for the Open Human Model (OHM) RFC working group.
> I'm working on the **Wiggleverse RFC App** — a FastAPI + SQLite + React + Vite application deployed at `ohm.wiggleverse.org` on a GCP e2-small VM (`rfc-app` in the `wiggleverse-rfc` project; separate from the `gitea` VM in `wiggleverse` that runs Gitea at `git.wiggleverse.org`). The app is the primary interface for the Open Human Model (OHM) RFC working group.
>
> **Stack:**
> - Backend: Python 3.11, FastAPI, uvicorn (single process), SQLite WAL mode
+18 -12
View File
@@ -1,6 +1,6 @@
# Runbook
Single-host deployment of the RFC app at `rfc.wiggleverse.org`, sharing
Single-host deployment of the RFC app at `ohm.wiggleverse.org`, sharing
infrastructure with `git.wiggleverse.org` (same Gitea instance, same nginx,
same Let's Encrypt). The shape matches §4.2: one process, one SQLite file,
no separate worker.
@@ -18,7 +18,7 @@ recover from a partial install is safe.
- Ubuntu/Debian-style host with nginx and certbot already serving
`git.wiggleverse.org` over HTTPS.
- DNS: an `A` record for `rfc.wiggleverse.org` pointing at the same IP as
- DNS: an `A` record for `ohm.wiggleverse.org` pointing at the same IP as
`git.wiggleverse.org`.
- Python 3.11+ available system-wide (the project has no `requires-python`
pin; the current production VM runs 3.11 on Debian bookworm). Node 20+
@@ -75,7 +75,7 @@ Invite → rfc-bot → Owner**.
Integrations → OAuth2 Applications → Create Application**:
- Name: `RFC App`
- Redirect URI: `https://rfc.wiggleverse.org/auth/callback`
- Redirect URI: `https://ohm.wiggleverse.org/auth/callback`
Copy the client ID and client secret. They go into `.env`.
@@ -93,7 +93,7 @@ sudo -u rfc-app /opt/rfc-app/backend/.venv/bin/pip install \
```sh
# On your laptop:
cd frontend && npm install && npm run build
cd frontend && npm ci && npm run build
rsync -a dist/ ben.stull@<host>:/tmp/rfc-app-dist/
# On the host:
sudo -u rfc-app mkdir -p /opt/rfc-app/frontend/dist
@@ -104,10 +104,16 @@ sudo chown -R rfc-app:rfc-app /opt/rfc-app/frontend/dist
Or build on the host directly if Node is installed there:
```sh
cd /opt/rfc-app/frontend && sudo -u rfc-app npm install
cd /opt/rfc-app/frontend && sudo -u rfc-app npm ci
sudo -u rfc-app npm run build
```
`npm ci` installs strictly from the committed `package-lock.json` and
refuses to mutate it. `npm install` was previously used here but can
regenerate the lockfile in place (e.g. stripping `libc` fields from
optional rollup native packages), which then collides with `git
checkout <tag>` on the next deploy.
**1.3.3 Write `.env`.**
```sh
@@ -128,7 +134,7 @@ META_REPO=meta
OAUTH_CLIENT_ID=<from 1.2.3>
OAUTH_CLIENT_SECRET=<from 1.2.3>
APP_URL=https://rfc.wiggleverse.org
APP_URL=https://ohm.wiggleverse.org
SECRET_KEY=<openssl rand -hex 32>
OWNER_GITEA_LOGIN=ben.stull
GITEA_WEBHOOK_SECRET=<openssl rand -hex 32>
@@ -182,9 +188,9 @@ Re-running is safe; every step is upsert-shaped.
**1.4.1 nginx vhost.**
```sh
sudo cp /opt/rfc-app/deploy/nginx/rfc.wiggleverse.org.conf \
/etc/nginx/sites-available/rfc.wiggleverse.org
sudo ln -s /etc/nginx/sites-available/rfc.wiggleverse.org \
sudo cp /opt/rfc-app/deploy/nginx/ohm.wiggleverse.org.conf \
/etc/nginx/sites-available/ohm.wiggleverse.org
sudo ln -s /etc/nginx/sites-available/ohm.wiggleverse.org \
/etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
@@ -200,7 +206,7 @@ sudo systemctl reload nginx
**1.4.2 Let's Encrypt cert.**
```sh
sudo certbot --nginx -d rfc.wiggleverse.org
sudo certbot --nginx -d ohm.wiggleverse.org
```
### 1.5 systemd
@@ -226,7 +232,7 @@ RFC app started — meta repo wiggleverse/meta
### 1.6 Smoke test
In a browser at `https://rfc.wiggleverse.org`:
In a browser at `https://ohm.wiggleverse.org`:
1. The landing page renders (§14.1 — title, pitch, three-item deck,
sign-in affordance).
@@ -384,7 +390,7 @@ say), restore from the most recent backup per §2.2.
`rfc-app`.
- **OAuth callback returns "Invalid state".** The redirect URI in Gitea
must match `APP_URL/auth/callback` exactly. Confirm it's
`https://rfc.wiggleverse.org/auth/callback`.
`https://ohm.wiggleverse.org/auth/callback`.
- **The catalog stays empty after a merge.** Check the webhook:
`journalctl -u rfc-app | grep webhook`. Gitea's **Settings → Webhooks
→ Recent Deliveries** on the meta repo shows the delivery status; the
@@ -2,21 +2,21 @@
# frontend served as static files from the Vite build output.
#
# Install:
# sudo cp deploy/nginx/rfc.wiggleverse.org.conf \
# /etc/nginx/sites-available/rfc.wiggleverse.org
# sudo ln -s /etc/nginx/sites-available/rfc.wiggleverse.org \
# sudo cp deploy/nginx/ohm.wiggleverse.org.conf \
# /etc/nginx/sites-available/ohm.wiggleverse.org
# sudo ln -s /etc/nginx/sites-available/ohm.wiggleverse.org \
# /etc/nginx/sites-enabled/
# sudo nginx -t && sudo systemctl reload nginx
#
# Then add the Let's Encrypt cert:
# sudo certbot --nginx -d rfc.wiggleverse.org
# sudo certbot --nginx -d ohm.wiggleverse.org
# Certbot will rewrite this file to add the 443 listener and certificate
# directives; the rest of the config below stays as written.
server {
listen 80;
listen [::]:80;
server_name rfc.wiggleverse.org;
server_name ohm.wiggleverse.org;
# Static SPA assets live in the Vite build output. The systemd unit
# runs as user `rfc-app`; make sure nginx (usually `www-data`) can
+31 -2
View File
@@ -69,7 +69,7 @@ The shortest path from scratch:
any deployment-identifying value; if a required variable is
missing, the build fails loudly.
6. **Build and run.** `cd frontend && npm install && npm run
6. **Build and run.** `cd frontend && npm ci && npm run
build`, then start the backend per `deploy/RUNBOOK.md`. The
first sign-in is the OWNER login from `backend/.env`.
@@ -166,7 +166,7 @@ The mechanics in practice:
5. **Check out the framework at the target version.** `git
fetch && git checkout <tag>`.
6. **Rebuild.** `npm install && npm run build` for the frontend;
6. **Rebuild.** `npm ci && npm run build` for the frontend;
restart the backend.
7. **Smoke-test.** Sign in, check the brand reflects your
@@ -240,6 +240,35 @@ The deployment repo does **not** hold:
edit a framework file, the right move is to file a change against
the framework, get a release, and pin to it.
## Private-beta gate
From `0.3.0` onward, every deployment ships with an opt-in email
allowlist. The default state is **off**: an empty `allowed_emails`
table behaves exactly like 0.2.x — any successful Gitea OAuth
provisions a user.
To run a closed beta:
1. Sign in once as the deployment operator so your `users` row exists
(you will be grandfathered by `gitea_id` thereafter — adding the
first allowlist row does **not** lock you out).
2. Open `/admin/allowlist` and add the first invited email. As soon as
any row exists, sign-in is restricted to listed emails plus
grandfathered users.
3. Optionally set `VITE_BETA_CONTACT` in `frontend/.env` (an email
address, a URL, or a short instruction). It is shown to rejected
sign-ins on the `/beta-pending` page so visitors know how to
request an invitation.
To re-open the deployment, remove all rows from `allowed_emails` (the
admin tab has a Remove button per row) — the gate flips off as soon
as the last row is gone.
Anonymous viewers see the full app in read-only mode regardless of
allowlist state. Write affordances (Propose, chat, Contribute, Open
PR) are hidden behind a sign-in CTA, and the public read endpoints
behave the same in both states.
## When something goes wrong
If a framework behavior is wrong for your deployment, file it as a
+36
View File
@@ -13,3 +13,39 @@
# VITE_APP_NAME=Wiggleverse RFC
# VITE_APP_NAME=Wiggleverse Open Human Model
VITE_APP_NAME=
# Optional contact line shown on the /beta-pending page when a deployment
# is in private-beta mode (i.e. the backend's `allowed_emails` table has
# rows). Free-text — an email address, a URL, or a one-line instruction
# tells visitors how to request an invitation. If unset, the page falls
# back to a generic "contact the deployment operator" line.
#
# Examples:
# VITE_BETA_CONTACT=ben@wiggleverse.org
# VITE_BETA_CONTACT=DM @ben on Matrix
VITE_BETA_CONTACT=
# Optional URL to the deployment's privacy policy (v0.13.0+, SPEC §14.5).
# The framework ships a minimal default privacy policy at `/privacy`
# that describes the framework's stance and lists the cookies the
# framework sets. When this var is set to an http(s) URL, the page
# renders the framework's stub above a link to the configured URL —
# deployments use this to layer their own policy content on top
# without forking the framework. Unset is OK; the stub is sufficient
# for a deployment that has nothing specific to add.
#
# Examples:
# VITE_PRIVACY_POLICY_URL=https://wiggleverse.org/privacy
VITE_PRIVACY_POLICY_URL=
# Optional URL to the deployment's cookies policy (v0.13.0+, SPEC §14.6).
# Same shape as VITE_PRIVACY_POLICY_URL. The framework's default
# `/cookies` page lists exactly which cookies the framework sets
# (rfc_session, the consent-choice localStorage entry); a deployment
# that adds its own cookies (analytics SDK once #13 lands, third-party
# embeds) points this var at a page that documents the full list.
# Unset is OK; the stub is sufficient for a default-config deployment.
#
# Examples:
# VITE_COOKIES_POLICY_URL=https://wiggleverse.org/cookies
VITE_COOKIES_POLICY_URL=
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rfc-app-frontend",
"version": "0.2.1",
"version": "0.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
"version": "0.2.1",
"version": "0.13.0",
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.2.1",
"version": "0.13.0",
"type": "module",
"scripts": {
"dev": "vite",
+278 -1
View File
@@ -5,7 +5,16 @@
height: 100vh; color: #888; font-size: 14px;
}
.app { height: 100vh; display: flex; flex-direction: column; }
/* `100dvh` is the dynamic viewport height adjusts as iOS Safari's
URL bar shows/hides. Without it, `100vh` measures the URL-bar-hidden
("largest") viewport, so the app overflows what's actually visible.
Combined with `body { overflow: hidden }` in index.css, the result
on iOS is that single-finger touches get consumed by the (blocked)
page-level scroll attempt and never reach the nested .chrome-pane;
two-finger touches bypass that and one-finger works thereafter.
The 100vh line stays as a fallback for browsers older than iOS
15.4 / Chrome 108 (early 2022) that don't understand dvh. */
.app { height: 100vh; height: 100dvh; display: flex; flex-direction: column; }
.app-header {
height: 48px; flex-shrink: 0;
@@ -33,6 +42,31 @@
}
.btn-link:hover { background: rgba(255,255,255,0.25); }
.btn-signin-header {
color: #fff; text-decoration: none;
background: rgba(255,255,255,0.15);
border-radius: 6px; padding: 4px 10px;
font-size: 13px;
display: inline-flex; align-items: center; gap: 6px;
}
.btn-signin-header:hover { background: rgba(255,255,255,0.25); }
/* Beta chip small uppercase tag sitting alongside a button label or
* link. Renders well on both dark headers and light surfaces. */
.beta-chip {
font-size: 9px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em;
padding: 1px 5px; border-radius: 3px;
background: #b45309; color: #fff;
line-height: 1.5;
vertical-align: middle;
}
.btn-link .beta-chip,
.btn-mode-toggle .beta-chip,
.btn-start-contribution-header .beta-chip {
margin-left: 5px;
}
.app-body { flex: 1; display: flex; overflow: hidden; }
/* --- Catalog (left pane, §7) --- */
@@ -318,6 +352,37 @@
}
.landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; }
/* --- Beta-pending page (post-OAuth-rejection) --- */
.beta-pending {
min-height: 100vh;
display: flex; align-items: center; justify-content: center;
padding: 40px 20px;
}
.beta-pending-inner {
max-width: 560px;
text-align: center;
}
.beta-pending h1 { font-size: 24px; margin: 0 0 16px; }
.beta-pending p { font-size: 15px; line-height: 1.6; color: #333; margin: 0 0 14px; }
.beta-pending-contact {
background: #fafafa; border: 1px solid #eee; border-radius: 8px;
padding: 14px 18px;
color: #444;
}
.beta-pending-actions {
margin-top: 24px;
display: flex; gap: 18px; justify-content: center; align-items: center;
}
.beta-pending-actions .btn-primary {
background: #1a1a1a; color: #fff;
border-radius: 8px; padding: 9px 18px;
font-size: 14px; font-weight: 600; text-decoration: none;
}
.beta-pending-actions .btn-primary:hover { background: #333; }
.btn-link-quiet { color: #666; text-decoration: none; font-size: 13px; }
.btn-link-quiet:hover { color: #1a1a1a; text-decoration: underline; }
/* ── §8 RFC view: three-column shape ─────────────────────────────────── */
.main-pane {
@@ -1678,6 +1743,27 @@
padding: 1px 5px; border-radius: 3px;
}
.allowlist-add {
display: flex; gap: 8px; margin-bottom: 22px; flex-wrap: wrap;
align-items: center;
}
.allowlist-add input[type="email"] {
border: 1px solid #d1d5db; border-radius: 6px;
padding: 6px 10px; font-size: 13px; min-width: 240px;
}
.allowlist-add input[type="text"] {
border: 1px solid #d1d5db; border-radius: 6px;
padding: 6px 10px; font-size: 13px; flex: 1; min-width: 200px;
}
.allowlist-add .btn-primary {
background: #1a1a1a; color: #fff;
border: none; border-radius: 6px;
padding: 6px 14px; font-size: 13px; font-weight: 600;
cursor: pointer;
}
.allowlist-add .btn-primary:hover:not(:disabled) { background: #333; }
.allowlist-add .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.user-cell { display: flex; flex-direction: column; gap: 1px; }
.user-handle { font-weight: 500; color: #111; }
.mute-toggle {
@@ -1690,3 +1776,194 @@
.grad-queue-link:hover strong { text-decoration: underline; }
.muted { color: #6b7280; }
.error { color: #b91c1c; }
/* v0.5.0 PR-less per-RFC discussion panel (RFCDiscussionPanel.jsx).
Visual neighbor of .chat-panel but distinct: discussion lives on the
RFC, branch chat lives on the branch. Same flex column shape so it
slots cleanly into the existing .right-panel container.
*/
.discussion-panel {
flex: 1; display: flex; flex-direction: column;
overflow: hidden; min-height: 0;
}
.discussion-header {
padding: 10px 14px;
border-bottom: 1px solid #f0f0ee;
background: #fafafa;
display: flex; flex-direction: column; gap: 4px;
}
.discussion-header-title { font-size: 12px; color: #555; font-weight: 600; }
.discussion-header-meta { font-size: 11px; color: #888; }
.discussion-thread-tabs {
display: flex; gap: 4px; flex-wrap: wrap;
padding: 6px 14px;
border-bottom: 1px solid #f0f0ee;
background: #fcfcfb;
}
.discussion-thread-tab {
background: #fff; border: 1px solid #e5e5e0; cursor: pointer;
font-size: 11px; color: #555;
padding: 3px 8px; border-radius: 999px;
}
.discussion-thread-tab.active {
background: #eef2ff; border-color: #5b5bd6; color: #3737a0;
}
.discussion-thread-tab.resolved { opacity: 0.6; }
.discussion-messages {
flex: 1; overflow-y: auto;
padding: 14px;
display: flex; flex-direction: column; gap: 10px;
}
.discussion-empty {
flex: 1; display: flex; align-items: center; justify-content: center;
text-align: center; padding: 24px;
}
.discussion-empty p {
font-size: 13px; color: #999; line-height: 1.6; max-width: 280px;
}
.discussion-error {
background: #fee; border: 1px solid #fcc; color: #b91c1c;
padding: 8px 10px; border-radius: 4px; font-size: 12px;
}
.discussion-message { display: flex; flex-direction: column; gap: 3px; }
.discussion-message-meta {
display: flex; gap: 8px; font-size: 11px; color: #888;
}
.discussion-message-author { color: #5b5bd6; font-weight: 500; }
.discussion-message-quote {
font-size: 11px; color: #666; font-style: italic;
border-left: 2px solid #ddd; padding-left: 8px; margin-bottom: 2px;
}
.discussion-message-body {
font-size: 13px; color: #222; line-height: 1.5;
white-space: pre-wrap; word-wrap: break-word;
background: #f7f7f5; padding: 8px 10px; border-radius: 6px;
}
.discussion-message.system .discussion-system-bubble {
font-size: 12px; color: #888; font-style: italic;
text-align: center; padding: 4px 0;
}
.discussion-composer {
border-top: 1px solid #f0f0ee;
padding: 10px 14px;
background: #fafafa;
display: flex; flex-direction: column; gap: 6px;
}
.discussion-composer-textarea {
width: 100%; resize: vertical; min-height: 60px;
font-family: inherit; font-size: 13px;
border: 1px solid #ddd; border-radius: 4px;
padding: 6px 8px;
}
.discussion-composer-textarea:focus {
outline: none; border-color: #5b5bd6;
}
.discussion-composer-actions {
display: flex; gap: 8px; align-items: center; justify-content: flex-end;
}
.discussion-readonly {
font-size: 12px; color: #666; padding: 4px 0;
}
/* §14.5 — cookie consent banner (v0.13.0) */
.cookie-consent-banner {
position: fixed;
left: 0; right: 0; bottom: 0;
z-index: 1000;
background: #fff;
border-top: 1px solid #d1d5db;
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08);
padding: 20px 24px;
}
.cookie-consent-body {
max-width: 880px; margin: 0 auto;
display: flex; flex-direction: column; gap: 12px;
}
.cookie-consent-title {
margin: 0; font-size: 16px; font-weight: 700; color: #111;
}
.cookie-consent-intro {
margin: 0; font-size: 13px; color: #4b5563; line-height: 1.5;
}
.cookie-consent-choices {
border: none; padding: 0; margin: 0;
display: flex; flex-direction: column; gap: 6px;
}
.cookie-consent-choice {
display: flex; gap: 10px; align-items: flex-start;
padding: 10px 12px; border-radius: 6px;
border: 1px solid #e5e7eb;
cursor: pointer;
}
.cookie-consent-choice.is-selected {
border-color: #111; background: #f9fafb;
}
.cookie-consent-choice input[type=radio] { margin-top: 3px; }
.cookie-consent-choice-text {
display: flex; flex-direction: column; gap: 2px;
}
.cookie-consent-choice-label {
font-size: 13px; font-weight: 600; color: #111;
}
.cookie-consent-choice-desc {
font-size: 12px; color: #6b7280; line-height: 1.5;
}
.cookie-consent-links {
margin: 0; font-size: 12px; color: #6b7280;
}
.cookie-consent-links a { color: #111; text-decoration: underline; }
.cookie-consent-error {
margin: 0; font-size: 12px; color: #b91c1c;
}
.cookie-consent-actions {
display: flex; gap: 8px; justify-content: flex-end;
}
.visually-hidden {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
/* §14.5 / §14.6 — privacy + cookies policy pages */
.policy-page {
max-width: 720px; margin: 0 auto;
padding: 0 32px 80px;
}
.policy-header {
display: flex; align-items: center; gap: 12px;
padding: 20px 0; border-bottom: 1px solid #f3f4f6;
margin-bottom: 24px;
}
.policy-back {
background: none; border: none; cursor: pointer;
color: #6b7280; font-size: 13px; padding: 4px 8px;
}
.policy-back:hover { color: #111; }
.policy-title { font-size: 13px; font-weight: 600; color: #6b7280; }
.policy-body { line-height: 1.7; color: #111; }
.policy-body h1 { font-size: 26px; margin: 0 0 6px; font-weight: 700; }
.policy-body .policy-subtitle { color: #6b7280; margin: 0 0 24px; font-size: 14px; }
.policy-body h2 { font-size: 16px; margin: 28px 0 8px; font-weight: 600; }
.policy-body p { margin: 0 0 12px; }
.policy-body ul { margin: 0 0 16px; padding-left: 22px; }
.policy-body li { margin-bottom: 6px; }
.policy-body code {
background: #f3f4f6; padding: 1px 5px; border-radius: 3px;
font-family: ui-monospace, monospace; font-size: 12px;
}
.policy-body .policy-footnote {
margin-top: 24px; font-size: 12px; color: #6b7280;
}
.policy-table {
width: 100%; border-collapse: collapse;
font-size: 13px; margin: 8px 0 16px;
}
.policy-table th, .policy-table td {
text-align: left; padding: 8px 10px;
border-bottom: 1px solid #f3f4f6;
vertical-align: top;
}
.policy-table th {
font-size: 11px; text-transform: uppercase;
color: #6b7280; letter-spacing: 0.05em; font-weight: 600;
}
+108 -42
View File
@@ -8,10 +8,14 @@ import PRView from './components/PRView.jsx'
import ProposalView from './components/ProposalView.jsx'
import ProposeModal from './components/ProposeModal.jsx'
import Landing from './components/Landing.jsx'
import BetaPending from './components/BetaPending.jsx'
import Philosophy from './components/Philosophy.jsx'
import NotificationSettings from './components/NotificationSettings.jsx'
import Admin from './components/Admin.jsx'
import ToastHost, { showToast } from './components/ToastHost.jsx'
import CookieConsentBanner from './components/CookieConsentBanner.jsx'
import Privacy from './pages/Privacy.jsx'
import Cookies from './pages/Cookies.jsx'
import './App.css'
export default function App() {
@@ -22,8 +26,19 @@ export default function App() {
const [inboxOpen, setInboxOpen] = useState(false)
const [unreadCount, setUnreadCount] = useState(0)
const [inboxTick, setInboxTick] = useState(0)
// §14.5: a tick that, when bumped, asks <CookieConsentBanner> to
// re-open even if the user has already made a choice. The settings
// "Privacy & cookies" tab dispatches a `rfc-app:cookie-consent-reopen`
// event that bumps this.
const [consentReopenTick, setConsentReopenTick] = useState(0)
const navigate = useNavigate()
useEffect(() => {
const handler = () => setConsentReopenTick(t => t + 1)
window.addEventListener('rfc-app:cookie-consent-reopen', handler)
return () => window.removeEventListener('rfc-app:cookie-consent-reopen', handler)
}, [])
useEffect(() => {
getMe()
.then(setMe)
@@ -68,17 +83,14 @@ export default function App() {
return <div className="boot">Loading</div>
}
// §14.2: the philosophy route is reachable by anonymous visitors too.
// Resolve it before the authentication gate so a signed-out reader
// who follows the §14.1 landing link does not get bounced to sign-in.
if (!me?.authenticated) {
return (
<Routes>
<Route path="/philosophy" element={<Philosophy authenticated={false} />} />
<Route path="*" element={<Landing />} />
</Routes>
)
}
// The deployment is in private beta: anonymous visitors get the full
// app in read-only mode (viewer = null is passed through to every
// component), and write affordances are hidden at the component
// level. /beta-pending is the post-OAuth-rejection page reachable by
// anyone. The original §14.1 Landing surface is retained for the
// `/welcome` URL only, in case a deployment wants to link to it.
const viewer = me?.authenticated ? me.user : null
const isAdmin = viewer && (viewer.role === 'owner' || viewer.role === 'admin')
return (
<div className="app">
@@ -88,60 +100,84 @@ export default function App() {
</div>
<div className="header-right">
{/* §14.3: the persistent About link. One word, no badge, no
state visible from every authenticated screen so a
contributor mid-PR who wonders why a conversation is
public can reach the answer in two clicks. */}
state visible from every screen so a viewer mid-PR who
wonders why a conversation is public can reach the answer
in two clicks. Anonymous viewers see it too. */}
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
About
</Link>
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
Settings
</Link>
{(me.user.role === 'owner' || me.user.role === 'admin') && (
{viewer && (
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
Settings
</Link>
)}
{isAdmin && (
<Link to="/admin" className="header-admin" title="Admin home base">
Admin
</Link>
)}
<button
className="inbox-trigger"
onClick={() => setInboxOpen(o => !o)}
title="Notifications inbox (§15.2)"
>
<span aria-hidden>📮</span>
{unreadCount > 0 && (
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
)}
</button>
<span className="user-name">{me.user.display_name}</span>
<span className={`user-role-badge role-${me.user.role}`}>{me.user.role}</span>
<a className="btn-link" href="/auth/logout">Sign out</a>
{viewer && (
<button
className="inbox-trigger"
onClick={() => setInboxOpen(o => !o)}
title="Notifications inbox (§15.2)"
>
<span aria-hidden>📮</span>
{unreadCount > 0 && (
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
)}
</button>
)}
{viewer ? (
<>
<span className="user-name">{viewer.display_name}</span>
<span className={`user-role-badge role-${viewer.role}`}>{viewer.role}</span>
<a className="btn-link" href="/auth/logout">Sign out</a>
</>
) : (
<a className="btn-signin-header" href="/auth/login" title="Private beta — only invited emails can sign in">
Sign in <span className="beta-chip">Beta</span>
</a>
)}
</div>
</header>
<div className="app-body">
<Routes>
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={me.user} />} />
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={me.user} />} />
<Route path="/admin/*" element={<AdminWithSidebar viewer={me.user} />} />
<Route path="/welcome" element={<Landing />} />
<Route path="/beta-pending" element={<BetaPending />} />
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
Available to anonymous and authenticated viewers alike. */}
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
<Route path="/cookies" element={<PolicyShell><Cookies /></PolicyShell>} />
{viewer && (
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={viewer} />} />
)}
{isAdmin && (
<Route path="/admin/*" element={<AdminWithSidebar viewer={viewer} />} />
)}
<Route path="*" element={
<>
<Catalog
viewer={viewer}
onProposeRFC={() => setProposeOpen(true)}
version={catalogVersion}
/>
<main className="main-pane">
<Routes>
<Route path="/" element={<Welcome viewer={me.user} />} />
<Route path="/rfc/:slug" element={<RFCView viewer={me.user} />} />
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={me.user} />} />
<Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} />
<Route path="/" element={<Welcome viewer={viewer} />} />
<Route path="/rfc/:slug" element={<RFCView viewer={viewer} />} />
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} />
<Route path="/proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} />
</Routes>
</main>
</>
} />
</Routes>
</div>
{proposeOpen && (
{proposeOpen && viewer && (
<ProposeModal
viewer={viewer}
onClose={() => setProposeOpen(false)}
onSubmitted={({ pr_number }) => {
setProposeOpen(false)
@@ -150,22 +186,30 @@ export default function App() {
}}
/>
)}
{inboxOpen && (
{inboxOpen && viewer && (
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
)}
<ToastHost />
<CookieConsentBanner viewer={viewer} forceOpen={consentReopenTick} />
</div>
)
}
function PhilosophyWithSidebar() {
function PolicyShell({ children }) {
// §14.5 / §14.6 policy pages reuse the chrome-pane shape so they
// render full-width without the catalog rail. The components inside
// carry their own back affordance per Philosophy.jsx's pattern.
return <main className="chrome-pane">{children}</main>
}
function PhilosophyWithSidebar({ viewer }) {
// The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
// all use the full app body no catalog left pane, no propose modal.
// The header carries the navigation back; the body is a single
// reading surface.
return (
<main className="chrome-pane">
<Philosophy authenticated={true} />
<Philosophy authenticated={!!viewer} />
</main>
)
}
@@ -187,6 +231,28 @@ function AdminWithSidebar({ viewer }) {
}
function Welcome({ viewer }) {
if (!viewer) {
return (
<div className="welcome">
<h1>Welcome.</h1>
<p>
The catalog on the left lists every super-draft and active RFC in the
framework. Open one to read the canonical body and the public
conversation behind each definition.
</p>
<p>
Discussion and contribution are in private <strong>Beta</strong>
read freely, and <a href="/auth/login">sign in</a> if your email has
been invited.
</p>
<p>
Wondering why a conversation is public, why graduation costs what it
does, or why the model is in the chat? <Link to="/philosophy">Read the
philosophy</Link>.
</p>
</div>
)
}
return (
<div className="welcome">
<h1>Welcome, {viewer.display_name}.</h1>
+81
View File
@@ -197,6 +197,52 @@ export async function resolveThread(slug, branch, threadId) {
return jsonOrThrow(res)
}
// ── v0.5.0: PR-less per-RFC discussion (§5 / §10) ────────────────────────
//
// The substrate is `threads.branch_name IS NULL` — the same threads
// table the branch chat uses, with a null branch the schema already
// supported. Contribution still requires a PR (api_prs / openPR), so
// these endpoints are read+write for discussion only.
export async function listDiscussionThreads(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/discussion/threads`))
}
export async function createDiscussionThread(slug, { label = null, message = null } = {}) {
const res = await fetch(`/api/rfcs/${slug}/discussion/threads`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label, message }),
})
return jsonOrThrow(res)
}
export async function getDiscussionThreadMessages(slug, threadId) {
return jsonOrThrow(await fetch(
`/api/rfcs/${slug}/discussion/threads/${threadId}/messages`,
))
}
export async function postDiscussionMessage(slug, threadId, { text, quote = null }) {
const res = await fetch(
`/api/rfcs/${slug}/discussion/threads/${threadId}/messages`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, quote }),
},
)
return jsonOrThrow(res)
}
export async function resolveDiscussionThread(slug, threadId) {
const res = await fetch(
`/api/rfcs/${slug}/discussion/threads/${threadId}/resolve`,
{ method: 'POST' },
)
return jsonOrThrow(res)
}
// ── Slice 4: super-draft body editing (§9.5) ─────────────────────────────
export async function startEditBranch(slug, body = {}) {
@@ -462,6 +508,23 @@ export async function setQuietHours({ start, end, timezone } = {}) {
}))
}
// v0.13.0 / roadmap item #11: cookie consent (SPEC §14.5).
export async function getCookieConsent() {
return jsonOrThrow(await fetch('/api/users/me/cookie-consent'))
}
export async function setCookieConsent({ analytics, other } = {}) {
return jsonOrThrow(await fetch('/api/users/me/cookie-consent', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
essential: true,
analytics: !!analytics,
other: !!other,
}),
}))
}
export async function muteUser(userId) {
return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'POST' }))
}
@@ -534,6 +597,24 @@ export async function listGraduationQueue() {
return jsonOrThrow(await fetch('/api/admin/graduation-queue'))
}
export async function listAllowlist() {
return jsonOrThrow(await fetch('/api/admin/allowlist'))
}
export async function addAllowlistEmail(email, note) {
return jsonOrThrow(await fetch('/api/admin/allowlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, note: note || null }),
}))
}
export async function removeAllowlistEmail(email) {
return jsonOrThrow(await fetch(`/api/admin/allowlist/${encodeURIComponent(email)}`, {
method: 'DELETE',
}))
}
export async function searchUsers(q) {
const params = new URLSearchParams()
if (q) params.set('q', q)
+139
View File
@@ -19,10 +19,14 @@ import {
listAuditLog,
listPermissionEvents,
listGraduationQueue,
listAllowlist,
addAllowlistEmail,
removeAllowlistEmail,
} from '../api.js'
const TABS = [
{ path: 'users', label: 'Users' },
{ path: 'allowlist', label: 'Allowlist' },
{ path: 'graduation', label: 'Graduation queue' },
{ path: 'audit', label: 'Audit log' },
{ path: 'permissions', label: 'Permission events' },
@@ -54,6 +58,7 @@ export default function Admin({ viewer }) {
<Routes>
<Route index element={<UsersTab />} />
<Route path="users" element={<UsersTab />} />
<Route path="allowlist" element={<AllowlistTab />} />
<Route path="graduation" element={<GraduationTab />} />
<Route path="audit" element={<AuditTab />} />
<Route path="permissions" element={<PermissionsTab />} />
@@ -174,6 +179,140 @@ function UsersTab() {
)
}
// Private-beta allowlist (`migrations/011_allowlist.sql`)
function AllowlistTab() {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [draftEmail, setDraftEmail] = useState('')
const [draftNote, setDraftNote] = useState('')
const [busy, setBusy] = useState(false)
async function refresh() {
setError(null)
try {
setData(await listAllowlist())
} catch (e) {
setError(e.message)
}
}
useEffect(() => { refresh() }, [])
async function handleAdd(event) {
event.preventDefault()
const email = draftEmail.trim()
if (!email) return
setBusy(true); setError(null)
try {
await addAllowlistEmail(email, draftNote.trim() || null)
setDraftEmail(''); setDraftNote('')
await refresh()
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
async function handleRemove(email) {
if (!confirm(`Remove ${email} from the allowlist?`)) return
setBusy(true); setError(null)
try {
await removeAllowlistEmail(email)
await refresh()
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
if (data == null && !error) return <p className="muted">Loading allowlist</p>
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Allowlist</h2>
<p className="muted">
When this list has any rows, OAuth sign-in is restricted: only emails
here (case-insensitive) may sign in. Already-provisioned users are
grandfathered by their Gitea ID and never re-checked. An empty list
turns the gate off entirely.
</p>
<p className="muted">
Status:{' '}
<strong>{data?.active ? 'Private beta — gate active' : 'Open — anyone can sign in'}</strong>
</p>
</header>
{error && <p className="settings-note warning">{error}</p>}
<form className="allowlist-add" onSubmit={handleAdd}>
<input
type="email"
placeholder="email@example.com"
value={draftEmail}
onChange={e => setDraftEmail(e.target.value)}
required
disabled={busy}
/>
<input
type="text"
placeholder="Note (optional)"
value={draftNote}
onChange={e => setDraftNote(e.target.value)}
maxLength={200}
disabled={busy}
/>
<button type="submit" className="btn-primary" disabled={busy || !draftEmail.trim()}>
Add to allowlist
</button>
</form>
{data?.items?.length > 0 ? (
<table className="admin-table">
<thead>
<tr>
<th>Email</th>
<th>Note</th>
<th>Added by</th>
<th>Added at</th>
<th></th>
</tr>
</thead>
<tbody>
{data.items.map(r => (
<tr key={r.email}>
<td><code>{r.email}</code></td>
<td>{r.note || <span className="muted"></span>}</td>
<td>
{r.added_by_login
? <span>@{r.added_by_login}</span>
: <span className="muted"></span>}
</td>
<td className="muted">{r.created_at}</td>
<td>
<button
type="button"
className="btn-link-quiet"
onClick={() => handleRemove(r.email)}
disabled={busy}
>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="muted">
No allow-listed emails yet. Add the first one to put the deployment
into private-beta mode.
</p>
)}
</div>
)
}
// Graduation-readiness queue (§13.2)
function GraduationTab() {
+41
View File
@@ -0,0 +1,41 @@
// BetaPending.jsx the post-OAuth-rejection page.
//
// When a deployment is in private-beta mode (i.e. its `allowed_emails`
// table has any rows), the OAuth callback redirects unrecognised users
// here instead of provisioning them. The framework cannot know the
// deployment operator's preferred contact channel so the deployment
// supplies one via VITE_BETA_CONTACT (an email, URL, or short
// instruction). If unset, we render a generic ask-the-operator line.
import { Link } from 'react-router-dom'
export default function BetaPending() {
const contact = import.meta.env.VITE_BETA_CONTACT || ''
return (
<div className="beta-pending">
<div className="beta-pending-inner">
<h1>{import.meta.env.VITE_APP_NAME} is in private Beta.</h1>
<p>
Discussion and contribution are gated to invited emails for now.
Reading is open every super-draft, every active RFC, and every
public conversation is visible without signing in.
</p>
{contact ? (
<p className="beta-pending-contact">
To request access, contact <strong>{contact}</strong> with the
email address you'd like to sign in with.
</p>
) : (
<p className="beta-pending-contact">
To request access, contact the deployment operator with the email
address you'd like to sign in with.
</p>
)}
<div className="beta-pending-actions">
<Link className="btn-primary" to="/">Browse as a guest</Link>
<Link className="btn-link-quiet" to="/philosophy">Read the philosophy </Link>
</div>
</div>
</div>
)
}
+9 -3
View File
@@ -22,7 +22,7 @@ const SORT_OPTIONS = [
{ id: 'state', label: 'State' },
]
export default function Catalog({ onProposeRFC, version }) {
export default function Catalog({ viewer, onProposeRFC, version }) {
const [rfcs, setRfcs] = useState([])
const [proposals, setProposals] = useState([])
const [search, setSearch] = useState('')
@@ -93,7 +93,7 @@ export default function Catalog({ onProposeRFC, version }) {
{filtered.length === 0 ? (
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
{rfcs.length === 0
? 'No RFCs in the catalog yet. Propose one below.'
? (viewer ? 'No RFCs in the catalog yet. Propose one below.' : 'No RFCs in the catalog yet.')
: 'No matches.'}
</div>
) : (
@@ -148,7 +148,13 @@ export default function Catalog({ onProposeRFC, version }) {
</div>
<div className="catalog-footer">
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New RFC</button>
{viewer ? (
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New RFC</button>
) : (
<a className="btn-propose" href="/auth/login" title="Private beta — only invited emails can propose">
Sign in to propose <span className="beta-chip">Beta</span>
</a>
)}
</div>
</aside>
)
@@ -0,0 +1,164 @@
// CookieConsentBanner.jsx v0.13.0 / roadmap item #11 / SPEC §14.5.
//
// A non-modal bottom-of-page banner that asks the user once which
// categories of cookies they accept. The framework's strictly-necessary
// cookies (session, signed payloads) are always on; the user can opt in
// or out of analytics (which gates the §13 SDK landing in v0.15.0) and
// "other" (third-party embeds, social widgets if a deployment adds any).
//
// Visible until the user makes a choice. Hides itself once the choice
// is recorded. The /settings/notifications "Privacy & cookies" tab
// surfaces the current choice and re-opens the banner via `forceOpen`.
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { getConsent, setConsent, hasChosen, hydrateFromServer } from '../lib/consent.js'
import { getCookieConsent, setCookieConsent } from '../api.js'
const CATEGORIES = [
{
key: 'essential-only',
label: 'Essential only',
description: 'Just the cookies the app needs to keep you signed in and protect submissions. (Sign-in session, signed payloads.)',
flags: { analytics: false, other: false },
},
{
key: 'essential-analytics',
label: 'Essential + analytics',
description: 'Adds anonymous usage analytics so the framework can see which surfaces get used. No third-party scripts beyond the analytics SDK.',
flags: { analytics: true, other: false },
},
{
key: 'essential-analytics-other',
label: 'Essential + analytics + other',
description: 'Adds analytics plus any third-party embeds the deployment configures (e.g. social widgets). Choose this if you want the full surface.',
flags: { analytics: true, other: true },
},
]
function selectionKeyFor(consent) {
if (consent.analytics && consent.other) return 'essential-analytics-other'
if (consent.analytics && !consent.other) return 'essential-analytics'
return 'essential-only'
}
export default function CookieConsentBanner({ viewer, forceOpen, onClosed }) {
const [open, setOpen] = useState(() => forceOpen || !hasChosen())
const [choice, setChoice] = useState(() => selectionKeyFor(getConsent()))
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
// When forceOpen flips (settings "Change" affordance), re-render the
// banner and pre-select the user's current choice.
useEffect(() => {
if (forceOpen) {
setOpen(true)
setChoice(selectionKeyFor(getConsent()))
}
}, [forceOpen])
// Server-side hydrate for authenticated viewers per the v0.13.0
// precedence rule: a server row overrides local; absent server row,
// upload the local choice.
useEffect(() => {
if (!viewer?.user_id) return
let cancelled = false
getCookieConsent()
.then(record => {
if (cancelled) return
if (record.recorded_at) {
// Server is authoritative adopt + hide the banner unless
// the settings page forced it open.
hydrateFromServer(record)
setChoice(selectionKeyFor(record))
if (!forceOpen) setOpen(false)
} else if (hasChosen()) {
// Local has a choice the server doesn't know about yet push.
const local = getConsent()
setCookieConsent({ analytics: local.analytics, other: local.other })
.then(r => hydrateFromServer(r))
.catch(() => {})
}
})
.catch(() => {
// Network or auth error leave the local-only path in place.
})
return () => { cancelled = true }
}, [viewer?.user_id]) // eslint-disable-line react-hooks/exhaustive-deps
if (!open) return null
async function save() {
setSaving(true)
setError(null)
const picked = CATEGORIES.find(c => c.key === choice) || CATEGORIES[0]
try {
setConsent(picked.flags)
if (viewer?.user_id) {
// Best-effort server persistence. A failure here doesn't
// invalidate the local choice; the banner still hides because
// the user expressed their preference. The server can catch up
// on the next sign-in via the hydrate path above.
try {
const r = await setCookieConsent(picked.flags)
hydrateFromServer(r)
} catch (e) {
setError(`Saved locally; server sync failed (${e.message}).`)
}
}
setOpen(false)
onClosed?.()
} finally {
setSaving(false)
}
}
return (
<div className="cookie-consent-banner" role="region" aria-label="Cookie consent">
<div className="cookie-consent-body">
<h2 className="cookie-consent-title">Cookies &amp; privacy</h2>
<p className="cookie-consent-intro">
This site uses cookies. Essential cookies keep you signed in and
protect your submissions; analytics and other cookies are
optional. Choose what you allow you can change this any time
from <Link to="/settings/notifications">Settings &rarr; Privacy &amp; cookies</Link>.
</p>
<fieldset className="cookie-consent-choices">
<legend className="visually-hidden">Cookie categories</legend>
{CATEGORIES.map(c => (
<label key={c.key} className={`cookie-consent-choice ${choice === c.key ? 'is-selected' : ''}`}>
<input
type="radio"
name="cookie-consent-choice"
value={c.key}
checked={choice === c.key}
onChange={() => setChoice(c.key)}
disabled={saving}
/>
<span className="cookie-consent-choice-text">
<span className="cookie-consent-choice-label">{c.label}</span>
<span className="cookie-consent-choice-desc">{c.description}</span>
</span>
</label>
))}
</fieldset>
<p className="cookie-consent-links">
<Link to="/cookies">Cookies policy</Link>
<span aria-hidden> &middot; </span>
<Link to="/privacy">Privacy policy</Link>
</p>
{error && <p className="cookie-consent-error">{error}</p>}
<div className="cookie-consent-actions">
<button
type="button"
className="btn-primary"
onClick={save}
disabled={saving}
>
{saving ? 'Saving…' : 'Save choice'}
</button>
</div>
</div>
</div>
)
}
@@ -28,7 +28,9 @@ import {
unmuteUser,
muteUser,
searchUsers,
getCookieConsent,
} from '../api.js'
import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js'
const CHURN_REFUSAL = 'Per-commit and per-message email is intentionally not offered. The digest aggregates this activity weekly.'
@@ -48,10 +50,67 @@ export default function NotificationSettings({ viewer }) {
<QuietHoursSection />
<WatchesSection />
<MutesSection viewer={viewer} />
<PrivacyCookiesSection />
</div>
)
}
// §14.5 cookie / privacy consent (v0.13.0 / roadmap item #11)
function PrivacyCookiesSection() {
const [consent, setConsent] = useState(() => getConsent())
useEffect(() => {
// Pull the server-side row on mount; if it has a recorded_at the
// local snapshot is updated via hydrate.
getCookieConsent()
.then(record => { if (record.recorded_at) hydrateFromServer(record) })
.catch(() => {})
return onConsentChange(next => setConsent(next))
}, [])
function reopenBanner() {
// App.jsx listens for this event and bumps the forceOpen tick on
// <CookieConsentBanner>. The banner pre-selects the current choice
// from the snapshot, so the user can revise rather than restart.
window.dispatchEvent(new CustomEvent('rfc-app:cookie-consent-reopen'))
}
const summary = (() => {
if (!consent.recorded_at) {
return 'No choice recorded — the consent banner is being shown to you.'
}
if (consent.analytics && consent.other) {
return 'Essential + analytics + other.'
}
if (consent.analytics) {
return 'Essential + analytics.'
}
return 'Essential only.'
})()
return (
<SectionShell
title="Privacy & cookies"
subtitle="What categories of cookies you've allowed. Essential cookies are always on; analytics and other categories are opt-in."
>
<div className="settings-row">
<span className="settings-note"><strong>Current choice:</strong> {summary}</span>
</div>
{consent.recorded_at && (
<p className="settings-note muted">Recorded {consent.recorded_at}.</p>
)}
<div className="settings-row">
<button className="btn-primary" onClick={reopenBanner}>
Change
</button>
<Link to="/cookies" className="btn-link-muted">Cookies policy</Link>
<Link to="/privacy" className="btn-link-muted">Privacy policy</Link>
</div>
</SectionShell>
)
}
// §15.4 email category toggles
function EmailPreferencesSection() {
+2 -4
View File
@@ -15,7 +15,7 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { marked } from 'marked'
import MarkdownPreview from './MarkdownPreview.jsx'
import { getPhilosophy } from '../api.js'
export default function Philosophy({ authenticated }) {
@@ -33,8 +33,6 @@ export default function Philosophy({ authenticated }) {
return () => { active = false }
}, [])
const html = body ? marked.parse(body) : ''
return (
<div className="philosophy-page">
<header className="philosophy-header">
@@ -53,7 +51,7 @@ export default function Philosophy({ authenticated }) {
{loading && <p className="muted">Loading</p>}
{error && <p className="error">Could not load the philosophy: {error}</p>}
{!loading && !error && (
<div dangerouslySetInnerHTML={{ __html: html }} />
<MarkdownPreview content={body} />
)}
</article>
</div>
+7 -1
View File
@@ -20,7 +20,7 @@ function slugify(title) {
.replace(/^-+|-+$/g, '')
}
export default function ProposeModal({ onClose, onSubmitted }) {
export default function ProposeModal({ viewer, onClose, onSubmitted }) {
const [title, setTitle] = useState('')
const [slug, setSlug] = useState('')
const [slugEdited, setSlugEdited] = useState(false)
@@ -131,6 +131,12 @@ export default function ProposeModal({ onClose, onSubmitted }) {
</div>
)}
{viewer && (
<p className="field-help" style={{ marginTop: 14, marginBottom: 0 }}>
Owner: <strong>{viewer.display_name || viewer.gitea_login}</strong> you'll be the first owner of this super-draft. Additional owners can claim later (§13.1).
</p>
)}
{error && <p className="field-error">{error}</p>}
</div>
<div className="modal-actions">
@@ -0,0 +1,290 @@
// RFCDiscussionPanel.jsx v0.5.0's PR-less per-RFC discussion surface.
//
// Roadmap item #3: an RFC's main view now has a discussion surface
// distinct from PR comments and from branch chat. The substrate is the
// existing threads/thread_messages tables rows with
// `threads.branch_name IS NULL` scope to "the RFC, no branch yet."
//
// Reused as the right-column panel on `branchParam === 'main'`. Branch
// chat (ChatPanel.jsx) keeps its existing role for branch-scoped work,
// including PRs. Contribution remains gated behind opening a PR
// nothing here writes to the document.
import { useCallback, useEffect, useRef, useState } from 'react'
import {
createDiscussionThread,
getDiscussionThreadMessages,
listDiscussionThreads,
postDiscussionMessage,
resolveDiscussionThread,
} from '../api'
export default function RFCDiscussionPanel({ slug, viewer }) {
const [threads, setThreads] = useState([])
const [messagesByThread, setMessagesByThread] = useState({})
const [composer, setComposer] = useState('')
const [activeThreadId, setActiveThreadId] = useState(null)
const [error, setError] = useState(null)
const [sending, setSending] = useState(false)
const bottomRef = useRef(null)
// Pull threads + messages on mount / slug change.
useEffect(() => {
if (!slug) return
let cancelled = false
setError(null)
setThreads([])
setMessagesByThread({})
setActiveThreadId(null)
listDiscussionThreads(slug)
.then(async ({ items }) => {
if (cancelled) return
setThreads(items || [])
// Pre-load messages for each thread. The list is small (per-RFC,
// not per-branch) so a fan-out fetch is fine; §19.2 candidate
// for paging if a hot RFC accumulates lots of threads.
const collected = {}
for (const t of items || []) {
try {
const { messages } = await getDiscussionThreadMessages(slug, t.id)
collected[t.id] = messages
} catch {
collected[t.id] = []
}
}
if (!cancelled) {
setMessagesByThread(collected)
// Default the active thread to the system's lazy whole-doc
// default (the first row with anchor_kind='whole-doc' and
// no label) so the composer wires to a real id immediately.
const dflt = (items || []).find(
t => t.anchor_kind === 'whole-doc' && !t.label,
)
setActiveThreadId(dflt?.id || items?.[0]?.id || null)
}
})
.catch(err => { if (!cancelled) setError(err.message) })
return () => { cancelled = true }
}, [slug])
// Scroll to bottom when messages land in the active thread.
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [activeThreadId, messagesByThread[activeThreadId]?.length])
const handleSend = useCallback(async () => {
if (!viewer) { window.location.href = '/auth/login'; return }
const text = composer.trim()
if (!text || sending) return
setSending(true)
setError(null)
try {
// If no thread yet, mint one with the message as its first turn.
if (!activeThreadId) {
const { thread_id, message_id } = await createDiscussionThread(slug, { message: text })
// Re-pull authoritative state the default whole-doc thread
// existed pre-this call (the GET creates it lazily), so we
// either get the existing default's id back from the new
// thread's row or the prior default; either way the list call
// is the source of truth.
const { items } = await listDiscussionThreads(slug)
setThreads(items || [])
const { messages } = await getDiscussionThreadMessages(slug, thread_id)
setMessagesByThread(prev => ({ ...prev, [thread_id]: messages }))
setActiveThreadId(thread_id)
void message_id
} else {
const { message_id } = await postDiscussionMessage(slug, activeThreadId, { text })
const { messages } = await getDiscussionThreadMessages(slug, activeThreadId)
setMessagesByThread(prev => ({ ...prev, [activeThreadId]: messages }))
void message_id
}
setComposer('')
} catch (err) {
setError(err.message)
} finally {
setSending(false)
}
}, [composer, sending, viewer, slug, activeThreadId])
const handleNewThread = useCallback(async () => {
if (!viewer) { window.location.href = '/auth/login'; return }
setError(null)
try {
const { thread_id } = await createDiscussionThread(slug, { label: null, message: null })
const { items } = await listDiscussionThreads(slug)
setThreads(items || [])
setActiveThreadId(thread_id)
setMessagesByThread(prev => ({ ...prev, [thread_id]: [] }))
} catch (err) {
setError(err.message)
}
}, [viewer, slug])
const handleResolve = useCallback(async (threadId) => {
if (!viewer) return
setError(null)
try {
await resolveDiscussionThread(slug, threadId)
const { items } = await listDiscussionThreads(slug)
setThreads(items || [])
} catch (err) {
setError(err.message)
}
}, [viewer, slug])
const onKeyDown = useCallback((e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
handleSend()
}
}, [handleSend])
const activeThread = threads.find(t => t.id === activeThreadId) || null
const activeMessages = messagesByThread[activeThreadId] || []
const openThreads = threads.filter(t => t.state === 'open')
return (
<div className="discussion-panel">
<div className="discussion-header">
<span className="discussion-header-title">
Discussion <span className="beta-chip">Beta</span>
</span>
<span className="discussion-header-meta">
{openThreads.length} open thread{openThreads.length === 1 ? '' : 's'}
{' · '}contribution requires a PR
</span>
</div>
{threads.length > 1 && (
<div className="discussion-thread-tabs">
{threads.map(t => (
<button
key={t.id}
type="button"
className={`discussion-thread-tab ${t.id === activeThreadId ? 'active' : ''} ${t.state === 'resolved' ? 'resolved' : ''}`}
onClick={() => setActiveThreadId(t.id)}
title={t.label || (t.id === activeThreadId ? 'Current thread' : 'Open thread')}
>
{t.label || (t.anchor_kind === 'whole-doc' && !t.label ? 'General' : `Thread ${t.id}`)}
{t.state === 'resolved' && ' ✓'}
</button>
))}
</div>
)}
<div className="discussion-messages">
{error && <div className="discussion-error">{error}</div>}
{activeMessages.length === 0 && !error && (
<div className="discussion-empty">
<p>
{viewer
? 'No discussion yet. Be the first to comment — discussion lives here without opening a PR. To propose an edit, use Start Contributing above.'
: 'No discussion yet. Sign in to comment. Discussion lives here without opening a PR; proposed edits still flow through PRs.'}
</p>
</div>
)}
{activeMessages.map(msg => (
<DiscussionMessage key={msg.id} message={msg} />
))}
<div ref={bottomRef} />
</div>
<div className="discussion-composer">
{viewer ? (
<>
<textarea
className="discussion-composer-textarea"
value={composer}
onChange={e => setComposer(e.target.value)}
onKeyDown={onKeyDown}
placeholder={
activeThread?.label
? `Reply in "${activeThread.label}" — Cmd/Ctrl+Enter to send`
: 'Discuss this RFC — Cmd/Ctrl+Enter to send'
}
disabled={sending}
rows={3}
/>
<div className="discussion-composer-actions">
<button
type="button"
className="btn-secondary"
onClick={handleNewThread}
disabled={sending}
title="Open a fresh discussion thread on this RFC"
>
New thread
</button>
{activeThread
&& activeThread.state === 'open'
&& (activeThread.created_by === viewer.user_id
|| viewer.role === 'owner'
|| viewer.role === 'admin') && (
<button
type="button"
className="btn-link"
onClick={() => handleResolve(activeThread.id)}
disabled={sending}
title="Mark this discussion thread resolved"
>
Resolve
</button>
)}
<button
type="button"
className="btn-primary"
onClick={handleSend}
disabled={sending || !composer.trim()}
>
{sending ? 'Sending…' : 'Send'}
</button>
</div>
</>
) : (
<div className="discussion-readonly">
Read-only <a href="/auth/login">sign in</a> to join the discussion.
Discussion is in private <strong>Beta</strong>.
</div>
)}
</div>
</div>
)
}
function DiscussionMessage({ message }) {
const isSystem = message.role === 'system'
if (isSystem) {
return (
<div className="discussion-message system">
<div className="discussion-system-bubble">{message.text}</div>
</div>
)
}
return (
<div className={`discussion-message ${message.role}`}>
<div className="discussion-message-meta">
<span className="discussion-message-author">
@{message.author_login || '—'}
</span>
<span className="discussion-message-time">
{formatTimestamp(message.created_at)}
</span>
</div>
{message.quote && (
<div className="discussion-message-quote">"{message.quote}"</div>
)}
<div className="discussion-message-body">{message.text}</div>
</div>
)
}
function formatTimestamp(ts) {
if (!ts) return ''
try {
const d = new Date(ts + (ts.endsWith('Z') ? '' : 'Z'))
return d.toLocaleString()
} catch {
return ts
}
}
+28 -14
View File
@@ -39,6 +39,7 @@ import MarkdownPreview from './MarkdownPreview.jsx'
import SelectionTooltip from './SelectionTooltip.jsx'
import PromptBar from './PromptBar.jsx'
import ChatPanel from './ChatPanel.jsx'
import RFCDiscussionPanel from './RFCDiscussionPanel.jsx'
import ChangePanel, { diffWords } from './ChangePanel.jsx'
import PRModal from './PRModal.jsx'
import GraduateDialog from './GraduateDialog.jsx'
@@ -535,9 +536,10 @@ export default function RFCView({ viewer }) {
type="button"
className={`btn-mode-toggle ${mode}`}
onClick={() => setMode(mode === 'discuss' ? 'contribute' : 'discuss')}
title={mode === 'discuss' ? 'Flip into edit mode' : 'Flip back to read-only discuss'}
title={mode === 'discuss' ? 'Flip into edit mode (Beta)' : 'Flip back to read-only discuss (Beta)'}
>
{mode === 'discuss' ? 'Contribute' : 'Discuss'}
<span className="beta-chip">Beta</span>
</button>
)}
{(branchParam === 'main' || !canContribute) && viewer && (
@@ -547,10 +549,13 @@ export default function RFCView({ viewer }) {
onClick={handleStartContributing}
>
Start Contributing
<span className="beta-chip">Beta</span>
</button>
)}
{!viewer && (
<a className="btn-link" href="/auth/login">Sign in</a>
<a className="btn-link" href="/auth/login" title="Private beta — only invited emails can sign in">
Sign in <span className="beta-chip">Beta</span>
</a>
)}
{canOpenPR && (
<button
@@ -742,7 +747,8 @@ export default function RFCView({ viewer }) {
/>
) : (
<div className="readonly-bar">
Read-only view. <a href="/auth/login">Sign in</a> to participate.
Read-only view. Discussion is in private <strong>Beta</strong> {' '}
<a href="/auth/login">sign in</a> if your email has been invited.
</div>
)}
</div>
@@ -754,17 +760,25 @@ export default function RFCView({ viewer }) {
data-open={drawerOpen ? 'true' : 'false'}
/>
<div className={`right-panel${drawerOpen ? ' drawer-open' : ''}`} role="complementary">
<ChatPanel
messages={messages}
threads={branchView.threads || []}
changes={changes}
branchName={branchParam}
isStreaming={isStreaming}
contributionMode={mode === 'contribute'}
onStartContribution={handleStartContributing}
onScrollToChange={setFocusedChangeId}
onResolveThread={handleResolveThread}
/>
{/* v0.5.0 on main, the right panel is the PR-less discussion
* surface (threads.branch_name IS NULL). Branches keep their
* existing branch-chat panel; contribution still requires
* opening a PR from a branch via the Open PR affordance above. */}
{branchParam === 'main' ? (
<RFCDiscussionPanel slug={slug} viewer={viewer} />
) : (
<ChatPanel
messages={messages}
threads={branchView.threads || []}
changes={changes}
branchName={branchParam}
isStreaming={isStreaming}
contributionMode={mode === 'contribute'}
onStartContribution={handleStartContributing}
onScrollToChange={setFocusedChangeId}
onResolveThread={handleResolveThread}
/>
)}
{mode === 'contribute' && (changes.length > 0 || manualPending) && (
<ChangePanel
changes={changes}
+154
View File
@@ -0,0 +1,154 @@
// consent.js — cookie / privacy consent state (v0.13.0, SPEC §14.5).
//
// The framework's analytics SDK gating (roadmap item #13, target v0.15.0)
// will read from this module. v0.13.0 ships the storage + the banner +
// the on-change pub/sub; no analytics SDK ships yet.
//
// Shape of a consent record:
//
// { essential: true, analytics: bool, other: bool, recorded_at: string | null }
//
// `essential` is always true at the API surface; it's included for
// symmetry. `recorded_at` is null when the user has not yet made a
// choice — the banner is shown until it's non-null.
//
// Precedence:
// - Anonymous viewer: localStorage is the only source.
// - Authenticated viewer: on sign-in, the server row (if any) overrides
// local; if the server has no row, the local choice is uploaded.
//
// The fan-out is intentionally tiny — three flags. The banner writes
// once; subscribers re-read on demand via `getConsent()` and can
// register `onConsentChange(cb)` to be notified of subsequent updates.
//
// IMPORTANT: don't import this from analytics SDKs that themselves
// set cookies on load. Read consent first, then conditionally `import()`
// the SDK module — that's the contract item #13 will follow.
const LS_KEY = 'rfc-app.cookie-consent.v1'
const DEFAULT = Object.freeze({
essential: true,
analytics: false,
other: false,
recorded_at: null,
})
const listeners = new Set()
function readLocal() {
try {
const raw = localStorage.getItem(LS_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object') return null
return normalize(parsed)
} catch {
return null
}
}
function writeLocal(record) {
try {
localStorage.setItem(LS_KEY, JSON.stringify(normalize(record)))
} catch {
// localStorage may be unavailable (private mode, disabled storage).
// In that case we behave as if no choice was ever made — the banner
// shows on every load. Acceptable per §14.5: the user can still
// refuse to consent on each visit.
}
}
function normalize(record) {
return {
essential: true,
analytics: !!record.analytics,
other: !!record.other,
recorded_at: record.recorded_at || null,
}
}
// In-memory snapshot. Initialised lazily on first read so the module
// import order doesn't matter; refreshed by `setConsent` and
// `hydrateFromServer`.
let _snapshot = null
function snapshot() {
if (_snapshot == null) {
_snapshot = readLocal() || { ...DEFAULT }
}
return _snapshot
}
function emit() {
for (const cb of listeners) {
try { cb(snapshot()) } catch {}
}
}
/** Read the current consent record. Always returns a normalized object;
* `recorded_at: null` means the user has not yet chosen. */
export function getConsent() {
return snapshot()
}
/** True if the user has made a choice. The banner uses this to decide
* whether to render itself on load. */
export function hasChosen() {
return snapshot().recorded_at != null
}
/** Subscribe to consent updates. Returns an unsubscribe function. */
export function onConsentChange(cb) {
listeners.add(cb)
return () => listeners.delete(cb)
}
/** Write a new choice locally and emit. Returns the new snapshot. The
* server-side persistence path is handled separately by the banner /
* settings surface via the API client; this helper is for both anon
* and authenticated callers because localStorage is the always-on
* layer (the server row is a backup that survives sign-out). */
export function setConsent({ analytics = false, other = false } = {}) {
const next = normalize({
analytics,
other,
recorded_at: new Date().toISOString(),
})
_snapshot = next
writeLocal(next)
emit()
return next
}
/** Adopt a server-side record as authoritative. Called by the banner /
* settings surface after sign-in when the server returns a non-null
* recorded_at. Updates local + memory + emits to subscribers. */
export function hydrateFromServer(record) {
if (!record || !record.recorded_at) return snapshot()
const next = normalize(record)
_snapshot = next
writeLocal(next)
emit()
return next
}
/** Reset local state used by the settings "Change" affordance to
* re-prompt the banner. Does not touch the server row; the user must
* re-confirm a choice and the banner uploads on save. */
export function clearLocal() {
try { localStorage.removeItem(LS_KEY) } catch {}
_snapshot = { ...DEFAULT }
emit()
return _snapshot
}
// Cross-tab sync: if another tab writes the key, mirror the change here.
// Wrapped in a guard so SSR / non-browser test contexts don't blow up.
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('storage', e => {
if (e.key !== LS_KEY) return
_snapshot = readLocal() || { ...DEFAULT }
emit()
})
}
+125
View File
@@ -0,0 +1,125 @@
// Cookies.jsx v0.13.0 / roadmap item #11 / SPEC §14.6.
//
// Lists the framework's cookies, by category, with each cookie's
// purpose. Deployments override via `VITE_COOKIES_POLICY_URL` (linked
// below the framework's stub list, same shape as the privacy page).
//
// Keeping the list in source makes the framework self-documenting:
// when a future framework release adds or removes a cookie, this page
// is the change-record. Item #13's analytics SDK will add its own row
// to the analytics-category list in v0.15.0.
import { useNavigate, Link } from 'react-router-dom'
const COOKIES = [
{
name: 'rfc_session',
category: 'Essential',
purpose: "Signed session cookie that remembers who you're signed in as. itsdangerous-signed; HttpOnly; SameSite=Lax.",
lifetime: 'Session (cleared on sign-out).',
},
{
name: 'rfc-app.cookie-consent.v1',
category: 'Essential',
purpose: 'localStorage entry (not a cookie strictly, but tracked here for symmetry) that remembers your consent choice on this device. Cleared on browser data reset.',
lifetime: 'Until cleared.',
},
]
export default function Cookies() {
const navigate = useNavigate()
const deploymentUrl = (import.meta.env.VITE_COOKIES_POLICY_URL || '').trim()
const appName = import.meta.env.VITE_APP_NAME || 'this deployment'
return (
<div className="policy-page">
<header className="policy-header">
<button
className="policy-back"
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
>
Back
</button>
<span className="policy-title">Cookies policy</span>
</header>
<article className="policy-body">
<h1>Cookies policy</h1>
<p className="policy-subtitle">
What {appName} stores in your browser, by category.
</p>
<h2>Categories</h2>
<ul>
<li>
<strong>Essential</strong> required for the app to keep
you signed in, protect submissions, and remember your
consent choice. Cannot be switched off (without these the
app cannot function).
</li>
<li>
<strong>Analytics</strong> optional anonymous usage
telemetry. Off by default; opt-in via the consent banner.
As of v0.13.0 no analytics SDK ships; roadmap item #13
(v0.15.0) adds one behind this gate.
</li>
<li>
<strong>Other</strong> third-party embeds, social
widgets, or anything else the deployment chooses to enable.
Off by default; opt-in via the consent banner. The
framework ships no such cookies by default.
</li>
</ul>
<h2>Current cookies set by the framework</h2>
<table className="policy-table">
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th>Purpose</th>
<th>Lifetime</th>
</tr>
</thead>
<tbody>
{COOKIES.map(c => (
<tr key={c.name}>
<td><code>{c.name}</code></td>
<td>{c.category}</td>
<td>{c.purpose}</td>
<td>{c.lifetime}</td>
</tr>
))}
</tbody>
</table>
<h2>Manage your choice</h2>
<p>
Change your consent any time from{' '}
<Link to="/settings/notifications">
Settings &rarr; Privacy &amp; cookies
</Link>. The "Change" affordance re-opens the consent banner
with your current selection pre-loaded.
</p>
{deploymentUrl ? (
<>
<h2>Deployment-specific cookies</h2>
<p>
This deployment may add additional cookies on top of the
framework's. See the full deployment policy at:
</p>
<p>
<a href={deploymentUrl} target="_blank" rel="noopener noreferrer">
{deploymentUrl}
</a>
</p>
</>
) : null}
<p className="policy-footnote">
See also the <Link to="/privacy">privacy policy</Link>.
</p>
</article>
</div>
)
}
+118
View File
@@ -0,0 +1,118 @@
// Privacy.jsx v0.13.0 / roadmap item #11 / SPEC §14.5.
//
// The framework's default privacy policy page. Reachable by anonymous
// and authenticated viewers alike at `/privacy`. The text below is a
// minimal stub that describes the framework's stance; deployments are
// expected to override it via the `VITE_PRIVACY_POLICY_URL` env var.
//
// When `VITE_PRIVACY_POLICY_URL` is set:
// - http(s) URL the page renders the framework's stub above a
// "Read the full deployment policy" link to the configured URL.
// We don't iframe-embed third-party policy hosts because their
// Content-Security-Policy frequently refuses framing; the link is
// the predictable affordance.
//
// The framework's stub is intentionally short the rules that matter
// to a user are: (1) what categories of cookies the app sets, (2) how
// to change consent, (3) where to reach the deployment operator with a
// complaint. Each deployment's content repo can carry a fuller version.
import { useNavigate, Link } from 'react-router-dom'
export default function Privacy() {
const navigate = useNavigate()
const deploymentUrl = (import.meta.env.VITE_PRIVACY_POLICY_URL || '').trim()
const appName = import.meta.env.VITE_APP_NAME || 'this deployment'
return (
<div className="policy-page">
<header className="policy-header">
<button
className="policy-back"
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
>
Back
</button>
<span className="policy-title">Privacy policy</span>
</header>
<article className="policy-body">
<h1>Privacy policy</h1>
<p className="policy-subtitle">
What {appName} stores, why, and how to control it.
</p>
<h2>What we store</h2>
<p>
{appName} runs on the Wiggleverse RFC framework. The framework
stores the identity you sign in with (your Gitea login,
display name, email, and avatar URL), the proposals and edits
you author, the discussion threads you participate in, and
your notification preferences. Authoring is public by design
this is a framework for public-async RFC work, and threads,
changes, and PRs are visible to anyone who reaches the
deployment. Settings (notification toggles, quiet hours, mute
list, cookie consent) are private to your account.
</p>
<h2>Cookies</h2>
<p>
The app sets a small set of cookies. The full list is on the{' '}
<Link to="/cookies">cookies policy page</Link>. You can choose
which categories you allow from the consent banner shown on
your first visit or from <Link to="/settings/notifications">
Settings &rarr; Privacy &amp; cookies</Link> any time
afterwards.
</p>
<h2>Analytics</h2>
<p>
The framework supports an optional anonymous analytics layer
gated behind your consent choice. As of v0.13.0 no analytics
SDK ships in the framework; deployments that enable analytics
do so via a later framework version (roadmap item #13). The
consent toggle exists today so the gate is already in place
when the SDK lands.
</p>
<h2>Your data, your control</h2>
<ul>
<li>Revoke cookie consent any time from settings.</li>
<li>
Edit notification preferences including the global email
opt-out from{' '}
<Link to="/settings/notifications">notification settings</Link>.
</li>
<li>
Your authored content (proposals, threads, edits) is public
and not retractable from the meta-repo's Git history. If you
need a redaction, reach the deployment operator directly.
</li>
</ul>
{deploymentUrl ? (
<>
<h2>Deployment-specific policy</h2>
<p>
This deployment may layer additional policy on top of the
framework's defaults. Read the full deployment policy at:
</p>
<p>
<a href={deploymentUrl} target="_blank" rel="noopener noreferrer">
{deploymentUrl}
</a>
</p>
</>
) : (
<>
<h2>Deployment contact</h2>
<p>
For deployment-specific privacy questions data subject
requests, redaction requests, complaints contact the
operator of {appName}.
</p>
</>
)}
</article>
</div>
)
}