Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f758fe072f | |||
| 33c67ccc09 | |||
| 508a8cb6d0 | |||
| fec51bdbb6 | |||
| 455ef33b29 | |||
| 9f548a340d | |||
| 569066ef48 | |||
| a117fbb521 | |||
| d5213fc2da | |||
| 380e1f9782 | |||
| db57caf8a1 | |||
| 999c4b65ef | |||
| 0252e40527 | |||
| 97ba3ae9b5 | |||
| 6c2bdb3c0a | |||
| 0f6b2b464b | |||
| f114af8ce0 | |||
| a2dc29af9c | |||
| 8004b2a123 | |||
| 69fd0cb2f0 | |||
| 87ddb845f4 | |||
| 76207bbb62 | |||
| 2fe2a719ac | |||
| fe47eefdd9 | |||
| e8ce3cd228 | |||
| 07e003e5fc | |||
| c386b05960 | |||
| 48fd6f9675 | |||
| cecc6c0b41 | |||
| f1b03dffef | |||
| 759a42e589 | |||
| 2746242022 | |||
| f7bd466f31 | |||
| 539d063c22 | |||
| 27a0a0443b | |||
| 7d05125381 | |||
| 8f21dc5f9c | |||
| 49b741243e | |||
| 2d9022b19e | |||
| 0bccae1260 | |||
| d8661d5025 | |||
| d73a9e2860 | |||
| 597f6bc92b | |||
| 3a3104f4c6 | |||
| dd72f913a3 | |||
| 1b011ed483 | |||
| 2cf7db4bff | |||
| 6f356d3598 | |||
| 7703fa233a | |||
| 1dab24eef0 | |||
| 503689bf1a | |||
| ad2ece18fa | |||
| 57b2fc5205 | |||
| 34a65e099e | |||
| 848de4cd8a | |||
| 49ba06e0c2 | |||
| 6f901e3e2c | |||
| 7aba89655d | |||
| 0062510a4e | |||
| 714c2aed86 | |||
| 551d240967 | |||
| 76c82a5e96 | |||
| e8e555d8a4 | |||
| 7e595b6e5e | |||
| 1d716d0cb8 | |||
| f96883506e | |||
| 0c972c8af5 | |||
| d581010063 | |||
| 732b23b156 | |||
| 1558cc3a8b | |||
| 8a94e26f75 | |||
| 3c9109c392 | |||
| 019c8a9185 | |||
| 79a447c77b | |||
| fe044ed3db | |||
| bd3ef269d4 | |||
| 698821f065 | |||
| e794523079 |
@@ -0,0 +1,15 @@
|
||||
# Keep the preview build context lean + reproducible.
|
||||
.git
|
||||
.gitea
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
backend/.venv/
|
||||
backend/data/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
e2e/
|
||||
mockups/
|
||||
docs/
|
||||
*.md
|
||||
!VERSION
|
||||
.pytest_cache/
|
||||
+869
@@ -23,6 +23,875 @@ 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.39.0 — 2026-06-04
|
||||
|
||||
**Minor — §22.13 step 1: the default-project-id re-stamp. A deployment can
|
||||
move its original corpus off the bootstrap `default` id onto a meaningful slug
|
||||
(e.g. `ohm`) so it lands at `/p/<id>/` and `default` is never a public URL.
|
||||
No-op unless `DEFAULT_PROJECT_ID` is set to a non-`default` value.**
|
||||
|
||||
Added:
|
||||
|
||||
- **`projects.restamp_default_project(config)`** — at startup, after the
|
||||
registry mirror, if `DEFAULT_PROJECT_ID` resolves to a non-`default` id and
|
||||
bootstrap-stamped rows still exist, it renames `project_id` from `default` to
|
||||
the configured id across **every** project-scoped table (discovered by
|
||||
column, so it stays correct as the schema grows) and drops the stale
|
||||
`default` `projects` row (its data has moved to the configured row the
|
||||
registry mirror created). The rename runs with FK enforcement off — parent
|
||||
and child rows move together, so the composite FKs stay consistent — with a
|
||||
`foreign_key_check` backstop before commit. Idempotent.
|
||||
- **Tests:** `test_restamp_default_project.py` — data + composite-FK children
|
||||
move to the new id, the stale row is dropped, FK integrity holds, the second
|
||||
call is a no-op, and an unset `DEFAULT_PROJECT_ID` leaves `default` in place.
|
||||
450 backend green.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. **MAY** set `DEFAULT_PROJECT_ID=<slug>` in the backend overlay and add the
|
||||
matching project (same `id`) to `projects.yaml`. On the next deploy the
|
||||
re-stamp moves the original corpus onto `<slug>` once; `default` URLs never
|
||||
become public. Leave it unset to keep the `default` id (no change).
|
||||
|
||||
## 0.38.0 — 2026-06-04
|
||||
|
||||
**Minor — §22 M3-backend Plan B (write path, propose): a new entry can be
|
||||
proposed *into a specific project*, landing in that project's content repo and
|
||||
surfacing under that project's proposals. A non-default project is no longer
|
||||
read-only. No upgrade steps; single-project deployments are unaffected.**
|
||||
|
||||
Added:
|
||||
|
||||
- **`POST /api/projects/{pid}/rfcs/propose`** — propose into a chosen project
|
||||
(read-gated, then project-level contribute-gated, §22.6/§22.7). The propose
|
||||
body is now a project-parameterized helper; the unscoped `/api/rfcs/propose`
|
||||
stays as the default-project compat path. Slug uniqueness, the idea-PR
|
||||
reservation, the landing state (§22.4b), and the `proposed_use_cases` row are
|
||||
all scoped to the target project.
|
||||
- **`GET /api/projects/{pid}/proposals`** — pending idea-PRs scoped to one
|
||||
project.
|
||||
- **Per-project PR mirror** (`app/cache.py`): `refresh_meta_pulls` iterates
|
||||
every project's `content_repo`, stamping `cached_prs.project_id` (was: the
|
||||
default project only). `projects.content_repo(pid)` helper added.
|
||||
- **Tests:** `test_project_scoped_propose.py` — propose into a second project
|
||||
lands in its content repo + shows only under its proposals (not the
|
||||
default's); gated-project propose 404s a non-member. 447 backend green.
|
||||
|
||||
Changed:
|
||||
|
||||
- **Frontend:** `api.proposeRFC(projectId, …)` / `listProposals(projectId)`;
|
||||
`ProposeModal` takes a `projectId`; `App` resolves the current project from
|
||||
the `/p/<id>/` URL so the propose modal targets it; `Catalog` lists that
|
||||
project's proposals.
|
||||
|
||||
Known limitation (next slice): the **edit** write flows — branch / PR /
|
||||
graduation — and the **default-project-id re-stamp** (§22.13 step 1) are not
|
||||
yet project-scoped (they still target the default project's content repo). Per
|
||||
`docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md` §1 + §3.
|
||||
|
||||
## 0.37.0 — 2026-06-04
|
||||
|
||||
**Minor — §22 M3-backend Plan B (2/2, read path): per-project RFC serving. A
|
||||
second project's corpus now renders under `/p/<id>/`, isolated by its own slug
|
||||
namespace. No upgrade steps; single-project deployments are unaffected.**
|
||||
|
||||
This is the slice that makes "multiple projects" *observable*: M3-frontend
|
||||
(v0.35.0) shipped the shell with a "not served" guard for any non-default
|
||||
project; v0.36.0 rebuilt the keys so project #2 can exist; this serves project
|
||||
#2's corpus.
|
||||
|
||||
Added:
|
||||
|
||||
- **Per-project read endpoints** (`app/api.py`): `GET /api/projects/{pid}/rfcs`
|
||||
(catalog scoped to one project) and `GET /api/projects/{pid}/rfcs/{slug}`
|
||||
(entry by `(project_id, slug)`), both behind the §22.5 read gate
|
||||
(gated project 404s a non-member). The unscoped `/api/rfcs[/{slug}]` remain as
|
||||
the default-project compat path.
|
||||
- **Per-project corpus mirror** (`app/cache.py`): `refresh_meta_repo` now
|
||||
iterates every `projects` row and mirrors each project's `content_repo` into
|
||||
`cached_rfcs` stamped with that `project_id` (was: the default project only).
|
||||
- **Tests:** `test_project_scoped_serving.py` — catalog scoping, entry
|
||||
isolation (same slug under two projects resolves distinctly; a slug present
|
||||
only in one 404s under the other), gated-project 404. 445 backend green.
|
||||
|
||||
Changed:
|
||||
|
||||
- **Frontend** reads the scoped routes: `api.listRFCs(projectId)` /
|
||||
`getRFC(projectId, slug)`; `Catalog` + `RFCView` pass `useProjectId()`. The
|
||||
**M3-frontend `NotServedPlaceholder` guard is removed** — every registry
|
||||
project renders its corpus. `ProjectLayout` keeps the 404/not-readable branch.
|
||||
|
||||
Known limitation (next slice): the **write** path (propose / branch / PR /
|
||||
graduate) and the **default-project-id re-stamp** (§22.13 step 1) are not yet
|
||||
project-scoped — write affordances still target the default project, so a
|
||||
non-default project is effectively read-only until Plan B's write slice. (No
|
||||
live impact: no deployment runs a second project yet.) Per
|
||||
`docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md` §1 + §3 (write).
|
||||
|
||||
## 0.36.0 — 2026-06-04
|
||||
|
||||
**Minor — §22 M3-backend Plan B (1/2): the slug-keyed PK/UNIQUE rebuild that
|
||||
activates project #2. No behavior change (deployments are still single-project
|
||||
`default`); migration `028` runs automatically on deploy.**
|
||||
|
||||
This lands the table rebuilds migration 026 deliberately deferred "until a
|
||||
second project exists" — folding `project_id` into the slug-keyed PRIMARY KEY /
|
||||
UNIQUE constraints so two projects can hold the same slug without colliding.
|
||||
Shipped separately from per-project RFC *serving* (Plan B 2/2) for migration
|
||||
hygiene: the schema change deploys and is verified on its own, smaller blast
|
||||
radius.
|
||||
|
||||
Added:
|
||||
|
||||
- **Migration `028_project_scoped_keys.sql`** — rebuilds 13 tables to composite
|
||||
slug keys: `cached_rfcs` PK `(slug)` → `(project_id, slug)`; the `UNIQUE`/PK
|
||||
on `cached_branches`, `branch_visibility`, `branch_contribute_grants`,
|
||||
`stars`, `watches`, `pr_seen`, `branch_chat_seen`, `funder_consents`,
|
||||
`rfc_collaborators`, `contribution_requests`, `proposed_use_cases` all gain
|
||||
`project_id`; and the FKs **to** `cached_rfcs(slug)` on `rfc_invitations`,
|
||||
`rfc_collaborators`, `contribution_requests` become composite
|
||||
`(project_id, rfc_slug) → cached_rfcs(project_id, slug)`. (`cached_prs` is
|
||||
unchanged — `(repo, pr_number)` is already globally unique.)
|
||||
- **Migration-runner capability** (`db.run_migrations`): a migration whose
|
||||
first line is `-- migrate:no-foreign-keys` runs with `PRAGMA foreign_keys`
|
||||
toggled OFF around it (required by SQLite's table-rebuild procedure, and a
|
||||
no-op inside a transaction) and a `PRAGMA foreign_key_check` after that fails
|
||||
the migration loudly on any dangling reference.
|
||||
|
||||
Changed:
|
||||
|
||||
- The `ON CONFLICT(...)` upsert targets for the rebuilt tables gain `project_id`
|
||||
(`cache.py`, `api_prs.py`, `api_branches.py`, `api_notifications.py`,
|
||||
`api.py`, `funder.py`) so they continue to match the new composite indexes.
|
||||
The inserted `project_id` still defaults to `default`, so behavior is
|
||||
identical for a single-project deployment.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. **MUST** deploy. Migration `028` runs automatically at startup; it rewrites
|
||||
the listed tables in one transaction with FK enforcement off and verifies
|
||||
`foreign_key_check` after. No data is dropped (rows already carry
|
||||
`project_id` from migration 026, M1). No config change.
|
||||
2. **MAY** note: per-project RFC *serving* (path-scoped endpoints + the
|
||||
default-id re-stamp + the frontend guard removal) is the next slice (Plan B
|
||||
2/2), per `docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md`.
|
||||
|
||||
## 0.35.0 — 2026-06-04
|
||||
|
||||
**Minor (breaking) — §22 M3 frontend: `/p/<project>/` routing, runtime
|
||||
branding (the `VITE_APP_NAME` hard cut), the deployment directory + project
|
||||
switcher, and server-side 308 redirects off the old corpus-root URLs.
|
||||
Completes the runtime-config cut 0.33.0 began.**
|
||||
|
||||
Added:
|
||||
|
||||
- **`DeploymentProvider`** (`src/context/DeploymentProvider.jsx`) — boots
|
||||
`GET /api/deployment` once and provides `{ name, tagline,
|
||||
defaultProjectId, projects[], loading }` to the tree. The neutral
|
||||
`brandTitle()` fallback (`'RFC'`) paints during the pre-fetch frame.
|
||||
- **`/p/:projectId/*` routing** with the generic `/e/<slug>` entry segment
|
||||
(§22.10). **`ProjectLayout`** (`src/components/ProjectLayout.jsx`) fetches
|
||||
`GET /api/projects/:id`, applies the project's `theme` as `:root` CSS
|
||||
custom-property overrides (reset on switch/unmount so accents never bleed),
|
||||
provides `ProjectContext`, and sets the tab title to the project name.
|
||||
- **The §4 guard** — `ProjectLayout` renders the corpus only for the
|
||||
corpus-served (default) project; any other id renders a "content not yet
|
||||
served" placeholder (`NotServedPlaceholder`), so per-project serving (the
|
||||
next backend slice, Plan B) can land without a wrong-content footgun.
|
||||
- **Deployment directory** at `/` (`Directory.jsx`) — renders the
|
||||
caller-visible projects as cards when **2+** are visible; the **N=1** case
|
||||
redirects straight into the single project (`/p/<id>/`), preserving OHM's
|
||||
"land in the corpus" UX. A **project switcher** (`ProjectSwitcher.jsx`)
|
||||
rides deployment chrome when 2+ projects are visible.
|
||||
- **Entry-noun terminology** (RFC / Spec / Feature) driven by `project.type`
|
||||
(§22.4a); the route segment stays the generic `/e/`.
|
||||
- **`GET /api/deployment`** now returns **`default_project_id`** — the
|
||||
corpus-served project the frontend guard keys on.
|
||||
- **Server-side 308 redirects** (`app/api_deployment.py`): `GET /rfc/<slug>`,
|
||||
`/rfc/<slug>/pr/<n>`, and `/proposals/<n>` permanently redirect to
|
||||
`/p/<default>/e/<slug>[…]` / `/p/<default>/proposals/<n>` (§5/§22.10), so
|
||||
external "RFC-0001"-style links and bookmarks keep working.
|
||||
|
||||
Breaking:
|
||||
|
||||
- **`VITE_APP_NAME` is removed.** The build no longer reads it and no longer
|
||||
fails without it; the deployment name comes from the registry
|
||||
(`deployment.name` in `projects.yaml`) served at runtime via
|
||||
`GET /api/deployment`. The same build now serves any deployment. The
|
||||
build-time `%VITE_APP_NAME%` HTML token and the `inject-app-name` Vite
|
||||
plugin are gone; `index.html` ships a static `<title>RFC</title>` and JS
|
||||
sets the real title after config loads.
|
||||
- **Entry/proposal URLs moved under `/p/<project>/`.** The old SPA routes
|
||||
`/rfc/:slug`, `/rfc/:slug/pr/:n`, `/proposals/:n` are removed from the SPA
|
||||
and served as backend 308s instead — so nginx must route `/rfc/` and
|
||||
`/proposals/` to the backend rather than the SPA `index.html`.
|
||||
|
||||
Scope boundary (informational, not breaking): RFC *data* is still served
|
||||
unscoped for the default project this slice — per-project corpus serving is
|
||||
the next backend slice (Plan B). Non-default projects show the placeholder.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. **MUST** update nginx: add `location /rfc/` and `location /proposals/`
|
||||
blocks that `proxy_pass` to the backend, **before** the SPA `location /`
|
||||
fallback. The framework's `deploy/nginx/ohm.wiggleverse.org.conf` (and
|
||||
`testing/web.nginx.conf`) already carry them; a deployment running a custom
|
||||
vhost MUST add them, or old corpus-root URLs 404 against the SPA instead of
|
||||
redirecting.
|
||||
2. **MUST** ensure the registry `projects.yaml` `deployment.name` is set — it
|
||||
is now the header brand and tab title (already required since 0.33.0).
|
||||
`tagline` shows on the directory.
|
||||
3. **SHOULD** remove `VITE_APP_NAME` from `frontend/.env`; it is no longer
|
||||
read (no error if left — simply ignored).
|
||||
4. **MUST** rebuild the frontend and deploy. Verify: the header shows the
|
||||
deployment name from `/api/deployment`; `/` lands in your project (N=1) or
|
||||
shows the directory (2+); an old `/rfc/<slug>` URL **308**-redirects to
|
||||
`/p/<default>/e/<slug>`; `/api/health` is green.
|
||||
5. **MAY** note: the Tier-1 Playwright e2e for these flows lands once the
|
||||
Tier-1 Docker stack seeds a registry repo + `projects.yaml` (`REGISTRY_REPO`
|
||||
is unset in `testing/.env.tier1` today, so the dockerized backend can't boot
|
||||
there post-0.33.0). This slice is covered by Vitest unit tests
|
||||
(`DeploymentProvider`, `ProjectLayout` theme/guard, `Directory`) + the
|
||||
backend redirect tests (`backend/tests/test_api_deployment.py`).
|
||||
|
||||
## 0.34.0 — 2026-06-04
|
||||
|
||||
**Minor — containerize rfc-app for per-PR preview environments (flotilla
|
||||
SPEC §15). Additive: production is unaffected — it still deploys via the
|
||||
pin-based on-VM gesture. No upgrade steps for existing deployments.**
|
||||
|
||||
Adds a `Dockerfile` (+ `deploy/preview/`) so the operator's flotilla can build
|
||||
a PR's tree and run it as an ephemeral, scale-to-zero Cloud Run preview with a
|
||||
seeded **synthetic** database and **zero real secrets** (test-secret env only):
|
||||
|
||||
- `Dockerfile` — multi-stage: Vite SPA build → Python runtime serving the SPA
|
||||
via nginx on `$PORT` and reverse-proxying `/api/`,`/auth/` to a single-process
|
||||
uvicorn on `127.0.0.1:8000` (mirrors the prod nginx + systemd split, minus
|
||||
TLS, minus prod secrets). Single process, single SQLite file (§4.2).
|
||||
- `deploy/preview/entrypoint.sh` — renders nginx against Cloud Run's `$PORT`,
|
||||
boots uvicorn (which runs migrations), and applies the synthetic seed on a
|
||||
fresh DB.
|
||||
- `deploy/preview/seed.sql` — version-controlled synthetic fixture (no PII).
|
||||
- `deploy/preview/preview.env.example` — the test-secret env shape (Cloudflare
|
||||
always-pass Turnstile keys, a Mailpit SMTP sink, analytics no-op'd) the
|
||||
operator loads into flotilla's preview overlay layer.
|
||||
|
||||
The framework still knows nothing about flotilla — the image is a generic
|
||||
container of rfc-app; flotilla is one possible orchestrator of it.
|
||||
|
||||
## 0.33.0 — 2026-06-04
|
||||
|
||||
**Minor (breaking) — §22 M3 backend Plan A: project registry mirror +
|
||||
runtime config. The framework now learns its projects from
|
||||
`REGISTRY_REPO/projects.yaml`; `META_REPO` is retired. Migration `027`
|
||||
runs automatically on deploy.**
|
||||
|
||||
Added:
|
||||
|
||||
- **Project registry mirror** (`app/registry.py`): the framework reads
|
||||
`projects.yaml` from `REGISTRY_REPO` and mirrors its `deployment:`
|
||||
block and `projects:` entries into the `projects` table + a new
|
||||
`deployment` singleton. The mirror runs at startup (reconciler) and
|
||||
on every §4 webhook push to the registry repo.
|
||||
- **`GET /api/deployment`** — returns `name`, `tagline`, and the list
|
||||
of visible projects (each item carries `id`, `name`, `type`,
|
||||
`visibility`). Replaces the build-time `VITE_APP_NAME` as the
|
||||
authoritative runtime config source (the frontend cut lands in
|
||||
M3-frontend).
|
||||
- **`GET /api/projects/:id`** — returns `id`, `name`, `tagline`, `type`,
|
||||
`visibility`, `initial_state`, and `theme` for a single project.
|
||||
- **§22.4b `initial_state`** honored at propose time: new RFCs enter
|
||||
the state named by the project's `initial_state` field (default:
|
||||
`super-draft`; `bdd` projects default to `active`).
|
||||
- **§22.4c `unreviewed` flag**: newly proposed RFCs are flagged
|
||||
`unreviewed = true`. Owners clear it via
|
||||
`POST /api/projects/:id/rfcs/:slug/mark-reviewed`. The catalog
|
||||
accepts `?unreviewed=true` to filter to the review queue.
|
||||
- **Migration `027`** (additive): adds `projects.type` /
|
||||
`projects.initial_state`, the `deployment` table, and
|
||||
`cached_rfcs.unreviewed` / `reviewed_at` / `reviewed_by`.
|
||||
|
||||
Breaking:
|
||||
|
||||
- `META_REPO` is retired. The app **refuses to start** if `REGISTRY_REPO`
|
||||
is unset. The corpus mirror now reads each project's `content_repo`
|
||||
from `projects.yaml` rather than from the `META_REPO` env var.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. **MUST** create a registry repo under your deployment's Gitea org.
|
||||
2. **MUST** author `projects.yaml` at its root with a `deployment:` block
|
||||
(`name`, `tagline`) and one `projects:` entry for your existing corpus:
|
||||
```yaml
|
||||
deployment:
|
||||
name: <your deployment name>
|
||||
tagline: <your tagline>
|
||||
projects:
|
||||
- id: default
|
||||
name: <your project name>
|
||||
type: document
|
||||
content_repo: <your old META_REPO value>
|
||||
visibility: public
|
||||
```
|
||||
Keep `id: default` for this release; the pretty-slug re-stamp lands in
|
||||
the next backend slice, before any `/p/` URL is public.
|
||||
3. **MUST** set `REGISTRY_REPO=<your registry repo name>` and **MUST**
|
||||
remove `META_REPO` (or leave it unset — it is ignored but its presence
|
||||
may cause confusion).
|
||||
4. **MUST** add a Gitea webhook on the registry repo pointing at
|
||||
`/api/webhooks/gitea` (same secret as the corpus webhook).
|
||||
5. **MUST** deploy. Migration `027` runs automatically at startup; the
|
||||
registry mirror reconciles immediately after. Verify
|
||||
`GET /api/deployment` returns your project and `/api/health` is green.
|
||||
6. **SHOULD** rebuild the frontend (no new env var is required until
|
||||
M3-frontend lands, but the frontend currently still reads
|
||||
`VITE_APP_NAME` for the display name).
|
||||
|
||||
## 0.32.0 — 2026-06-01
|
||||
|
||||
**Minor — graduation's integer RFC number is now optional, and RFC
|
||||
owners + site owners can retire (soft-delete) RFCs. Schema migration
|
||||
`025_retired_state.sql` runs automatically on deploy; a frontend rebuild
|
||||
applies the UI.**
|
||||
|
||||
Two changes to the §13 lifecycle:
|
||||
|
||||
1. **Optional number at graduation (§13.2/§13.3).** Graduation no longer
|
||||
hard-requires a valid `RFC-NNNN`. The Graduate dialog's integer-ID
|
||||
field is now optional: it is still pre-filled with the next free
|
||||
number as a *suggestion*, but the graduating owner may clear it and
|
||||
graduate with **no number**. When blank, the entry flips to `active`
|
||||
with `id: null` and the **slug remains the canonical identifier**
|
||||
(§2.3). `GET …/graduate/check` treats a blank id as valid (`ok:true`);
|
||||
`POST …/graduate` accepts a blank/absent `rfc_id` and only validates
|
||||
the `^RFC-\d{4,}$` regex + collision check when a number *is* supplied.
|
||||
The catalog and RFC view render number-less active entries by their
|
||||
slug/title (no "RFC-undefined"). RFC-0001's existing number is
|
||||
grandfathered — `cached_rfcs.rfc_id` was already nullable, so no data
|
||||
migration was needed for this part.
|
||||
|
||||
2. **Retire / un-retire — soft delete (§3, §3.1, §13.7).** A fourth entry
|
||||
state, `retired`, is added. An RFC's own owners (frontmatter) and site
|
||||
`owner`-role holders — **not** app admins — may retire an entry
|
||||
(`POST /api/rfcs/<slug>/retire`); it flips to `retired` via an
|
||||
auto-merged meta-repo PR, leaving the body and every other field
|
||||
(including any integer id) intact. A retired entry is removed from
|
||||
**every** browsing surface: the catalog, the RFC/discussion/branch
|
||||
views (404), and link pickers/search. It is *not* hard-deleted — the
|
||||
entry stays in `rfcs/` (git is truth). Un-retire
|
||||
(`POST /api/rfcs/<slug>/unretire`) restores the prior state and is
|
||||
**site-owner-only**, so a soft-delete is always recoverable by the
|
||||
operator but an RFC owner cannot reverse their own retirement. Site
|
||||
owners find retired entries via a new owner-gated admin surface
|
||||
(`GET /api/admin/retired-rfcs`, the "Retired" tab) and un-retire from
|
||||
there or by navigating directly to the entry.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
- **MUST** run the migration. `025_retired_state.sql` rebuilds
|
||||
`cached_rfcs` to widen its `state` CHECK constraint to include
|
||||
`retired` (SQLite cannot alter a CHECK in place). It runs automatically
|
||||
at startup via the forward-only migration runner; existing rows are
|
||||
preserved, and the table is reconstructible from Gitea by the
|
||||
reconciler regardless (§4). No operator action beyond a normal deploy.
|
||||
- **SHOULD** rebuild the frontend so the optional-id Graduate dialog, the
|
||||
Retire affordance, and the owner-only "Retired" admin tab are present.
|
||||
- No config or secret changes.
|
||||
|
||||
## 0.31.4 — 2026-06-01
|
||||
|
||||
**Patch — bug fix + UI polish: secondary buttons that were invisible on
|
||||
light surfaces now render legibly, and the RFC view's breadcrumb action
|
||||
bar is harmonized into one coherent control group. CSS-only
|
||||
(`frontend/src/App.css`); no schema, API, config, overlay, or secret
|
||||
change — a plain frontend rebuild applies it. No upgrade steps. Shipped
|
||||
from driver session 0059.0.**
|
||||
|
||||
`.btn-link` was authored as a *dark-header* utility — white text on a
|
||||
translucent-white fill (`rgba(255,255,255,0.15)`), the established
|
||||
on-dark pattern for the app header's "Sign out". But the same class is
|
||||
reused on **light** surfaces: the RFC breadcrumb action bar
|
||||
(`RFCView.jsx`), the PR view's diff-mode toggle and "Edit title" control
|
||||
(`PRView.jsx`), the invitations and inbox modals, and the discussion
|
||||
panel. On those near-white backgrounds the buttons were white-on-white —
|
||||
present in the DOM, fully functional, but visually invisible. The
|
||||
reported symptom: on a super-draft's header, "Metadata", "Claim
|
||||
ownership", and "Invitations" looked *missing*, while the filled CTAs
|
||||
("Start Contributing", "Graduate to RFC repo") rendered fine because
|
||||
their fill carried them.
|
||||
|
||||
The fix is root-cause, not a per-site patch:
|
||||
|
||||
- The base `.btn-link` rule is now a proper light-surface secondary
|
||||
button (white fill, hairline `--c-gray-300` border, `--c-gray-700`
|
||||
label, hover darkens both). This corrects every light-surface reuse at
|
||||
once.
|
||||
- The original translucent-on-dark treatment is preserved for the one
|
||||
legitimate dark-surface use via an `.app-header .btn-link` scope, so
|
||||
the header "Sign out" is unchanged.
|
||||
- The breadcrumb action bar (`.breadcrumb-actions`) normalizes every
|
||||
action — the discuss/contribute toggle, the filled CTAs, and the
|
||||
secondary buttons — to one height, radius, and type scale, so the row
|
||||
reads as a single intentional control group. The bar now `flex-wrap`s
|
||||
instead of clipping buttons off the right edge when the set is wide.
|
||||
- The diff-mode toggle's active option now reads as clearly selected
|
||||
(filled ink) rather than relying on a weight change alone.
|
||||
|
||||
Smooth hover transitions and the keyboard focus ring were already
|
||||
provided globally by the v0.21.0 interaction-polish layer, so this
|
||||
change adds no new motion or focus rules — it only corrects resting-state
|
||||
color/contrast and harmonizes sizing.
|
||||
|
||||
## 0.31.3 — 2026-05-30
|
||||
|
||||
**Patch — admin Users tab: "Last seen" reads "Never" for unclaimed
|
||||
invites. Visual/logic only in `Admin.jsx`. A plain frontend rebuild
|
||||
applies it.**
|
||||
|
||||
An admin-created invite row showed a real-looking "Last seen" timestamp
|
||||
identical to "Signed up," implying the invitee had visited when they
|
||||
hadn't. Cause: `users.last_seen_at` is `NOT NULL DEFAULT (datetime('now'))`
|
||||
(`migrations/001_users_and_audit.sql`) and the invite INSERT
|
||||
(`invites.py`) sets neither timestamp, so both default to the
|
||||
row-creation instant; `last_seen_at` only advances on a real
|
||||
authentication. Since an unclaimed invite has provably never
|
||||
authenticated (that unclaimed state is exactly what drives the
|
||||
"PENDING INVITE" badge), the Users tab now renders **"Never"** for the
|
||||
Last-seen cell of a pending-invite row instead of the misleading
|
||||
default. Signed-up (the invite-created date) is unchanged.
|
||||
|
||||
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
|
||||
|
||||
## 0.31.2 — 2026-05-29
|
||||
|
||||
**Patch — landing (`/`) welcome panel spacing. Visual only: CSS in
|
||||
`App.css` (`.welcome`). A plain frontend rebuild applies it.**
|
||||
|
||||
The welcome read-view was jammed against the catalog divider with no
|
||||
top offset and loose, uneven paragraph spacing. Root cause: `.main-pane`
|
||||
carries a bare `.main-pane { padding: 0; display: flex }` override (the
|
||||
§8 three-column RFC shell) that shadows the earlier padded read-view
|
||||
rule, so the pane provides no padding — and `.welcome` (just
|
||||
`max-width`) never compensated. The welcome surface now owns its own
|
||||
breathing room: 56px top / 48px side gutters, a capped 680px measure,
|
||||
a stronger `text-3xl` "Welcome." hero, and even `--space-8` paragraph
|
||||
rhythm at `--leading-relaxed`. Applies to both the signed-out and
|
||||
signed-in welcome (same `.welcome` class).
|
||||
|
||||
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
|
||||
|
||||
## 0.31.1 — 2026-05-29
|
||||
|
||||
**Patch — admin Users tab + header UX polish. Visual only: CSS plus
|
||||
markup/structure in `Admin.jsx` (no API, schema, config, overlay, or
|
||||
secret change). A plain frontend rebuild applies it.**
|
||||
|
||||
Two latent CSS defects fixed:
|
||||
|
||||
- **`.invite-badge` had no rule.** The "(pending invite)" marker on
|
||||
admin-created-but-unclaimed user rows rendered as bare parenthetical
|
||||
text. It's now a quiet amber pill, consistent with the other status
|
||||
badges.
|
||||
- **`.btn-link-quiet` never reset native button chrome.** Used as a
|
||||
bare link-style `<button>` (admin Revoke / Grant / Remove, the modal
|
||||
close ×, and link-buttons in Login / BetaPending), it kept the
|
||||
browser's default grey button box. The reset that the `.otc-login`
|
||||
scope already carried is folded into the base rule, so every
|
||||
`btn-link-quiet` is now a true quiet link.
|
||||
|
||||
Users-tab cleanups, all token-based:
|
||||
|
||||
- Table column headers no longer wrap (`WRITE-MUTED` was breaking onto
|
||||
two lines); timestamps render as an intentional date-over-time stack
|
||||
instead of a ragged mid-value wrap; the duplicated email in a row's
|
||||
subline (the handle already *is* the email when there's no Gitea
|
||||
login) is de-duplicated; the "Create user + invite" action moves
|
||||
flush-right beside the title; inline DB-column references in the
|
||||
intro copy read as quiet chips.
|
||||
|
||||
Header:
|
||||
|
||||
- **Inbox (§15.2) trigger restyled for the dark header.** It carried a
|
||||
light-surface treatment — a `gray-200` border and a `gray-50` hover —
|
||||
that rendered as a pale box in the nav and went white-background /
|
||||
white-icon (invisible) on hover. It now speaks the nav-link
|
||||
vocabulary (`.header-about` et al.): borderless, `gray-300` icon
|
||||
brightening to white on a faint translucent hover, unread badge
|
||||
unchanged.
|
||||
|
||||
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
|
||||
|
||||
## 0.31.0 — 2026-05-29
|
||||
|
||||
**Minor — meta-only repository topology (SPEC §1, ROADMAP #36). RFCs no
|
||||
longer graduate into their own Gitea repositories; every RFC lives in
|
||||
its meta-repo entry (`rfcs/<slug>.md`) for its whole life, and
|
||||
graduation is an in-place `super-draft → active` state flip that keeps
|
||||
the body in the entry. The per-RFC-repo machinery — repo creation,
|
||||
`RFC.md`/`README.md`/`.rfc/metadata.yaml` seeding, body-strip, the
|
||||
five-step transactional sequence and its rollback — is removed. This is
|
||||
the framework change behind a much simpler deployer story: one content
|
||||
repository, every RFC under `rfcs/`.**
|
||||
|
||||
What changed, concretely:
|
||||
|
||||
- **Graduation is a single flip.** `POST /api/rfcs/<slug>/graduate` now
|
||||
opens one meta-repo PR that re-serializes the entry with
|
||||
`state: active`, the integer `id`, and `graduated_at`/`graduated_by`
|
||||
— **body unchanged, `repo` left null** — then auto-merges it. No repo
|
||||
is created, nothing is seeded, and there is no rollback (an open- or
|
||||
merge-failure leaves the entry a super-draft; a failed merge's PR and
|
||||
branch are cleaned up). The Graduate dialog drops the **Repo name**
|
||||
field (two fields now: integer ID + owners) and the progress stack is
|
||||
two steps (`open_pr`, `merge_pr`).
|
||||
- **Active RFCs edit on the meta repo.** Branch/PR/chat dispatch keys on
|
||||
meta-residency (`repo IS NULL`) rather than `state == 'super-draft'`,
|
||||
so an active RFC's branches, body-edit PRs, threads, flags, and
|
||||
`changes` all live on the meta repo exactly as a super-draft's do.
|
||||
`promote-to-branch` names an active RFC's auto-branch
|
||||
`edit-<slug>-<hex>` so the shared-repo cache can attribute it.
|
||||
- **Open body-edit PRs no longer block graduation** (§9.8) — the body is
|
||||
kept, so they coexist with the flip.
|
||||
- **`refresh_rfc_repo` and the per-RFC read path are dead** for
|
||||
meta-only entries (the reconciler only sweeps entries with a non-null
|
||||
`repo`, of which there are none after the fold-back below).
|
||||
|
||||
**Upgrade steps:**
|
||||
|
||||
- Deployments **MUST** fold any already-graduated per-RFC-repo RFC back
|
||||
into its meta entry before/with this deploy: restore the per-RFC
|
||||
`RFC.md` body into `rfcs/<slug>.md`, set `repo: null` (keep
|
||||
`state: active` and the integer `id`), and archive the per-RFC repo.
|
||||
An entry left with a non-null `repo` keeps using the retained legacy
|
||||
read path, but **no new** per-RFC repos are ever created. For OHM,
|
||||
RFC-0001 `human` was folded back in driver session 0041.0 (§13.6).
|
||||
- No schema migration, no new config, no new secret, no overlay change.
|
||||
A plain code deploy applies it; the running reconciler reconciles the
|
||||
catalog on its next sweep (≤5 min).
|
||||
- The `repo:` frontmatter field and the `/api/rfcs/<slug>/blocking-prs`
|
||||
endpoint are **retained** (the field for schema stability + legacy
|
||||
entries; the endpoint as an informational, non-blocking surface), so
|
||||
no client contract is removed — `graduate/check` simply no longer
|
||||
returns a `repo` field and never reports `blocking_prs` as a gate.
|
||||
|
||||
## 0.30.2 — 2026-05-29
|
||||
|
||||
**Patch — header nav label: the persistent chrome link reverts from
|
||||
"Philosophy" back to "About." Display text only — the route
|
||||
(`/philosophy`), the `header-about` class, and the page itself are
|
||||
unchanged. A plain frontend rebuild applies it; no schema, API, config,
|
||||
overlay, or secret change.**
|
||||
|
||||
The §14.3 persistent link was relabeled "About" → "Philosophy" in
|
||||
v0.21.0. This restores "About" as the neutral, framework-native label
|
||||
(the CSS class `header-about` and the surrounding comment already call
|
||||
it "the About link"). A deployment that wants a more pointed framing
|
||||
can title its own About page in the `PHILOSOPHY.md` content the
|
||||
`PHILOSOPHY_PATH` override serves.
|
||||
|
||||
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
|
||||
|
||||
## 0.30.1 — 2026-05-29
|
||||
|
||||
**Patch — bug fix: a merged idea-PR whose branch was deleted no longer
|
||||
lingers as a phantom "pending idea." No schema, API, config, overlay,
|
||||
or secret change — a plain code deploy applies it, and the running
|
||||
reconciler clears any existing ghost on the next sweep (≤5 min) once
|
||||
deployed. Shipped from driver session 0040.0.**
|
||||
|
||||
`refresh_meta_pulls` (and `refresh_rfc_repo`) recover a PR's slug/kind
|
||||
by parsing its Gitea `head.ref`. When a PR is merged **and its branch
|
||||
deleted**, Gitea stops reporting the real branch name and returns the
|
||||
synthetic `refs/pull/<N>/head` sentinel instead. The slug then parsed
|
||||
to `None`, the reconcile loop skipped the row, and `cached_prs.state`
|
||||
stayed frozen at `open` forever — so the entry showed as **both** a
|
||||
super-draft (the `cached_rfcs` push-event reconcile succeeded) **and** a
|
||||
pending idea (the `cached_prs` PR-close reconcile never landed). The fix
|
||||
recovers the original branch name from the already-stored `cached_prs`
|
||||
row (which retains the real `head_branch` from when the PR was open;
|
||||
migration 002) whenever Gitea reports an empty or `refs/pull/` sentinel
|
||||
ref. Regression test added in `test_propose_vertical.py`
|
||||
(`test_merged_idea_pr_with_deleted_branch_clears_proposal`).
|
||||
|
||||
Surfaced through the ROADMAP #35 operator authoring lane, which merges
|
||||
idea PRs from the CLI with branch-deletion enabled — a path the web UX
|
||||
never exercises (it leaves branches in place, so
|
||||
`default_delete_branch_after_merge` stays false). The framework should
|
||||
not depend on branches outliving their merge, hence the framework-level
|
||||
fix rather than a tooling workaround.
|
||||
|
||||
Upgrade steps: none. **SHOULD** deploy as a normal code deploy; the
|
||||
periodic reconciler self-heals any existing phantom on its next sweep.
|
||||
|
||||
## 0.30.0 — 2026-05-29
|
||||
|
||||
**Minor — documentation: the user guide (`DOCS.md`, served at
|
||||
`/docs/user-guide` via `/api/docs`) brought back in sync with the
|
||||
shipped app. No code, schema, API, config, overlay, or secret change —
|
||||
a plain code deploy serves the updated guide. Shipped from driver
|
||||
session 0037.0.**
|
||||
|
||||
The guide had drifted since it was first written: it still described
|
||||
the pre-OTC email *allowlist* sign-in, listed four propose-RFC fields,
|
||||
and predated several shipped surfaces. Updated to match v0.7.0–v0.29.0:
|
||||
|
||||
- **Signing in** rewritten for the email + one-time-code flow (v0.7.0),
|
||||
optional passcode (v0.10.0), trust-this-device for 30 days (v0.11.0),
|
||||
optional Cloudflare Turnstile (v0.12.0), and the beta-access request →
|
||||
`pending` → admin-`granted` gate (v0.8.0 / #6), plus the admin-create
|
||||
+ invite-claim path (v0.17.0 / #16). The vestigial allowlist is no
|
||||
longer described as the gate.
|
||||
- **Proposing a new RFC** now lists five fields, adding the optional
|
||||
"What will you be using this RFC for?" use-case field (#26) and noting
|
||||
the AI tag-suggestion disclosure (#27).
|
||||
- **Roles & permissions** documents the `pending` state.
|
||||
- New **Invitations, cross-references, and contribution requests**
|
||||
section covers owner invitations (#12) and the RFC auto-link /
|
||||
create-RFC / ask-to-contribute affordances (#28).
|
||||
- New **Privacy and cookies** section covers the consent banner and
|
||||
consent-gated analytics (#11 / #13).
|
||||
|
||||
Upgrade steps: none — documentation-only; the change is the `DOCS.md`
|
||||
file served verbatim by `/api/docs`. A plain code deploy at this tag
|
||||
serves it. A deployment that overrides the guide via `DOCS_PATH`
|
||||
supplies its own copy and is unaffected.
|
||||
|
||||
## 0.29.0 — 2026-05-28
|
||||
|
||||
**Minor — roadmap #28 Parts 2 + 3: offer-to-create-an-RFC for strong-
|
||||
candidate terms, and offer-to-contribute-to-a-pending-RFC. One auto-
|
||||
applied migration (024, additive: a new `contribution_requests` table).
|
||||
No config/overlay/secret change; no nginx/systemd change. A plain code
|
||||
deploy + the auto-migration picks it up. Shipped from driver session
|
||||
0033.0.**
|
||||
|
||||
Both parts extend the v0.26.0 (#28 Part 1) read-time scanner
|
||||
(`backend/app/rfc_links.py`) and its renderer
|
||||
(`frontend/src/components/LinkedText.jsx`). The scanner now sorts each
|
||||
matched term into one of three buckets — active link (Part 1, unchanged),
|
||||
pending-RFC contribute offer (Part 3), create-RFC offer (Part 2) — in one
|
||||
pass, with precedence active > pending > candidate at any position. The
|
||||
backend still emits only structured segments (never HTML), so the surface
|
||||
stays XSS-safe by construction.
|
||||
|
||||
- **Part 2 — create-RFC offers.** A *strong-candidate* term — a
|
||||
**multi-word tag** from the #27 tag taxonomy that has no defining RFC
|
||||
(no active or super-draft RFC whose slug/title is that term) — renders,
|
||||
for a viewer with create rights (`permission_state='granted'`), as an
|
||||
inline "+ create RFC" affordance. Clicking opens the propose-RFC modal
|
||||
with the term pre-filled as the title (`ProposeModal` gained an
|
||||
`initialTitle`; the affordance routes via `?propose=<term>`, read in
|
||||
`App.jsx`). The heuristic is deliberately conservative — multi-word is
|
||||
the same false-positive guard the title rule uses, so a single common
|
||||
tag word (`identity`) is never offered. Broader candidate detection
|
||||
(capitalized phrases mined from text, terms repeated across recent PRs,
|
||||
or the #27 Haiku `ANTHROPIC_API_KEY` pathway) is a sanctioned but
|
||||
deferred extension.
|
||||
- **Part 3 — contribute-to-pending offers.** A term matching a *pending*
|
||||
RFC — a super-draft (`state='super-draft'`: accepted as an idea, owned,
|
||||
with a contribution surface, not yet graduated) — renders, for a
|
||||
signed-in non-owner, as an inline "ask to contribute" affordance
|
||||
carrying the owner's display name ("<owner> is working on an RFC for
|
||||
'<term>'"). It opens a contribute-request form (`?contribute=<slug>`)
|
||||
with three fields — **who I am** (required), **why I'm asking**
|
||||
(required), **what I'd use it for** (optional, mirroring #26). Submitting
|
||||
lands a `contribution_requests` row and one actionable §15 inbox
|
||||
notification per owner (new kind `contribution_request_on_pending_rfc`,
|
||||
category `personal-direct` — so it reuses the existing
|
||||
`email_personal_direct` preference, no new toggle). In the inbox the
|
||||
owner sees the requester's who/why/use-case inline with **Accept** /
|
||||
**Decline**. Accept fires #12's owner-invite flow with the requester as
|
||||
the invitee (a `contributor` `rfc_invitations` row + the existing invite
|
||||
email) and echoes a notification back to the requester; Decline closes
|
||||
the request and notifies the requester. Pre-merge idea PRs (not yet in
|
||||
`cached_rfcs`, no contribution surface) are deliberately out of scope —
|
||||
a documented future extension.
|
||||
|
||||
New endpoints (all under the existing `/api` router):
|
||||
`GET /api/rfcs/{slug}/contribution-target`,
|
||||
`POST /api/rfcs/{slug}/contribution-requests`,
|
||||
`POST /api/rfcs/{slug}/contribution-requests/{id}/accept`,
|
||||
`POST /api/rfcs/{slug}/contribution-requests/{id}/decline`.
|
||||
|
||||
The owner-invite issue path was refactored into one reusable chokepoint,
|
||||
`api_invitations.issue_invitation(...)`, shared by the manual invite
|
||||
endpoint and Part 3's accept path so the dup-guard, token mint, insert,
|
||||
and transactional email stay identical.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. Deployments **MUST** apply the auto-run migration `024` (additive: the
|
||||
new `contribution_requests` table; no existing table or row is
|
||||
touched). The standard deploy path runs pending migrations on start —
|
||||
no manual step beyond deploying the new code.
|
||||
2. No config, overlay, or secret change is required. The Part 2 candidate
|
||||
affordance reuses #27's tag taxonomy; it surfaces only when the corpus
|
||||
carries multi-word tags without a defining RFC, and the create
|
||||
affordance renders only for beta-granted viewers. Part 3's email reuse
|
||||
sends through the existing invitation SMTP path — no new key.
|
||||
|
||||
## 0.28.0 — 2026-05-28
|
||||
|
||||
**Minor — security-hardening follow-up (Session-0026 audit, informational
|
||||
findings I3 + I4). No operator action required: no migration, no schema
|
||||
change, no config/overlay change, no API/behavior change for any caller.
|
||||
A plain code deploy picks it up. Shipped from driver session 0032.0.**
|
||||
|
||||
Two informational findings from the Session-0026 audit, both
|
||||
framework-internal defense-in-depth:
|
||||
|
||||
- **I3 — dead HTML-email branch guarded.** `email_envelope.build_envelope`
|
||||
accepted a `body_html=` argument that built a `multipart/alternative`
|
||||
body, but no send path ever passed it — every rfc-app mail is plain
|
||||
text. An unused branch that would emit HTML built from (potentially
|
||||
unescaped) user content is the C1 stored-XSS class waiting in the mail
|
||||
channel. The branch is now a loud guard: passing `body_html` raises
|
||||
`NotImplementedError`. The argument is kept in the signature for
|
||||
documented future symmetry; enabling HTML mail becomes a deliberate
|
||||
change that MUST HTML-escape user content at the call site and remove
|
||||
the guard in the same commit.
|
||||
- **I4 — Turnstile siteverify no longer blocks the event loop.**
|
||||
`turnstile.verify_token` was a synchronous function issuing a blocking
|
||||
`httpx.post` from inside the async `/auth/otc/request` handler, so a
|
||||
slow CloudFlare response stalled the single worker for up to the 10s
|
||||
timeout. It is now `async` and awaits the call on an
|
||||
`httpx.AsyncClient` (matching the codebase's existing async-httpx
|
||||
pattern), isolated behind a narrow `_siteverify_post` seam. The sole
|
||||
caller (`main.py`) now `await`s it.
|
||||
|
||||
Upgrade steps: **none.** Both changes are internal. The `verify_token`
|
||||
signature changed from sync to `async` (callers must `await`), but the
|
||||
only caller is in-tree (`main.py`) and is updated in this release; no
|
||||
deployment-facing surface, config key, or migration is affected.
|
||||
|
||||
## 0.27.0 — 2026-05-28
|
||||
|
||||
**Minor — security-hardening release (Session-0026 audit remediation).
|
||||
One auto-applied migration (023); one behavior change that re-prompts
|
||||
device-trust; deployments MUST re-apply the nginx + systemd files.**
|
||||
This is the work cut as the "v0.25.0 security-hardening" branch; it
|
||||
reversioned to 0.27.0 because v0.26.0 (#28) took the next slot while it
|
||||
was in flight. Shipped from driver session 0030.0.
|
||||
|
||||
- **C1 (Critical) — stored-XSS closed.** Every markdown→HTML sink now
|
||||
routes through one chokepoint, `frontend/src/lib/sanitizeHtml.js`
|
||||
(DOMPurify), before any `innerHTML` / `dangerouslySetInnerHTML` write:
|
||||
`MarkdownPreview`, both `ProposalView` sinks (entry body +
|
||||
`proposed_use_case`), and `Editor`. A hook adds
|
||||
`rel="noopener noreferrer"` to `target=_blank` links. `marked` no
|
||||
longer passes raw HTML / `javascript:` URIs to the DOM, so a
|
||||
contributor can no longer plant a payload that runs in an admin/owner
|
||||
session during review.
|
||||
- **H1 — OTC verify is rate-limited.** New `backend/app/ratelimit.py`
|
||||
(per-IP token buckets) gates `/auth/otc/verify`, `/auth/otc/request`,
|
||||
and the passcode check/verify paths; a per-account OTC-verify lockout
|
||||
(migration `023_otc_verify_lockout.sql`) mirrors the passcode lockout.
|
||||
- **M1 — device-trust lookup no longer table-scans.** The device-trust
|
||||
cookie value is now `"<row_id>.<raw_token>"`; `device_trust.lookup`
|
||||
reads the one indexed row and bcrypt-checks only it, instead of
|
||||
bcrypt-checking every row in the table on each unauthenticated
|
||||
`/auth/device-trust/start`.
|
||||
- **M2 — HTTP security headers** (CSP, HSTS, X-Frame-Options,
|
||||
X-Content-Type-Options, Referrer-Policy) added to the nginx server
|
||||
block. **L8/I1**: `server_tokens off` + legacy TLS1.0/1.1 removed.
|
||||
- **M4 — session cookie `Secure` by default** (`SESSION_COOKIE_SECURE`,
|
||||
defaults on; a dev box on plain http sets it `false`).
|
||||
- **M5 — bounce webhook fails closed.** An unset
|
||||
`WEBHOOK_EMAIL_BOUNCE_SECRET` now **disables** `/api/webhooks/email-bounce`
|
||||
(503) instead of leaving it open; a dev opts back in with
|
||||
`RFC_APP_INSECURE_BOUNCE_WEBHOOK=1`.
|
||||
- **L2/L3** per-IP cooldown + check-endpoint throttle. **L4** systemd
|
||||
sandbox knobs (`CapabilityBoundingSet=`, `ProtectKernel*`,
|
||||
`RestrictAddressFamilies`, `SystemCallFilter`, …).
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. **Migration** — none manual; `023_otc_verify_lockout.sql` auto-applies
|
||||
at startup via `db.run_migrations`.
|
||||
2. **Device trust (MUST expect re-prompt)** — the cookie format changed,
|
||||
so existing "trusted device" cookies no longer match; affected users
|
||||
are re-prompted for device verification once. No data migration; old
|
||||
rows are simply never matched and age out.
|
||||
3. **nginx + systemd (MUST apply out-of-band)** — the deploy gesture does
|
||||
**not** install `deploy/nginx/ohm.wiggleverse.org.conf` or
|
||||
`deploy/systemd/rfc-app.service`. After deploying the code, copy both
|
||||
to their system locations, then `nginx -t && systemctl reload nginx`
|
||||
and `systemctl daemon-reload && systemctl restart <unit>`. (M2 headers
|
||||
and L4 sandboxing do not take effect until this is done.)
|
||||
4. **Bounce webhook (SHOULD)** — bind `WEBHOOK_EMAIL_BOUNCE_SECRET` (or
|
||||
set `RFC_APP_INSECURE_BOUNCE_WEBHOOK=1` for dev). Unset → the endpoint
|
||||
returns 503 (closed). No legitimate bounce source is wired today, so
|
||||
503 is the safe default.
|
||||
5. **Session cookie (SHOULD, dev only)** — a deployment served over plain
|
||||
http MUST set `SESSION_COOKIE_SECURE=false` or the session cookie
|
||||
won't be sent. Production over HTTPS leaves it unset (Secure on).
|
||||
|
||||
## 0.26.0 — 2026-05-28
|
||||
|
||||
**Minor — no schema migration, no new secret, no config, no upgrade
|
||||
steps. Additive read-time enrichment; deployments inherit it on deploy
|
||||
with nothing to set.** Roadmap item #28 **Part 1**: references to existing
|
||||
**accepted** RFCs inside PR descriptions and comment text now render as
|
||||
inline links to the referenced RFC. Shipped from driver session 0029.0,
|
||||
in parallel with the v0.25.0 security-hardening session — hence the
|
||||
version-slot gap (0.25.0 is that session's; this took the next free slot
|
||||
per the roadmap's "claims the next available version number" rule).
|
||||
|
||||
Parts 2 (offer-to-create-RFC for strong-candidate terms) and 3
|
||||
(offer-to-contribute-to-a-pending-RFC) of item #28 are deliberately **not**
|
||||
in this release — Part 1 ships first as the easy win, exactly as the
|
||||
roadmap row anticipates.
|
||||
|
||||
- **Where it links.** The PR review page's description and review
|
||||
comments (`GET /api/rfcs/{slug}/prs/{n}`), and the PR-less per-RFC
|
||||
discussion comments (`GET /api/rfcs/{slug}/discussion/threads/{id}/messages`).
|
||||
Branch-chat (the AI-collaboration surface) is intentionally out of
|
||||
scope for Part 1 — a noted follow-up.
|
||||
- **Read-time, not submit-time.** The roadmap row phrases the scan as
|
||||
happening "at submit/post time"; this ships it as **read-time**
|
||||
enrichment instead. The intent the row actually names — "not as live
|
||||
compose preview" — holds (drafts are never scanned, only submitted
|
||||
content on read). Read-time was chosen because (a) links track the
|
||||
*live* accepted-RFC set — a newly-accepted RFC starts linking in older
|
||||
comments, a withdrawn one stops linking everywhere — rather than
|
||||
freezing stale at submit; (b) it needs **no migration** (no derived
|
||||
data to persist); (c) the active-RFC corpus is small and cache-resident,
|
||||
so per-read scanning is cheap. (Recorded as a §19.3-rule-2 spec note in
|
||||
the session transcript.)
|
||||
- **XSS-safe by construction.** The backend returns structured *segments*
|
||||
(a list of `{type:"text"}` / `{type:"rfc", slug, label, title}` items),
|
||||
never HTML. The new `LinkedText` frontend component maps segments onto
|
||||
React text nodes and anchors — no `dangerouslySetInnerHTML` — so a
|
||||
comment author cannot inject markup through this path. Every enriched
|
||||
field keeps its raw `text`/`description` alongside the `*_segments`, so
|
||||
any non-segment-aware caller is unaffected.
|
||||
- **Conservative matching (false-positive-averse).** A reference links
|
||||
only when it is unlikely to be coincidental: an `rfc_id` token
|
||||
(`RFC-0001`), a **multi-word** title (`Open Human Model`), or a
|
||||
**hyphenated** slug (`open-human-model`). A single common-word title or
|
||||
slug (a hypothetical RFC titled "Human") is **not** auto-linked — that
|
||||
would turn every prose "human" into a link. Matching is case-insensitive,
|
||||
word-boundary-anchored, longest-match-wins, and suppresses an RFC's
|
||||
self-references inside its own PR/discussion. The roadmap's "curated
|
||||
canonical-terms list" remains an explicit future opt-in rather than a
|
||||
guessed-at default. New module: `backend/app/rfc_links.py`.
|
||||
|
||||
Tests: 12 new (`test_rfc_links_vertical.py`) — 9 scanner units (gating
|
||||
rules, word boundaries, longest-match, case-insensitivity, casing
|
||||
preservation, the rfc_id/multi-word/hyphenated gates) plus 3 end-to-end
|
||||
(PR description + review comment + discussion comment all surface
|
||||
`*_segments`; self-reference suppression). Full suite 363 green; frontend
|
||||
builds clean.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. None. This release is purely additive: no migration, no new
|
||||
environment variable, no secret, no overlay. A deployment picks up RFC
|
||||
auto-linking the moment it deploys this version. (RFCs only link once
|
||||
they are in the `active` state — proposed/super-draft and withdrawn
|
||||
RFCs are never link targets, matching the §11.3 universal-public read
|
||||
rule.)
|
||||
|
||||
## 0.24.0 — 2026-05-28
|
||||
|
||||
**Minor — one new secret required before this version serves tag
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@~/.claude/wiggleverse.md
|
||||
|
||||
# Working in rfc-app
|
||||
|
||||
This is the framework — the software that hosts an RFC standardization
|
||||
|
||||
@@ -39,23 +39,44 @@ thread. Every write affordance is replaced with a sign-in prompt.
|
||||
|
||||
## Signing in
|
||||
|
||||
While the framework is in private beta, only invited email addresses
|
||||
can complete sign-in. If your email is on the allowlist, the
|
||||
"Sign in" button in the header completes the flow and lands you on
|
||||
the catalog with full read and write access. If your email is not on
|
||||
the allowlist, you'll be sent to a short "pending" page explaining
|
||||
the gate.
|
||||
Anyone can start the sign-in flow with their own email address — there
|
||||
is no invite-only allowlist. Sign-in is passwordless:
|
||||
|
||||
Once you have an account, you're a **contributor** by default — the
|
||||
role that grants every write affordance the app exposes, scoped by
|
||||
the per-RFC and per-branch rules described below.
|
||||
1. **Enter your email.** If the deployment has human verification
|
||||
enabled (a Cloudflare Turnstile challenge), you complete it here.
|
||||
2. **Enter the one-time code.** The app emails you a short numeric
|
||||
code; entering it signs you in. Codes expire after a few minutes,
|
||||
and repeated wrong entries briefly lock the email.
|
||||
3. **Set a passcode (optional).** After your first code sign-in you
|
||||
can set a passcode. On later visits you sign in with email +
|
||||
passcode, with the one-time code as the forgot-passcode fallback.
|
||||
4. **Trust this device (optional).** You can mark a device trusted for
|
||||
30 days to skip the code/passcode step on it. Trusted devices are
|
||||
listed in your settings and can be revoked individually or all at
|
||||
once.
|
||||
|
||||
### Getting write access
|
||||
|
||||
Signing in gives you an account, but write access is gated. The first
|
||||
time you sign in you're asked for your first name, last name, and a
|
||||
short note on why you'd like access; you then land on a "request in
|
||||
review" page. While your account is **pending**, you can read
|
||||
everything an anonymous visitor can but cannot write — no chat,
|
||||
propose, branch, PR, or discussion post. Once an admin **grants** your
|
||||
account you become a **contributor**, the role that carries every
|
||||
write affordance the app exposes, scoped by the per-RFC and per-branch
|
||||
rules described below.
|
||||
|
||||
An admin can also create your account ahead of time and email you an
|
||||
invite link. Clicking it claims the account and signs you in with the
|
||||
role the admin assigned, skipping the one-time-code step.
|
||||
|
||||
---
|
||||
|
||||
## Proposing a new RFC
|
||||
|
||||
A new RFC begins as a proposal. The "+ Propose new RFC" button at
|
||||
the bottom of the catalog opens a small modal that collects four
|
||||
the bottom of the catalog opens a small modal that collects five
|
||||
things:
|
||||
|
||||
- **Title.** The word, concept, or topic this RFC would define.
|
||||
@@ -65,8 +86,13 @@ things:
|
||||
inline.
|
||||
- **Pitch.** One or two paragraphs answering *why this RFC is
|
||||
needed*. This becomes the body of the entry.
|
||||
- **Tags.** Optional. The AI suggests tags from the pitch; you can
|
||||
accept, dismiss, or type your own.
|
||||
- **Use case.** Optional. *What will you be using this RFC for?* —
|
||||
the concrete application driving the proposal, as distinct from the
|
||||
abstract case for it. Leaving it blank is fine.
|
||||
- **Tags.** Optional. If the deployment has AI tag suggestion
|
||||
enabled, suggested tags appear as you fill the form (with an inline
|
||||
note that the text you've entered is sent to the model that
|
||||
generates them); you can accept, dismiss, or type your own.
|
||||
|
||||
Submitting the modal does one concrete thing: it opens a pull
|
||||
request against the framework's meta repository, adding one new
|
||||
@@ -167,6 +193,51 @@ either requires a contributor account.
|
||||
|
||||
---
|
||||
|
||||
## Invitations, cross-references, and contribution requests
|
||||
|
||||
Three connected surfaces help the right people find and join the
|
||||
right RFC.
|
||||
|
||||
### Owner invitations
|
||||
|
||||
An RFC's owner (or an app-wide admin or owner) can invite a specific
|
||||
person to that RFC from the "Invitations" control in the RFC header.
|
||||
The invite names an email and a role for *this RFC*:
|
||||
|
||||
- **contributor** — can open PRs and join the discussion;
|
||||
- **discussant** — can join the discussion only.
|
||||
|
||||
The invitee gets an email with an accept link; accepting adds them as
|
||||
a collaborator on that RFC. The invitations panel lists every invite
|
||||
with its status (pending / accepted / expired / revoked); pending
|
||||
invites can be revoked. An invitation is per-RFC — it does not change
|
||||
the invitee's app-wide role, and it cannot lift the pending gate: the
|
||||
invitee still needs a granted account to write.
|
||||
|
||||
### RFC cross-links in PRs and comments
|
||||
|
||||
When a PR description or a comment mentions an existing active RFC —
|
||||
by its ID, its multi-word title, or its slug — the framework renders
|
||||
that mention as a link to the RFC. The matching is conservative by
|
||||
design (single common words are never auto-linked), and the links are
|
||||
computed at read time, so nothing is rewritten in what you typed.
|
||||
|
||||
### "Create" and "ask to contribute" offers
|
||||
|
||||
The same scan surfaces two affordances inline:
|
||||
|
||||
- If a term looks like it should have an RFC but none exists yet, a
|
||||
reader who has create rights sees a **"create RFC for '<term>'"**
|
||||
link that opens the propose modal with the title pre-filled.
|
||||
- If a term matches a *pending* RFC (a super-draft someone already
|
||||
owns), a signed-in reader sees an **"ask to contribute"** offer
|
||||
naming the owner. It opens a short request form — who you are, why
|
||||
you're asking, and optionally what you'd use the RFC for. The
|
||||
request lands in the owner's inbox; the owner can **accept** (which
|
||||
sends you an owner invitation) or **decline** (which notifies you).
|
||||
|
||||
---
|
||||
|
||||
## Working on a branch
|
||||
|
||||
Contribute mode flips one branch into edit-enabled. The centre
|
||||
@@ -505,6 +576,14 @@ Each role is a strict superset of the one below it.
|
||||
entirely. The framework names a single "owner zero" at
|
||||
bootstrap.
|
||||
|
||||
Between anonymous and contributor sits one transient state:
|
||||
**pending**. A freshly signed-in account that hasn't been granted
|
||||
access yet (see [Signing in](#signing-in)) reads everything an
|
||||
anonymous visitor can, but no write affordance unlocks until an admin
|
||||
grants it. Granting promotes the account to contributor; an admin can
|
||||
also revoke a granted account back to a no-write state. These
|
||||
transitions are recorded in the `permission_events` log.
|
||||
|
||||
The practical difference between admin and owner is narrow but
|
||||
load-bearing: admin is the operational tier — it does the day-to-
|
||||
day moderation and stewardship work; owner is the tier that
|
||||
@@ -594,6 +673,20 @@ review.
|
||||
|
||||
---
|
||||
|
||||
## Privacy and cookies
|
||||
|
||||
A consent banner appears on your first visit and lets you choose
|
||||
which cookie categories to allow — essential always, with analytics
|
||||
and other categories opt-in. The choice is remembered and can be
|
||||
changed any time from the privacy/cookies controls in settings.
|
||||
|
||||
Analytics only load if you opt in: the framework defers the analytics
|
||||
SDK behind your consent, so declining means it is never initialized.
|
||||
The `/privacy` and `/cookies` pages describe what's collected and
|
||||
why; a deployment can point those pages at its own fuller policy.
|
||||
|
||||
---
|
||||
|
||||
## Where to learn more
|
||||
|
||||
- The framework's *why* lives in [the philosophy
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
# rfc-app container image — the Cloud Run keystone for per-PR preview
|
||||
# environments (flotilla SPEC §15). NOT used by production: prod still deploys
|
||||
# via the pin-based on-VM gesture (flotilla §8). This image exists so flotilla
|
||||
# `preview up --pr=N` can build a PR's tree and run it as an ephemeral,
|
||||
# scale-to-zero Cloud Run service with a SEEDED SYNTHETIC database and ZERO real
|
||||
# secrets (test-secret env only — flotilla §15 / §3 invariant 1).
|
||||
#
|
||||
# Single container, single port: nginx serves the built SPA on $PORT (Cloud Run
|
||||
# injects it) and reverse-proxies /api/ + /auth/ to a single-process uvicorn on
|
||||
# 127.0.0.1:8000 — mirroring the prod nginx + systemd split
|
||||
# (deploy/nginx/ohm.wiggleverse.org.conf, deploy/systemd/rfc-app.service), so a
|
||||
# preview behaves like prod minus the secrets. Single uvicorn process + single
|
||||
# SQLite file, per §4.2 (never scale workers).
|
||||
#
|
||||
# Build context is the repo root: docker build -t <image> .
|
||||
|
||||
# ---- stage 1: build the Vite SPA -------------------------------------------
|
||||
FROM node:20-slim AS web
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
# VITE_* values are baked into the bundle at build time (intentionally public —
|
||||
# §8 phase-5 note). VITE_APP_NAME is REQUIRED by vite.config.js (the framework
|
||||
# ships no default — each deployment names itself); previews self-name. Turnstile
|
||||
# uses Cloudflare's always-pass test SITE key so the widget renders + auto-passes.
|
||||
ARG VITE_APP_NAME="RFC App (preview)"
|
||||
ARG VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
||||
ARG VITE_AMPLITUDE_API_KEY=
|
||||
RUN VITE_APP_NAME="$VITE_APP_NAME" \
|
||||
VITE_TURNSTILE_SITE_KEY="$VITE_TURNSTILE_SITE_KEY" \
|
||||
VITE_AMPLITUDE_API_KEY="$VITE_AMPLITUDE_API_KEY" \
|
||||
npm run build
|
||||
|
||||
# ---- stage 2: runtime (backend + nginx) ------------------------------------
|
||||
FROM python:3.12-slim AS runtime
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nginx gettext-base sqlite3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/rfc-app
|
||||
|
||||
# Backend deps first for layer caching.
|
||||
COPY backend/requirements.txt backend/requirements.txt
|
||||
RUN pip install --no-cache-dir -r backend/requirements.txt
|
||||
|
||||
COPY backend/ backend/
|
||||
COPY deploy/preview/ deploy/preview/
|
||||
# health.py reads VERSION at parents[2] (== /opt/rfc-app/VERSION).
|
||||
COPY VERSION ./
|
||||
COPY --from=web /app/frontend/dist/ frontend/dist/
|
||||
|
||||
RUN chmod +x deploy/preview/entrypoint.sh
|
||||
|
||||
# Cloud Run injects $PORT (default 8080); the entrypoint renders nginx against
|
||||
# it. The synthetic preview DB lives on the container's ephemeral filesystem and
|
||||
# dies with the instance (zero create/seed/drop lifecycle — §15).
|
||||
ENV PORT=8080 \
|
||||
DATABASE_PATH=/opt/rfc-app/backend/data/rfc-app.db
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["deploy/preview/entrypoint.sh"]
|
||||
@@ -0,0 +1,19 @@
|
||||
.PHONY: tier1-up tier1-down tier1-logs fe-unit e2e e2e-install
|
||||
|
||||
tier1-up:
|
||||
docker compose -f testing/docker-compose.yml up --build -d
|
||||
|
||||
tier1-down:
|
||||
docker compose -f testing/docker-compose.yml down -v
|
||||
|
||||
tier1-logs:
|
||||
docker compose -f testing/docker-compose.yml logs -f
|
||||
|
||||
fe-unit:
|
||||
cd frontend && npm run test:run
|
||||
|
||||
e2e-install:
|
||||
cd e2e && npm ci && npx playwright install chromium
|
||||
|
||||
e2e:
|
||||
cd e2e && BASE_URL=$${BASE_URL:-http://localhost:8080} MAILSINK_URL=$${MAILSINK_URL:-http://localhost:8025} npm run e2e
|
||||
@@ -30,13 +30,44 @@ providers (Anthropic, Google, OpenAI / GitHub Copilot).
|
||||
|
||||
## 1. Repository topology
|
||||
|
||||
Each RFC is its own Gitea repository. There is in addition exactly one **meta
|
||||
repository** that serves as the authoritative directory of all RFCs in the
|
||||
system — drafts, active work, and retired entries alike.
|
||||
There is exactly one **meta repository** that holds every RFC in the
|
||||
system as a single markdown entry under `rfcs/` — drafts, active work,
|
||||
and retired entries alike — regardless of state. An RFC is a single
|
||||
canonical document, and its document *is* its meta entry: the body
|
||||
lives in the entry file at every state, from idea through active. There
|
||||
are **no per-RFC repositories**.
|
||||
|
||||
All Git operations across all repositories are performed by a single **bot
|
||||
For a deployment, this single repository is its **content repository**:
|
||||
the one place every RFC document lives (under `rfcs/`), alongside the
|
||||
framework's `PHILOSOPHY.md`, `README.md`, and `CONTRIBUTING.md` (§2). A
|
||||
deployment names it concretely — `<deployment>-content` reads cleanly
|
||||
— and the `META_REPO` setting carries the name. The term *meta
|
||||
repository* persists for the config surface and
|
||||
historical continuity, but under the meta-only topology this repo is,
|
||||
functionally, the deployment's content repo: "one repo, your RFCs are
|
||||
in `rfcs/`" is the whole mental model a new deployer needs.
|
||||
|
||||
> **Topology change (v0.31.0, meta-only — supersedes the original
|
||||
> per-RFC-repo model).** This spec originally said "each RFC is its own
|
||||
> Gitea repository," and graduation created a dedicated `rfc-NNNN-<slug>`
|
||||
> repo and moved the body into its `RFC.md`. That model is **retired**.
|
||||
> The per-RFC-repo machinery never paid for itself under this design:
|
||||
> authorization is decided in app data before a single bot acts (below),
|
||||
> not by per-repo Gitea permissions; raw `git clone`+`push` was never a
|
||||
> supported contribution path; and the super-draft phase already ran
|
||||
> meta-only. So an RFC now lives in its meta entry for its whole life,
|
||||
> and graduation is an in-place state flip rather than a repo-creation
|
||||
> transaction (see §13). Where later sections still say "the RFC's repo"
|
||||
> or "RFC.md on the new repo," read it as "the RFC's meta entry body" —
|
||||
> the editing, branch, PR, and chat machinery is unchanged; only the
|
||||
> location collapses onto the meta repo. The one RFC graduated under the
|
||||
> old model, **RFC-0001 `human`**, was folded back into its meta entry
|
||||
> and its `wiggleverse/rfc-0001-human` repo archived (see §13.6). The
|
||||
> decision record is OHM ROADMAP #36.
|
||||
|
||||
All Git operations on the meta repository are performed by a single **bot
|
||||
service account** in Gitea. Real human users do not have meaningful Gitea
|
||||
permissions on the repos themselves; their accounts exist for OAuth identity
|
||||
permissions on the repo itself; their accounts exist for OAuth identity
|
||||
only. The bot is the author of every commit, the opener of every PR, and the
|
||||
merger of every merge. Authorization decisions are made by the app, in app
|
||||
data, *before* the bot acts on the user's behalf.
|
||||
@@ -67,8 +98,8 @@ The meta repo's `main` branch contains:
|
||||
arriving at the meta-repo via Git rather than via the app. The **index
|
||||
below the header is regenerated by CI on every merge to main** and lists
|
||||
active RFCs, super-drafts, and (eventually) retired entries with links
|
||||
into the corresponding entry files and, when present, the RFC's own
|
||||
repository.
|
||||
into the corresponding entry files. (There is no per-RFC repository to
|
||||
link to under the meta-only topology, §1.)
|
||||
- `CONTRIBUTING.md` — explains how to propose, claim, and contribute.
|
||||
- A workflow file (Gitea Actions) that regenerates the README index.
|
||||
|
||||
@@ -84,7 +115,9 @@ slug: human
|
||||
title: Human
|
||||
state: super-draft # super-draft | active | withdrawn
|
||||
id: null # null until graduated; then "RFC-0042"
|
||||
repo: null # null until graduated; then "wiggleverse/rfc-0042-human"
|
||||
repo: null # always null under the meta-only topology (§1).
|
||||
# Retained for schema stability + historical
|
||||
# entries; never populated by graduation (§13).
|
||||
proposed_by: ben@wiggleverse.org
|
||||
proposed_at: 2026-05-22
|
||||
graduated_at: null
|
||||
@@ -103,8 +136,9 @@ tags: [identity, schema]
|
||||
## Why this RFC is needed
|
||||
|
||||
(One- or two-paragraph pitch from the proposer. While the entry is a
|
||||
super-draft, the body may grow into the actual draft document. On
|
||||
graduation, this body migrates to RFC.md in the new repo; see §13.)
|
||||
super-draft, the body grows into the actual draft document. The body
|
||||
stays in this entry at every state — graduation is an in-place state
|
||||
flip and does not move it (§13).)
|
||||
```
|
||||
|
||||
### 2.2 Idea submission as PR
|
||||
@@ -126,16 +160,31 @@ an idea costs nothing in identifier space.
|
||||
|
||||
## 3. RFC states
|
||||
|
||||
There are three canonical states stored in entry frontmatter:
|
||||
There are four canonical states stored in entry frontmatter:
|
||||
|
||||
- **`super-draft`** — the entry exists in the meta repo's `rfcs/`
|
||||
directory. No dedicated repo yet. Anyone signed in can chat on it; anyone
|
||||
can claim ownership; an owner is required before graduation.
|
||||
- **`active`** — the entry has been graduated. A dedicated RFC repo
|
||||
exists, `repo:` points to it, and real branches/PRs/conversation happen
|
||||
there.
|
||||
directory. Anyone signed in can chat on it; anyone can claim ownership;
|
||||
an owner is required before graduation.
|
||||
- **`active`** — the entry has been graduated: it carries
|
||||
`graduated_at`/`graduated_by` and, **optionally**, an integer `id`
|
||||
(`RFC-NNNN`). The integer id is not required — graduation may proceed
|
||||
with no number, in which case `id` stays null and the **slug remains
|
||||
the canonical identifier** (§2.3, §13.2). It lives in the same
|
||||
`rfcs/<slug>.md` entry it always did — graduation is an in-place state
|
||||
flip (§13), not a move. Branches, PRs, and conversation happen on the
|
||||
meta repo against that entry, exactly as they did while it was a
|
||||
super-draft. `repo:` stays null (§1).
|
||||
- **`withdrawn`** — pulled before becoming canonical. Stays in the
|
||||
directory as historical record, hidden from default views, filterable in.
|
||||
- **`retired`** — soft-deleted. The entry stays in the `rfcs/` directory
|
||||
as historical record (git is truth — nothing is hard-deleted), but it
|
||||
is **removed from every UI surface**: it does not appear in the
|
||||
catalog, is not addressable through the ordinary RFC view, and is
|
||||
excluded from link pickers and search. Unlike `withdrawn` (which is
|
||||
merely hidden-by-default and filterable back in), `retired` is not
|
||||
surfaced anywhere a contributor browses. Retiring is for entries an
|
||||
owner wants gone from the working set; it is reversible only by a site
|
||||
owner (§13.7).
|
||||
|
||||
A fourth concept — **`idea`** — is not stored in frontmatter. It is the
|
||||
*derived view* of "there is an open PR against the meta repo proposing
|
||||
@@ -155,28 +204,47 @@ super-draft ──[graduate, owner or admin]─────▶ active
|
||||
super-draft ──[withdraw, proposer or owner/admin]──▶ withdrawn
|
||||
active ──[withdraw, owner/admin]─────────────▶ withdrawn
|
||||
withdrawn ──[reopen, owner/admin]────────────▶ super-draft
|
||||
super-draft ──[retire, RFC owner or site owner]──▶ retired
|
||||
active ──[retire, RFC owner or site owner]───▶ retired
|
||||
retired ──[un-retire, site owner]────────────▶ prior state
|
||||
```
|
||||
|
||||
Every transition is a commit to the meta repo. State history is auditable
|
||||
through `git log rfcs/<slug>.md`. The app maintains a separate audit log
|
||||
for "who clicked the button" (see §6.5).
|
||||
|
||||
**Who may retire.** Retire (§13.7) is deliberately *narrower* than
|
||||
withdraw: it is available to the RFC's own `owners` (frontmatter) and to
|
||||
holders of the site **`owner`** role only — **not** to app admins. It is
|
||||
the one destructive-feeling action in the lifecycle (the entry leaves the
|
||||
working set entirely), so the authority to perform it is held closer than
|
||||
withdraw's owner/admin set. Un-retire — bringing a retired entry back to
|
||||
the state it held before — is held tighter still: **site owners only**.
|
||||
An RFC owner can retire their own entry but cannot bring it back; that
|
||||
asymmetry is intentional, so a soft-delete is always recoverable by the
|
||||
site's operator. The entry file is never removed from `rfcs/`, so a
|
||||
retired entry remains recoverable by editing frontmatter directly even if
|
||||
the app surfaces were lost.
|
||||
|
||||
### 3.2 State change side-effects
|
||||
|
||||
For now, changing state in the meta repo entry is the *only* required
|
||||
operation for a state transition. Graduation has additional side effects
|
||||
(creating the new repo, seeding it); those are covered in §13. We
|
||||
deliberately do not tag commits, lock branches, or post notices on
|
||||
state change for now — the entry frontmatter is the single source of
|
||||
truth and any further automation is a later refinement.
|
||||
Changing state in the meta repo entry is the *only* required operation
|
||||
for a state transition — graduation included. Graduation additionally
|
||||
assigns the integer `id` and stamps `graduated_at`/`graduated_by` in the
|
||||
same commit (§13); it has no other side effects under the meta-only
|
||||
topology (no repo to create, nothing to seed). We deliberately do not
|
||||
tag commits, lock branches, or post notices on state change for now —
|
||||
the entry frontmatter is the single source of truth and any further
|
||||
automation is a later refinement.
|
||||
|
||||
---
|
||||
|
||||
## 4. Storage architecture: Git is truth, app keeps a cache
|
||||
|
||||
Gitea remains the source of truth for everything Git-shaped: meta repo
|
||||
content, RFC repo content, branches, PRs, commits. Nothing in this system
|
||||
overrides Gitea on those concerns.
|
||||
content, branches, PRs, commits — all of which live on the single meta
|
||||
repo under the meta-only topology (§1). Nothing in this system overrides
|
||||
Gitea on those concerns.
|
||||
|
||||
The app maintains a **SQLite database**, colocated with the FastAPI
|
||||
process, that serves three purposes:
|
||||
@@ -187,10 +255,11 @@ process, that serves three purposes:
|
||||
assignments, per-branch grants, branch visibility settings, chat
|
||||
history, audit logs. This data is canonical; it is not cached, it
|
||||
is owned by the app.
|
||||
3. **Cached bodies** — the main-branch body of each RFC's `RFC.md` (and
|
||||
each super-draft's entry body) is cached for left-pane previews and
|
||||
read-without-roundtrip. Branch bodies are *not* cached; the editor
|
||||
fetches them live from Gitea when opened.
|
||||
3. **Cached bodies** — the main-branch body of each RFC, read from its
|
||||
`rfcs/<slug>.md` meta entry (the same source for super-drafts and
|
||||
active RFCs alike under the meta-only topology), is cached for
|
||||
left-pane previews and read-without-roundtrip. Branch bodies are
|
||||
*not* cached; the editor fetches them live from Gitea when opened.
|
||||
|
||||
### 4.1 Cache freshness
|
||||
|
||||
@@ -201,7 +270,7 @@ Two paths keep the cache current, running in parallel:
|
||||
A webhook handler does a focused re-read of just what changed.
|
||||
Typical latency: sub-second.
|
||||
- **A periodic reconciler** runs every five minutes and does a full
|
||||
sweep — list meta-repo entries, list each RFC repo's branches and
|
||||
sweep — list meta-repo entries, list the meta repo's branches and
|
||||
PRs, diff against the cache, fix drift. This is the safety net for
|
||||
missed webhooks and downtime.
|
||||
|
||||
@@ -1607,42 +1676,43 @@ contributor — identical to an active RFC's main chat per §11.4 plus
|
||||
|
||||
### 9.8 Graduation handoff additions
|
||||
|
||||
§13's graduation sequence was written before this section's
|
||||
machinery existed. The mechanics §13 needs to absorb fold inline
|
||||
into §13.2 and §13.4 in their respective sections; the substantive
|
||||
additions are captured here for cross-reference:
|
||||
> **Meta-only update (v0.31.0).** This section originally reconciled
|
||||
> §13's per-RFC-repo graduation transaction with the §9-era editing
|
||||
> machinery. Under the meta-only topology (§1, §13) the entry never
|
||||
> moves and its body is never stripped, so the frictions this section
|
||||
> existed to manage **dissolve**. The bullets are retained, struck
|
||||
> through, for the audit trail of what the per-repo model required.
|
||||
|
||||
- **Open body-edit PRs block graduation.** §13.3's step 3 removes
|
||||
Under meta-only, graduation is a single frontmatter-flipping commit
|
||||
to `rfcs/<slug>.md` (§13.3). Nothing else changes: the body stays in
|
||||
the entry; branches, edit-PRs, threads, flags, and `changes` rows all
|
||||
remain exactly where they were, keyed by the slug per §2.3, and keep
|
||||
working against the same meta entry after the flip as before it. There
|
||||
is no entry move, no body strip, no per-repo seed, and therefore no
|
||||
"handoff" to coordinate.
|
||||
|
||||
- ~~**Open body-edit PRs block graduation.** §13.3's step 3 removes
|
||||
the meta-repo entry's body field, and an open body-edit PR
|
||||
post-graduation would attempt to re-introduce a body to a
|
||||
frontmatter-only entry. The Graduate dialog disables the confirm
|
||||
button if any meta-repo PR is open against `rfcs/<slug>.md`. The
|
||||
precondition is enforced before the bot starts §13.3's sequence,
|
||||
so §13.3's rollback complexity does not grow.
|
||||
- **Bare edit branches survive graduation.** Edit branches without
|
||||
an open PR are not blocked. They remain on the meta repo subject
|
||||
to §12's hygiene timers. The contributor can re-cut against the
|
||||
new RFC repo's main if they still want the work. The branch chat
|
||||
persists per §8.4 as historical record even after auto-close, so
|
||||
the argument that produced the work is preserved regardless of
|
||||
whether the work itself merges.
|
||||
- **Chat migration includes range and paragraph sub-threads.**
|
||||
§13.4's chat-follows-the-work rule covers the whole-doc main
|
||||
thread; it extends to range and paragraph sub-threads on the
|
||||
super-draft's main view, which migrate as part of the same
|
||||
movement. Anchors re-resolve against `RFC.md` on the new repo;
|
||||
since §13.3's step 2 seeds `RFC.md` from the super-draft body
|
||||
verbatim, anchors typically locate the same content. Where they
|
||||
do not, §8.12's stale mechanic engages.
|
||||
- **Pre-graduation history surfaces from the new RFC view.**
|
||||
Meta-repo edit-branch chats, flag threads, and `changes` rows
|
||||
stay attached to their original `branch_name` on the meta repo;
|
||||
they do not migrate. A **"Pre-graduation history"** affordance on
|
||||
the new RFC view surfaces these — the slug remains the canonical
|
||||
key per §2.3, so the query is a straightforward lookup of
|
||||
`threads` and `changes` rows where `rfc_slug = <slug>` and
|
||||
`branch_name` begins with `edit/<slug>/`. UI affordance; no data
|
||||
movement, no rollback cost.
|
||||
frontmatter-only entry.~~ **No longer applies** — the body is kept,
|
||||
so an open body-edit PR coexists with graduation. Graduation touches
|
||||
only the frontmatter; a body-edit PR that merges after graduation
|
||||
edits the same entry's body just as it would have before. The
|
||||
Graduate dialog no longer gates on open body-edit PRs (§13.2).
|
||||
- ~~**Bare edit branches survive graduation.**~~ Trivially true now —
|
||||
no repo boundary is crossed, so every edit branch simply remains a
|
||||
meta-repo branch on the same slug, subject to §12's hygiene timers,
|
||||
with no "re-cut against the new repo" step.
|
||||
- ~~**Chat migration includes range and paragraph sub-threads.**~~ No
|
||||
migration occurs: whole-doc, range, and paragraph threads stay on
|
||||
their `(rfc_slug, branch_name)` rows. Their anchors resolve against
|
||||
the same entry body, which did not move, so §8.12's stale mechanic is
|
||||
not provoked by graduation.
|
||||
- ~~**Pre-graduation history surfaces from the new RFC view.**~~ There
|
||||
is no "new RFC view" distinct from the entry's own view, so there is
|
||||
no pre-graduation hop to bridge. Edit-branch threads, flags, and
|
||||
`changes` rows surface on the active RFC the same way they did on the
|
||||
super-draft — same slug, same surface.
|
||||
|
||||
---
|
||||
|
||||
@@ -1953,16 +2023,23 @@ email request to an owner.
|
||||
|
||||
---
|
||||
|
||||
## 13. The graduation flow (super-draft → active RFC repo)
|
||||
## 13. The graduation flow (super-draft → active, in place)
|
||||
|
||||
Graduation is initiated by an owner or admin clicking "Graduate to RFC
|
||||
repo" on a super-draft's page. The button is disabled with a tooltip
|
||||
when the super-draft has no owners (see §13.1) or when any meta-repo
|
||||
body-edit PR is open against `rfcs/<slug>.md` (see §9.8 — open
|
||||
body-edit PRs would attempt to re-introduce a body to a frontmatter-
|
||||
only entry after step 3 of §13.3). Bare edit branches without an open
|
||||
PR do not block graduation; they remain on the meta repo subject to
|
||||
§12's hygiene timers.
|
||||
Graduation is initiated by an owner or admin clicking "Graduate" on a
|
||||
super-draft's page. The button is disabled with a tooltip when the
|
||||
super-draft has no owners (see §13.1). Open meta-repo body-edit PRs no
|
||||
longer block graduation: under the meta-only topology (§1) the body is
|
||||
kept in the entry, so graduation touches only frontmatter and coexists
|
||||
with body edits (see §9.8). Bare edit branches are likewise unaffected;
|
||||
they remain on the meta repo subject to §12's hygiene timers.
|
||||
|
||||
> **Meta-only rewrite (v0.31.0).** §13 originally described a
|
||||
> transactional create-repo-seed-flip sequence (`super-draft → active
|
||||
> RFC repo`) with rollback. That is **retired** (§1). Graduation is now
|
||||
> a single in-place state flip on the entry: no repo is created, the
|
||||
> body is not moved or stripped, and there is nothing to roll back. The
|
||||
> subsections below are rewritten to the new model; §13.3 records what
|
||||
> the old transaction did, struck through, for the audit trail.
|
||||
|
||||
### 13.1 Claim ownership (prerequisite)
|
||||
|
||||
@@ -1983,137 +2060,162 @@ broadening rather than a precondition for the proposer's own RFC.)
|
||||
|
||||
### 13.2 The Graduate dialog
|
||||
|
||||
Clicking "Graduate to RFC repo" opens a small dialog with three
|
||||
editable fields:
|
||||
Clicking "Graduate" opens a small dialog with two editable fields:
|
||||
|
||||
- **Integer ID** — pre-filled as `max(existing integer IDs) + 1`,
|
||||
formatted as `RFC-NNNN`. Editable to allow gap reservations but the
|
||||
default is just the next number.
|
||||
- **Repo name** — pre-filled as `rfc-NNNN-<slug>`, editable but
|
||||
constrained to valid Gitea repo names.
|
||||
- **Integer ID** — **optional.** Pre-filled as `max(existing integer
|
||||
IDs) + 1`, formatted as `RFC-NNNN`, purely as a *suggestion* the
|
||||
graduating owner may keep, change (to reserve gaps), or **clear
|
||||
entirely**. Leaving it blank graduates the entry to `active` with no
|
||||
number — `id` stays null and the slug remains the canonical identifier
|
||||
(§2.3). The field's helper text states this: blank means "graduate
|
||||
without a number." A blank id is the default-accepted case, not an
|
||||
error.
|
||||
- **Initial owners** — pre-filled from the entry's `owners:`, with an
|
||||
"add owner" picker. Must have at least one.
|
||||
|
||||
(The old **Repo name** field is gone — there is no repo to name under
|
||||
the meta-only topology.)
|
||||
|
||||
Each field validates inline as the admin types, with a short
|
||||
debounce, against the catalog cache and a regex — integer-ID
|
||||
collision against existing IDs, repo-name pattern against valid
|
||||
Gitea name rules, the at-least-one-owner constraint on the picker.
|
||||
Errors render as a short line of text beneath the offending field.
|
||||
The repo-name collision check is re-issued atomically server-side
|
||||
on confirm, since a concurrent graduation could land between
|
||||
dialog-open and submit. While any field is invalid, the confirm
|
||||
button is disabled and its tooltip names the first blocker
|
||||
specifically — "Integer ID 42 is already taken," "Repo name must be
|
||||
lowercase letters, digits, and dashes," "Add at least one initial
|
||||
owner" — the same grammar the precondition popover below uses, so
|
||||
the dialog and the gate read as one surface rather than two
|
||||
competing styles.
|
||||
debounce, against the catalog cache and a regex. The integer-ID field
|
||||
is valid when it is **blank** (graduate without a number) *or* matches
|
||||
`^RFC-\d{4,}$` and is not already taken by another entry; the owners
|
||||
picker enforces the at-least-one constraint. Errors render as a short
|
||||
line of text beneath the offending field. The integer-ID collision
|
||||
check is re-issued atomically server-side on confirm, since a
|
||||
concurrent graduation could land between dialog-open and submit (only
|
||||
relevant when a number was supplied — a blank id can never collide).
|
||||
While any field is invalid, the confirm button is disabled and its
|
||||
tooltip names the first blocker specifically — "Integer ID 42 is
|
||||
already taken," "Add at least one initial owner" — the same grammar the
|
||||
precondition popover below uses, so the dialog and the gate read as one
|
||||
surface rather than two competing styles. A missing number is **never**
|
||||
a blocker.
|
||||
|
||||
The dialog's confirm button is also disabled when the preconditions
|
||||
from §13's opening paragraph fail — no owners on the entry, or any
|
||||
open meta-repo PR against `rfcs/<slug>.md`. The disabled button
|
||||
opens a small popover on hover or click that lists each failing
|
||||
precondition as its own line item with an inline remediation
|
||||
affordance per item. "No owners claimed yet" surfaces a "Copy share
|
||||
link" affordance for surfacing the super-draft to a would-be
|
||||
claimer, plus a secondary "Claim ownership yourself" — admins are
|
||||
contributors per §6.1, so they can claim if they intend to graduate
|
||||
solo. "N open body-edit PRs" expands inline within the popover to a
|
||||
list of the offending PRs, one per row, carrying each PR's title,
|
||||
author, and last-activity timestamp plus inline merge, withdraw,
|
||||
and open-in-new-tab affordances; admins hold §6.3 authority on
|
||||
those PRs and can resolve the precondition from the popover without
|
||||
leaving the Graduate context.
|
||||
The dialog's confirm button is also disabled when the entry has no
|
||||
owners. The disabled button opens a small popover on hover or click
|
||||
listing the failing precondition with an inline remediation
|
||||
affordance: "No owners claimed yet" surfaces a "Copy share link"
|
||||
affordance for surfacing the super-draft to a would-be claimer, plus
|
||||
a secondary "Claim ownership yourself" — admins are contributors per
|
||||
§6.1, so they can claim if they intend to graduate solo. Open
|
||||
body-edit PRs are **not** a precondition anymore (§9.8): the body is
|
||||
kept, so they coexist with graduation.
|
||||
|
||||
The preconditions are enforced before the bot starts §13.3's
|
||||
sequence, so §13.3's rollback complexity is unchanged.
|
||||
### 13.3 The flip
|
||||
|
||||
### 13.3 The transactional sequence
|
||||
Confirming the dialog runs a single operation as the bot: open a PR
|
||||
against the meta repo that re-serializes `rfcs/<slug>.md` with
|
||||
`state: active`, `id: RFC-NNNN` **or `id: null` when no number was
|
||||
supplied**, `graduated_at: <timestamp>`, `graduated_by: <admin
|
||||
username>`, and the `owners:` from the dialog — **leaving the body
|
||||
unchanged** — then auto-merge it (the admin who clicked is the merge
|
||||
actor). The webhook flow updates the SQLite cache and the catalog row
|
||||
transitions per §7.2. When `id` is null, the catalog and RFC view
|
||||
identify the entry by its slug (no "RFC-NNNN" chip is rendered).
|
||||
|
||||
Confirming the dialog runs this sequence as the bot:
|
||||
```
|
||||
super-draft entry ──[graduate]──▶ same entry, state: active, id assigned OR null
|
||||
(body unchanged, repo: null, lives in rfcs/<slug>.md throughout)
|
||||
```
|
||||
|
||||
1. Create the new Gitea repo.
|
||||
2. Seed it with an initial commit on `main` containing:
|
||||
- `README.md` (header pointing at the meta-repo entry, plus the
|
||||
super-draft's pitch body migrated over).
|
||||
- `RFC.md` (the actual document, starting from the super-draft body
|
||||
or a template if the body is empty).
|
||||
- `.rfc/metadata.yaml` — mirror of the meta-repo frontmatter for
|
||||
future tooling.
|
||||
3. Open a PR against the meta repo updating the entry: `state: active`,
|
||||
`id: RFC-NNNN`, `repo: <new repo URL>`, `graduated_at: <timestamp>`,
|
||||
`graduated_by: <admin username>`. The meta-repo entry's body field
|
||||
is removed (frontmatter only, plus a generated "see the full RFC at
|
||||
<repo>" link).
|
||||
4. Auto-merge the PR (the same admin who clicked the button is the
|
||||
merge actor).
|
||||
5. Webhook flow updates the SQLite cache; left pane reflects the new
|
||||
state immediately.
|
||||
There is no repo to create, nothing to seed, and the body is neither
|
||||
moved nor stripped, so there is **no multi-step transaction and no
|
||||
rollback**. If opening or merging the flip PR fails, the entry simply
|
||||
stays a super-draft and the admin sees the error — nothing partial was
|
||||
created that needs cleaning up. The dialog reports a single in-flight
|
||||
"Graduating…" state that resolves to success (the PR merged) or a
|
||||
plain error (the PR could not be opened or merged), rather than the
|
||||
old five-step stack. On success, a brief "Graduation complete" frame
|
||||
holds for a moment before the dialog closes.
|
||||
|
||||
The dialog renders the sequence in flight as a stack of the five
|
||||
named steps with per-step states — `pending`, `running`, `done`,
|
||||
`failed`, `not reached` — and a one-line caption beneath the current
|
||||
step naming the concrete operation ("Creating repository
|
||||
wiggleverse/rfc-0042-human…"). The stack streams from
|
||||
the server via the SSE surface in §17, one event per step
|
||||
transition. On success, a brief "Graduation complete" frame holds
|
||||
for a moment before the dialog closes and the catalog row
|
||||
transitions per §7.2.
|
||||
> ~~**The old transactional sequence (per-RFC-repo model, retired).**
|
||||
> Confirming created a new Gitea repo; seeded it with `README.md`,
|
||||
> `RFC.md` (body migrated), and `.rfc/metadata.yaml`; opened a meta-repo
|
||||
> PR flipping `state`/`id`/`repo`/`graduated_*` **and stripping the
|
||||
> entry body** to frontmatter-only with a "see the full RFC at <repo>"
|
||||
> link; auto-merged it; refreshed the cache. A five-step SSE stack
|
||||
> rendered progress, and any mid-sequence failure rolled back (delete
|
||||
> the half-created repo, abandon the PR). All of that machinery is
|
||||
> removed — the body strip was the only reason most of it existed.~~
|
||||
|
||||
If any step fails partway, the app rolls back: deletes the
|
||||
half-created repo, abandons the unmerged PR, surfaces a clear error
|
||||
to the admin. The rollback is itself a visible step appended to the
|
||||
stack on failure — the admin sees that cleanup ran, not just that
|
||||
the act failed. The failed step turns red, later original-sequence
|
||||
steps mark "not reached," and a "What happened" panel renders below
|
||||
the stack explaining what was rolled back, what wasn't (if anything
|
||||
is unrecoverable), and what to do next. The panel persists until the
|
||||
admin dismisses it — a failure surface is not auto-dismissed.
|
||||
Graduation is rare enough to afford this level of care.
|
||||
### 13.4 Chat, branches, and history stay put
|
||||
|
||||
### 13.4 Chat history follows the work
|
||||
Nothing moves at graduation. The whole-doc main thread (§8.4), range
|
||||
and paragraph sub-threads (§8.12), edit-branch chats, flag threads,
|
||||
and `changes` rows all remain on their existing `(rfc_slug,
|
||||
branch_name)` rows — the slug is the canonical key per §2.3 and does
|
||||
not change. Their anchors resolve against the same entry body, which
|
||||
did not move, so §8.12's stale mechanic is not provoked by graduation.
|
||||
|
||||
The chat thread attached to the super-draft moves to the new repo's
|
||||
main-branch chat at graduation. This covers both the whole-doc main
|
||||
thread per §8.4 and any range or paragraph sub-threads per §8.12
|
||||
anchored to the super-draft's main view; anchors re-resolve against
|
||||
`RFC.md` on the new repo and, where they fail, §8.12's stale
|
||||
mechanic engages. The meta-repo entry retains a generated link
|
||||
"Conversation continues at <repo URL>." The chat is about the RFC,
|
||||
not the meta-repo entry, and it should travel with the work.
|
||||
Because there is no repo boundary to cross, there is no
|
||||
"pre-graduation history" hop: the active RFC's view is the same view
|
||||
the super-draft had, listing the same `main`, open branches, and open
|
||||
PRs in the §8.1 breadcrumb dropdown. Edit branches that closed during
|
||||
the super-draft phase surface through the ordinary "Show closed
|
||||
branches" filter — there is no separate "lived on the meta repo before
|
||||
the repo existed" set to distinguish, because the repo never existed.
|
||||
|
||||
Meta-repo edit-branch chats, flag threads, and `changes` rows from
|
||||
the super-draft phase **do not migrate**. They stay attached to
|
||||
their original `branch_name` on the meta repo and surface from the
|
||||
new RFC view via a **"Pre-graduation history"** affordance — a
|
||||
straightforward lookup of `threads` and `changes` rows where
|
||||
`rfc_slug = <slug>` and `branch_name` begins with `edit/<slug>/`
|
||||
(the slug remains the canonical key per §2.3, before and after
|
||||
graduation). UI affordance; no data movement, no rollback cost.
|
||||
### 13.5 Reversing graduation
|
||||
|
||||
The affordance renders as a section in the §8.1 breadcrumb dropdown
|
||||
on the new RFC view, alongside `main`, open branches, and open PRs,
|
||||
headed "Pre-graduation history (N)" with each pre-graduation edit
|
||||
branch listed as its own row. Selecting a row swaps the center
|
||||
column to a read-only render of that branch's body at its last
|
||||
commit and the right column to that branch's chat, with associated
|
||||
change-cards and flags inline — the same machinery a closed branch
|
||||
on an active RFC uses per §10.7 and §11.5. Anchors on pre-graduation
|
||||
threads resolve against the pre-graduation body, not against
|
||||
`RFC.md` on the new repo. The pre-graduation set is kept distinct
|
||||
from the post-graduation "Show closed branches" filter in the same
|
||||
dropdown — "branches that closed normally on this repo" and
|
||||
"branches that lived on the meta repo before this repo existed" are
|
||||
semantically different sets, and conflating them would obscure the
|
||||
graduation hop.
|
||||
The canonical forward path from `active` is still `withdrawn` (§3.1),
|
||||
and `withdrawn → super-draft` reopens an entry. Under the meta-only
|
||||
topology, reversing a graduation is no longer operationally messy —
|
||||
there are no repo commits to orphan, only a frontmatter flip — but the
|
||||
state graph in §3.1 remains the authority: `active → withdrawn →
|
||||
super-draft` is the supported route, and the integer `id`, once
|
||||
assigned, is not reclaimed (gap-free allocation per §2.3 tolerates
|
||||
gaps from withdrawals). A direct `active → super-draft` un-graduate is
|
||||
not exposed in v1; withdraw-and-reopen covers the need.
|
||||
|
||||
### 13.5 Graduation is not reversible
|
||||
### 13.6 RFC-0001 fold-back (migration record)
|
||||
|
||||
Once an entry is graduated to `active`, the path forward is
|
||||
`withdrawn`, not back to `super-draft`. Reversing graduation cleanly
|
||||
is operationally messy (existing commits in the new repo, etc.) and
|
||||
the cost of not having it is low — withdraw and re-graduate as a
|
||||
fresh idea if needed.
|
||||
RFC-0001 `human` was graduated under the original per-RFC-repo model
|
||||
(2026-05-26) into `wiggleverse/rfc-0001-human`, with its body in that
|
||||
repo's `RFC.md` and its meta entry stripped to frontmatter. When the
|
||||
meta-only topology landed (v0.31.0, OHM ROADMAP #36, driver session
|
||||
0041.0), RFC-0001 was folded back to the single model: the full
|
||||
`RFC.md` body was restored into `rfcs/human.md` in the meta repo
|
||||
(`repo:` set null, `state: active` and `id: RFC-0001` retained), and
|
||||
`wiggleverse/rfc-0001-human` was archived with a `README` pointing at
|
||||
the canonical home in the app. RFC-0001 is therefore an ordinary
|
||||
meta-only active RFC like any other; no grandfathered per-repo path
|
||||
remains in the code.
|
||||
|
||||
### 13.7 Retire (soft delete)
|
||||
|
||||
Retire takes an entry out of the working set without destroying it. It
|
||||
is the same shape as the other lifecycle transitions — an in-place
|
||||
frontmatter flip on the meta entry, committed via an auto-merged bot PR
|
||||
(§13.3's machinery, reused) — but with two distinguishing rules:
|
||||
|
||||
- **Authority (§3.1).** Only the RFC's own `owners` (frontmatter) and
|
||||
holders of the site `owner` role may retire. App admins may *not*
|
||||
(this is the one lifecycle action where admin authority does not
|
||||
apply). Un-retire is **site owners only**: an RFC owner can retire but
|
||||
cannot reverse it, so every soft-delete remains recoverable by the
|
||||
operator.
|
||||
- **Visibility.** A `retired` entry is removed from **every** browsing
|
||||
surface — the catalog (`GET /api/rfcs`), the ordinary RFC view (`GET
|
||||
/api/rfcs/<slug>` returns 404 for everyone except a site owner, so the
|
||||
un-retire affordance has somewhere to live), link pickers, and search.
|
||||
This is stronger than `withdrawn`, which is merely hidden-by-default
|
||||
and filterable back in.
|
||||
|
||||
The flip sets `state: retired` and leaves every other field — including
|
||||
the integer `id`, if one was assigned — untouched, so an un-retire
|
||||
restores the entry exactly as it was. Retire is allowed from
|
||||
`super-draft` or `active`; the prior state is recorded in the audit log
|
||||
(`retire` action) so un-retire (`unretire` action) can restore it.
|
||||
Because the entry file is never removed from `rfcs/`, the meta repo
|
||||
remains the source of truth and the reconciler reproduces the
|
||||
`retired` state on every sweep.
|
||||
|
||||
A site owner finds retired entries through an admin surface (a
|
||||
"Retired" list, owner-gated) and un-retires from there; the entry then
|
||||
reappears in the catalog under its restored state. The integer `id`,
|
||||
once assigned, is never reclaimed or reissued by retire/un-retire
|
||||
(gap-tolerant allocation per §2.3).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+11
-3
@@ -9,10 +9,18 @@ GITEA_URL=http://localhost:3000
|
||||
GITEA_BOT_USER=rfc-bot
|
||||
GITEA_BOT_TOKEN=
|
||||
|
||||
# The Gitea org or user that owns the meta repo and every RFC repo
|
||||
# the bot will create on graduation.
|
||||
# The Gitea org or user that owns every RFC repo the bot will create on
|
||||
# graduation.
|
||||
GITEA_ORG=wiggleverse
|
||||
META_REPO=meta
|
||||
|
||||
# §22.2 — the project registry repo (REQUIRED). The framework reads
|
||||
# `projects.yaml` at its root to learn which projects exist. The repo name is
|
||||
# the deployment's choice; the app fails to start if this is unset.
|
||||
REGISTRY_REPO=registry
|
||||
# §22.13 — optional id for the bootstrap/default project. Reserved for the
|
||||
# Plan B re-stamp; leave unset in Plan A (the default project id stays
|
||||
# `default`). When set, it must match an `id` in projects.yaml.
|
||||
# DEFAULT_PROJECT_ID=ohm
|
||||
|
||||
# --- OAuth (Gitea) ---
|
||||
# In Gitea: Site Administration → Applications → Add OAuth2 Application.
|
||||
|
||||
+243
-26
@@ -21,12 +21,15 @@ from pydantic import BaseModel, Field
|
||||
from . import (
|
||||
api_admin,
|
||||
api_branches,
|
||||
api_contributions,
|
||||
api_deployment,
|
||||
api_discussion,
|
||||
api_graduation,
|
||||
api_invitations,
|
||||
api_notifications,
|
||||
api_prs,
|
||||
auth,
|
||||
projects as projects_mod,
|
||||
db,
|
||||
device_trust as device_trust_mod,
|
||||
docs as docs_mod,
|
||||
@@ -141,6 +144,13 @@ def make_router(
|
||||
# invited users keep read access but cannot write (v0.6.0
|
||||
# contract extended to per-RFC scope).
|
||||
router.include_router(api_invitations.make_router())
|
||||
# v0.29.0 (roadmap item #28 Part 3): offer-to-contribute-to-a-pending
|
||||
# (super-draft) RFC. Reuses the #12 invite flow (api_invitations above)
|
||||
# on accept; lands the request + owner notifications via §15 notify.
|
||||
router.include_router(api_contributions.make_router())
|
||||
# §22.9/§22.10 (M3): runtime deployment + per-project config (replaces
|
||||
# VITE_APP_NAME) + the old-URL 308 redirects.
|
||||
router.include_router(api_deployment.make_router(config))
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §17: /api/health — unauthenticated post-flight probe.
|
||||
@@ -602,25 +612,40 @@ def make_router(
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs")
|
||||
async def list_rfcs(request: Request) -> dict[str, Any]:
|
||||
async def list_rfcs(request: Request, unreviewed: str | None = None) -> dict[str, Any]:
|
||||
"""§7's left pane data.
|
||||
|
||||
The chip-filter / sort / search combinatorics live on the
|
||||
client — the server returns the full set and lets the chips
|
||||
narrow it. The set is small (hundreds, not thousands) for the
|
||||
foreseeable future, so paginating here would buy nothing.
|
||||
|
||||
§22.4c: pass `?unreviewed=true` to narrow to active entries
|
||||
still awaiting owner review.
|
||||
"""
|
||||
viewer = auth.current_user(request)
|
||||
viewer_id = viewer.user_id if viewer else None
|
||||
# §22.5: a gated project's entries never surface in a non-member's
|
||||
# catalog. For the single public default project this is the full set.
|
||||
visible = auth.visible_project_ids(viewer)
|
||||
if not visible:
|
||||
return {"items": []}
|
||||
placeholders = ",".join("?" for _ in visible)
|
||||
params = list(visible)
|
||||
unreviewed_clause = ""
|
||||
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
|
||||
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT slug, title, state, rfc_id, repo,
|
||||
owners_json, arbiters_json, tags_json,
|
||||
last_main_commit_at, last_entry_commit_at, updated_at
|
||||
FROM cached_rfcs
|
||||
WHERE state IN ('super-draft', 'active')
|
||||
AND project_id IN ({placeholders}){unreviewed_clause}
|
||||
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
||||
"""
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
starred = set()
|
||||
@@ -652,12 +677,21 @@ def make_router(
|
||||
return {"items": items}
|
||||
|
||||
@router.get("/api/rfcs/{slug}")
|
||||
async def get_rfc(slug: str) -> dict[str, Any]:
|
||||
async def get_rfc(slug: str, request: Request) -> dict[str, Any]:
|
||||
row = db.conn().execute(
|
||||
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
viewer = auth.current_user(request)
|
||||
# §22.5 visibility gate (subtractive, §22.7): a gated project's entries
|
||||
# 404 to non-members.
|
||||
auth.require_project_readable(viewer, row["project_id"])
|
||||
# §13.7: a retired entry is removed from every browsing surface. The
|
||||
# sole exception is a site owner, so the un-retire affordance has
|
||||
# somewhere to live; everyone else gets a plain 404.
|
||||
if row["state"] == "retired" and (viewer is None or viewer.role != "owner"):
|
||||
raise HTTPException(404, "Not found")
|
||||
payload = _serialize_rfc(row)
|
||||
# Roadmap #26: surface the optional propose-time use case on the
|
||||
# RFC view. The idea PR closes on merge, but the canonical row in
|
||||
@@ -674,6 +708,122 @@ def make_router(
|
||||
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
||||
return payload
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §22.4 (Plan B): per-project RFC serving — the catalog + entry view
|
||||
# scoped to one project, identified by its own slug namespace
|
||||
# (project_id, slug). The unscoped /api/rfcs[/{slug}] above stay as the
|
||||
# default-project compat path; the frontend reads these scoped routes so a
|
||||
# second project's corpus renders under /p/<id>/.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/projects/{project_id}/rfcs")
|
||||
async def list_project_rfcs(
|
||||
project_id: str, request: Request, unreviewed: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
# §22.5 read gate: a gated project's catalog 404s to a non-member.
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
viewer_id = viewer.user_id if viewer else None
|
||||
unreviewed_clause = ""
|
||||
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
|
||||
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
|
||||
rows = db.conn().execute(
|
||||
f"""
|
||||
SELECT slug, title, state, rfc_id, repo,
|
||||
owners_json, arbiters_json, tags_json,
|
||||
last_main_commit_at, last_entry_commit_at, updated_at
|
||||
FROM cached_rfcs
|
||||
WHERE state IN ('super-draft', 'active')
|
||||
AND project_id = ?{unreviewed_clause}
|
||||
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
||||
""",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
starred = set()
|
||||
if viewer_id is not None:
|
||||
starred = {
|
||||
r["rfc_slug"]
|
||||
for r in db.conn().execute(
|
||||
"SELECT rfc_slug FROM stars WHERE user_id = ? AND project_id = ?",
|
||||
(viewer_id, project_id),
|
||||
)
|
||||
}
|
||||
items = [
|
||||
{
|
||||
"slug": r["slug"],
|
||||
"title": r["title"],
|
||||
"state": r["state"],
|
||||
"id": r["rfc_id"],
|
||||
"repo": r["repo"],
|
||||
"owners": json.loads(r["owners_json"] or "[]"),
|
||||
"arbiters": json.loads(r["arbiters_json"] or "[]"),
|
||||
"tags": json.loads(r["tags_json"] or "[]"),
|
||||
"last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"],
|
||||
"starred_by_me": r["slug"] in starred,
|
||||
"has_open_prs": False,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {"items": items}
|
||||
|
||||
@router.get("/api/projects/{project_id}/rfcs/{slug}")
|
||||
async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
row = db.conn().execute(
|
||||
"SELECT * FROM cached_rfcs WHERE project_id = ? AND slug = ?",
|
||||
(project_id, slug),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
if row["state"] == "retired" and (viewer is None or viewer.role != "owner"):
|
||||
raise HTTPException(404, "Not found")
|
||||
payload = _serialize_rfc(row)
|
||||
uc = db.conn().execute(
|
||||
"""
|
||||
SELECT use_case FROM proposed_use_cases
|
||||
WHERE scope = 'rfc' AND rfc_slug = ? AND project_id = ?
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(slug, project_id),
|
||||
).fetchone()
|
||||
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
||||
return payload
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §22.4c: mark-reviewed — clear an active entry's `unreviewed` flag
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/projects/{project_id}/rfcs/{slug}/mark-reviewed")
|
||||
async def mark_reviewed(project_id: str, slug: str, request: Request) -> dict[str, Any]:
|
||||
"""§22.4c — clear an active entry's `unreviewed` flag. Authority is the
|
||||
§22.7 project superuser (project_admin or deployment owner/admin)."""
|
||||
viewer = auth.require_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
if not auth.is_project_superuser(viewer, project_id):
|
||||
raise HTTPException(403, "Only a project owner/admin can mark an entry reviewed")
|
||||
row = db.conn().execute(
|
||||
"SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND project_id = ?",
|
||||
(slug, project_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
if row["state"] != "active" or not row["unreviewed"]:
|
||||
raise HTTPException(409, "Entry is not an unreviewed active entry")
|
||||
try:
|
||||
await bot.mark_entry_reviewed(
|
||||
viewer.as_actor(),
|
||||
org=config.gitea_org,
|
||||
meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
slug=slug,
|
||||
reviewed_by=viewer.gitea_login,
|
||||
reviewed_at=entry_mod.today(),
|
||||
)
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
return {"ok": True}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §7.3 / §9.3: pending ideas
|
||||
# ---------------------------------------------------------------
|
||||
@@ -689,14 +839,50 @@ def make_router(
|
||||
return row["use_case"] if row else None
|
||||
|
||||
@router.get("/api/proposals")
|
||||
async def list_proposals() -> dict[str, Any]:
|
||||
async def list_proposals(request: Request) -> dict[str, Any]:
|
||||
# §22.5: idea PRs in a gated project never surface to non-members.
|
||||
visible = auth.visible_project_ids(auth.current_user(request))
|
||||
if not visible:
|
||||
return {"items": []}
|
||||
placeholders = ",".join("?" for _ in visible)
|
||||
rows = db.conn().execute(
|
||||
f"""
|
||||
SELECT rfc_slug, pr_number, title, description, opened_by, opened_at, state
|
||||
FROM cached_prs
|
||||
WHERE pr_kind = 'idea' AND state = 'open'
|
||||
AND project_id IN ({placeholders})
|
||||
ORDER BY opened_at DESC
|
||||
""",
|
||||
visible,
|
||||
).fetchall()
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"slug": r["rfc_slug"],
|
||||
"pr_number": r["pr_number"],
|
||||
"title": r["title"],
|
||||
"description": r["description"],
|
||||
"opened_by": r["opened_by"],
|
||||
"opened_at": r["opened_at"],
|
||||
"proposed_use_case": _proposal_use_case(r["pr_number"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/api/projects/{project_id}/proposals")
|
||||
async def list_project_proposals(project_id: str, request: Request) -> dict[str, Any]:
|
||||
# §22.4/§22.5: the pending idea-PRs scoped to one project.
|
||||
viewer = auth.current_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT rfc_slug, pr_number, title, description, opened_by, opened_at, state
|
||||
FROM cached_prs
|
||||
WHERE pr_kind = 'idea' AND state = 'open'
|
||||
WHERE pr_kind = 'idea' AND state = 'open' AND project_id = ?
|
||||
ORDER BY opened_at DESC
|
||||
"""
|
||||
""",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
return {
|
||||
"items": [
|
||||
@@ -731,10 +917,12 @@ def make_router(
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not a proposal PR")
|
||||
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
|
||||
auth.require_project_readable(auth.current_user(request), row["project_id"])
|
||||
# Read the proposed entry file from the head branch.
|
||||
slug = row["rfc_slug"]
|
||||
head = row["head_branch"]
|
||||
result = await gitea.read_file(config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref=head)
|
||||
result = await gitea.read_file(config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref=head)
|
||||
entry_payload: dict[str, Any] | None = None
|
||||
if result:
|
||||
text, _sha = result
|
||||
@@ -763,9 +951,12 @@ def make_router(
|
||||
# §9.1: propose a new RFC
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/propose")
|
||||
async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_contributor(request)
|
||||
async def _propose_into_project(project_id: str, payload: ProposeBody, user) -> dict[str, Any]:
|
||||
# §22.6/§22.7: proposing a new entry requires project-level contribute
|
||||
# standing in the *target* project. On the public default project the
|
||||
# implicit-public baseline preserves the pre-multi-project flow.
|
||||
if not auth.can_contribute_in_project(user, project_id):
|
||||
raise HTTPException(403, "You do not have contribute access to this project")
|
||||
slug = payload.slug.strip().lower()
|
||||
if not entry_mod.is_valid_slug(slug):
|
||||
raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes")
|
||||
@@ -775,21 +966,28 @@ def make_router(
|
||||
# on every keystroke, since a concurrent submission could land
|
||||
# between dialog-open and submit.
|
||||
clash = db.conn().execute(
|
||||
"SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
"SELECT 1 FROM cached_rfcs WHERE slug = ? AND project_id = ?", (slug, project_id)
|
||||
).fetchone()
|
||||
if clash:
|
||||
raise HTTPException(409, f"Slug `{slug}` is already taken")
|
||||
idea_clash = db.conn().execute(
|
||||
"SELECT 1 FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' AND rfc_slug = ?",
|
||||
(slug,),
|
||||
"SELECT 1 FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' "
|
||||
"AND rfc_slug = ? AND project_id = ?",
|
||||
(slug, project_id),
|
||||
).fetchone()
|
||||
if idea_clash:
|
||||
raise HTTPException(409, f"Slug `{slug}` is already reserved by an open proposal")
|
||||
|
||||
# §22.4b: the target project's landing state. Through Plan A every
|
||||
# entry lands in the default project; M3-frontend routing carries a
|
||||
# non-default target later.
|
||||
target_project = project_id
|
||||
landing_state = "active" if projects_mod.project_initial_state(target_project) == "active" else "super-draft"
|
||||
|
||||
entry = entry_mod.Entry(
|
||||
slug=slug,
|
||||
title=payload.title.strip(),
|
||||
state="super-draft",
|
||||
state=landing_state,
|
||||
id=None,
|
||||
repo=None,
|
||||
proposed_by=user.email or user.gitea_login,
|
||||
@@ -804,6 +1002,7 @@ def make_router(
|
||||
arbiters=[],
|
||||
tags=[t.strip() for t in payload.tags if t.strip()],
|
||||
body=payload.pitch.strip() + "\n",
|
||||
unreviewed=(landing_state == "active"),
|
||||
)
|
||||
contents = entry_mod.serialize(entry)
|
||||
pr_title = f"Propose: {entry.title}"
|
||||
@@ -818,7 +1017,7 @@ def make_router(
|
||||
pr = await bot.open_idea_pr(
|
||||
user.as_actor(),
|
||||
org=config.gitea_org,
|
||||
meta_repo=config.meta_repo,
|
||||
meta_repo=(projects_mod.content_repo(project_id) or ""),
|
||||
slug=slug,
|
||||
file_contents=contents,
|
||||
pr_title=pr_title,
|
||||
@@ -842,19 +1041,35 @@ def make_router(
|
||||
if use_case:
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
|
||||
VALUES ('rfc', ?, ?, ?)
|
||||
ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
||||
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case, project_id)
|
||||
VALUES ('rfc', ?, ?, ?, ?)
|
||||
ON CONFLICT(project_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
||||
""",
|
||||
(slug, pr["number"], use_case),
|
||||
(slug, pr["number"], use_case, project_id),
|
||||
)
|
||||
db.conn().execute(
|
||||
"UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ?",
|
||||
(use_case, pr["number"]),
|
||||
"UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ? AND project_id = ?",
|
||||
(use_case, pr["number"], project_id),
|
||||
)
|
||||
|
||||
return {"pr_number": pr["number"], "slug": slug}
|
||||
|
||||
@router.post("/api/rfcs/propose")
|
||||
async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]:
|
||||
# Default-project compat path (pre-multi-project clients).
|
||||
user = auth.require_contributor(request)
|
||||
return await _propose_into_project(projects_mod.resolved_default_id(config), payload, user)
|
||||
|
||||
@router.post("/api/projects/{project_id}/rfcs/propose")
|
||||
async def propose_project_rfc(
|
||||
project_id: str, payload: ProposeBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
# §22.4: propose a new entry into a specific project (read-gated first
|
||||
# so a gated project 404s a non-member before the contribute check).
|
||||
user = auth.require_contributor(request)
|
||||
auth.require_project_readable(user, project_id)
|
||||
return await _propose_into_project(project_id, payload, user)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §9.1 Slice 2 (roadmap #27): Claude Haiku tag suggestions as the
|
||||
# propose-RFC fields fill in. The modal debounce-posts the partial
|
||||
@@ -894,7 +1109,7 @@ def make_router(
|
||||
await bot.merge_idea_pr(
|
||||
user.as_actor(),
|
||||
org=config.gitea_org,
|
||||
meta_repo=config.meta_repo,
|
||||
meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
pr_number=pr_number,
|
||||
slug=row["rfc_slug"],
|
||||
)
|
||||
@@ -914,7 +1129,7 @@ def make_router(
|
||||
await bot.decline_idea_pr(
|
||||
user.as_actor(),
|
||||
org=config.gitea_org,
|
||||
meta_repo=config.meta_repo,
|
||||
meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
pr_number=pr_number,
|
||||
slug=row["rfc_slug"],
|
||||
comment=body.comment,
|
||||
@@ -938,7 +1153,7 @@ def make_router(
|
||||
await bot.withdraw_idea_pr(
|
||||
user.as_actor(),
|
||||
org=config.gitea_org,
|
||||
meta_repo=config.meta_repo,
|
||||
meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
pr_number=pr_number,
|
||||
slug=row["rfc_slug"],
|
||||
)
|
||||
@@ -990,10 +1205,12 @@ def make_router(
|
||||
async def add_funder_consent(slug: str, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_contributor(request)
|
||||
rfc = db.conn().execute(
|
||||
"SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
"SELECT project_id FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
if rfc is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
|
||||
auth.require_project_readable(user, rfc["project_id"])
|
||||
# §6.7: refuse consent from a user with no registered credentials
|
||||
# — a consent without a universe would be inert and the surface
|
||||
# should fail loudly rather than silently.
|
||||
|
||||
@@ -829,6 +829,57 @@ def make_router(config: Config) -> APIRouter:
|
||||
items_blocked.append(payload)
|
||||
return {"ready": items_ready, "blocked": items_blocked}
|
||||
|
||||
# ----- §13.7: retired (soft-deleted) entries — site owners only -----
|
||||
|
||||
@router.get("/api/admin/retired-rfcs")
|
||||
async def retired_rfcs(request: Request) -> dict[str, Any]:
|
||||
# Retired entries are hidden from every browsing surface (§13.7);
|
||||
# this owner-gated list is how a site owner discovers them to
|
||||
# un-retire. Admins do not get this surface — un-retire authority is
|
||||
# site-owner-only, so neither is the list that feeds it.
|
||||
viewer = auth.require_admin(request)
|
||||
if viewer.role != "owner":
|
||||
raise HTTPException(403, "Site owner role required")
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT slug, title, rfc_id, owners_json, tags_json,
|
||||
proposed_at, updated_at
|
||||
FROM cached_rfcs
|
||||
WHERE state = 'retired'
|
||||
ORDER BY updated_at DESC
|
||||
"""
|
||||
).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
# The state it would return to on un-retire, mirroring
|
||||
# api_graduation._prior_state_before_retire's audit lookup.
|
||||
prior = db.conn().execute(
|
||||
"""
|
||||
SELECT details FROM actions
|
||||
WHERE rfc_slug = ? AND action_kind = 'retire'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(r["slug"],),
|
||||
).fetchone()
|
||||
restored_state = None
|
||||
if prior and prior["details"]:
|
||||
try:
|
||||
restored_state = json.loads(prior["details"]).get("prior_state")
|
||||
except (ValueError, TypeError):
|
||||
restored_state = None
|
||||
if restored_state not in ("super-draft", "active"):
|
||||
restored_state = "active" if r["rfc_id"] else "super-draft"
|
||||
items.append({
|
||||
"slug": r["slug"],
|
||||
"title": r["title"],
|
||||
"id": r["rfc_id"],
|
||||
"owners": json.loads(r["owners_json"] or "[]"),
|
||||
"tags": json.loads(r["tags_json"] or "[]"),
|
||||
"retired_at": r["updated_at"],
|
||||
"restores_to": restored_state,
|
||||
})
|
||||
return {"items": items}
|
||||
|
||||
# ----- User search (typeahead for §15.8 mute add) -----
|
||||
|
||||
@router.get("/api/users/search")
|
||||
|
||||
+123
-79
@@ -29,7 +29,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver
|
||||
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver, projects as projects_mod
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
@@ -120,7 +120,9 @@ def make_router(
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/models")
|
||||
async def list_models_for_rfc(slug: str) -> dict[str, Any]:
|
||||
async def list_models_for_rfc(slug: str, request: Request) -> dict[str, Any]:
|
||||
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
|
||||
_require_rfc(slug, auth.current_user(request))
|
||||
resolved = models_resolver.resolve_models_for_rfc(slug, providers)
|
||||
return {
|
||||
"models": [
|
||||
@@ -140,7 +142,7 @@ def make_router(
|
||||
@router.get("/api/rfcs/{slug}/main")
|
||||
async def get_rfc_main(slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
rfc = _require_rfc(slug)
|
||||
rfc = _require_rfc(slug, viewer)
|
||||
if rfc["state"] not in ("active", "super-draft"):
|
||||
raise HTTPException(409, f"RFC is {rfc['state']}")
|
||||
|
||||
@@ -150,7 +152,7 @@ def make_router(
|
||||
# `refresh_meta_branches` writes is internal scaffolding for the
|
||||
# §10.1 has-commits-ahead check — the §9.4 dropdown's first
|
||||
# position is rendered separately as 'canonical body'.
|
||||
if _is_super_draft(rfc):
|
||||
if _is_meta_resident(rfc):
|
||||
branch_rows = db.conn().execute(
|
||||
"""
|
||||
SELECT branch_name, head_sha, state, last_commit_at, pinned
|
||||
@@ -180,7 +182,7 @@ def make_router(
|
||||
# per-RFC repo. For super-draft: meta_body_edit and meta_metadata
|
||||
# PRs on the meta repo. Same shape either way — the §9.4 dropdown
|
||||
# treats both as "open work against this entry."
|
||||
pr_kinds = ("meta_body_edit", "meta_metadata") if _is_super_draft(rfc) else ("rfc_branch",)
|
||||
pr_kinds = ("meta_body_edit", "meta_metadata") if _is_meta_resident(rfc) else ("rfc_branch",)
|
||||
placeholders = ",".join("?" * len(pr_kinds))
|
||||
pr_rows = db.conn().execute(
|
||||
f"""
|
||||
@@ -204,17 +206,18 @@ def make_router(
|
||||
for r in pr_rows
|
||||
]
|
||||
|
||||
# For super-drafts the cached body is entry.body already (see
|
||||
# cache._upsert_cached_rfc), so no extraction is needed.
|
||||
# §9.8 / §13.4 pre-graduation history: for active RFCs, surface
|
||||
# any `threads` or `changes` rows whose `branch_name` starts with
|
||||
# `edit-<slug>-` so the breadcrumb dropdown can render the
|
||||
# affordance as a distinct disclosure alongside main, open
|
||||
# branches, and open PRs. The slug is the canonical key per §2.3
|
||||
# before and after graduation, so the query is a straightforward
|
||||
# lookup — no data movement.
|
||||
# For meta-resident entries the cached body is entry.body already
|
||||
# (see cache._upsert_cached_rfc), so no extraction is needed.
|
||||
# Pre-graduation history is a LEGACY-only affordance: under the
|
||||
# meta-only topology (§1, §13.4) graduation moves nothing, so an
|
||||
# active RFC's edit branches are its *current* branches and already
|
||||
# surface in `branches` above — there is no separate pre-graduation
|
||||
# set. The disclosure is therefore computed only for a legacy
|
||||
# per-RFC-repo active entry (`repo` set), where edit branches on the
|
||||
# meta repo genuinely predate the per-RFC repo and would otherwise
|
||||
# not appear. After the RFC-0001 fold-back (§13.6) nothing matches.
|
||||
pre_grad: list[dict[str, Any]] = []
|
||||
if rfc["state"] == "active":
|
||||
if rfc["state"] == "active" and rfc["repo"]:
|
||||
pre_grad_rows = db.conn().execute(
|
||||
"""
|
||||
SELECT t.branch_name,
|
||||
@@ -288,12 +291,24 @@ def make_router(
|
||||
403,
|
||||
"This RFC's owner has not invited you to contribute PRs",
|
||||
)
|
||||
rfc = _require_active_rfc(slug)
|
||||
rfc = _require_active_rfc(slug, viewer)
|
||||
owner, repo = _repo_for(rfc)
|
||||
new_branch = (body.branch_name or "").strip()
|
||||
if not new_branch:
|
||||
new_branch = _auto_branch_name(viewer.gitea_login)
|
||||
_validate_branch_name(new_branch)
|
||||
# Meta-only topology (§1): an active RFC's branches live on the
|
||||
# shared meta repo, so the auto name must embed the slug for the
|
||||
# cache to attribute it (`edit-<slug>-<hex>`, recovered by
|
||||
# `_slug_from_branch_name`). A legacy per-RFC-repo entry can use
|
||||
# the slug-free `<login>-draft-<hex>` since every branch there
|
||||
# belongs to the one RFC. The auto name is trusted (it carries a
|
||||
# reserved `edit-` prefix by design); only a user-supplied name
|
||||
# is validated, mirroring `start_edit_branch`.
|
||||
new_branch = (
|
||||
_auto_edit_branch_name(slug) if _is_meta_resident(rfc)
|
||||
else _auto_branch_name(viewer.gitea_login)
|
||||
)
|
||||
else:
|
||||
_validate_branch_name(new_branch)
|
||||
try:
|
||||
await bot.cut_branch_from_main(
|
||||
viewer.as_actor(),
|
||||
@@ -326,8 +341,10 @@ def make_router(
|
||||
_ensure_branch_vis(slug, new_branch, creator_user_id=viewer.user_id)
|
||||
|
||||
# Make the cache aware immediately so the breadcrumb reflects
|
||||
# the new branch without waiting for the webhook hop.
|
||||
await cache.refresh_rfc_repo(config, gitea, slug)
|
||||
# the new branch without waiting for the webhook hop. Meta-resident
|
||||
# entries (§1) refresh meta branches; a legacy per-RFC repo refreshes
|
||||
# its own — `_refresh_cache_for` dispatches on residency.
|
||||
await _refresh_cache_for(rfc)
|
||||
|
||||
return {"branch_name": new_branch, "slug": slug}
|
||||
|
||||
@@ -348,7 +365,7 @@ def make_router(
|
||||
403,
|
||||
"This RFC's owner has not invited you to contribute PRs",
|
||||
)
|
||||
rfc = _require_super_draft(slug)
|
||||
rfc = _require_super_draft(slug, viewer)
|
||||
owner, repo = _repo_for(rfc)
|
||||
new_branch = (body.branch_name or "").strip()
|
||||
if not new_branch:
|
||||
@@ -393,7 +410,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/metadata")
|
||||
async def edit_metadata(slug: str, body: MetadataEditBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_super_draft(slug)
|
||||
rfc = _require_super_draft(slug, viewer)
|
||||
# Permission: super-draft owners/arbiters per §6.3, plus app-wide
|
||||
# admins/owners per §6.1. Until claim, that collapses to admin/owner.
|
||||
if not _can_edit_metadata(rfc, viewer):
|
||||
@@ -466,7 +483,7 @@ def make_router(
|
||||
request: Request,
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
rfc = _require_rfc_with_repo(slug, viewer)
|
||||
_require_can_contribute(slug, branch, viewer)
|
||||
row = _require_pending_change(slug, branch, change_id)
|
||||
if row["kind"] != "ai":
|
||||
@@ -553,7 +570,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/changes/{change_id}/decline")
|
||||
async def decline_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc_with_repo(slug)
|
||||
_require_rfc_with_repo(slug, viewer)
|
||||
_require_can_contribute(slug, branch, viewer)
|
||||
row = _require_pending_change(slug, branch, change_id)
|
||||
if row["kind"] != "ai":
|
||||
@@ -571,7 +588,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/changes/{change_id}/reask")
|
||||
async def reask_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
rfc = _require_rfc_with_repo(slug, viewer)
|
||||
_require_can_contribute(slug, branch, viewer)
|
||||
row = _require_change(slug, branch, change_id)
|
||||
if row["kind"] != "ai":
|
||||
@@ -638,7 +655,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/manual-flush")
|
||||
async def manual_flush(slug: str, branch: str, body: ManualFlushBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
rfc = _require_rfc_with_repo(slug, viewer)
|
||||
_require_can_contribute(slug, branch, viewer)
|
||||
owner, repo = _repo_for(rfc, branch)
|
||||
path = _file_path_for(rfc, branch)
|
||||
@@ -715,7 +732,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/visibility")
|
||||
async def set_branch_visibility(slug: str, branch: str, body: VisibilityBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
rfc = _require_rfc_with_repo(slug, viewer)
|
||||
creator = _branch_creator(slug, branch)
|
||||
_require_branch_owner(rfc, viewer, creator)
|
||||
current = _branch_vis(slug, branch)
|
||||
@@ -725,7 +742,7 @@ def make_router(
|
||||
"""
|
||||
INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
|
||||
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
read_public = excluded.read_public,
|
||||
contribute_mode = excluded.contribute_mode
|
||||
""",
|
||||
@@ -736,7 +753,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/grants")
|
||||
async def add_branch_grant(slug: str, branch: str, body: GrantBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
rfc = _require_rfc_with_repo(slug, viewer)
|
||||
creator = _branch_creator(slug, branch)
|
||||
_require_branch_owner(rfc, viewer, creator)
|
||||
grantee = db.conn().execute(
|
||||
@@ -757,7 +774,7 @@ def make_router(
|
||||
@router.delete("/api/rfcs/{slug}/branches/{branch:path}/grants/{grantee_login}")
|
||||
async def revoke_branch_grant(slug: str, branch: str, grantee_login: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
rfc = _require_rfc_with_repo(slug, viewer)
|
||||
creator = _branch_creator(slug, branch)
|
||||
_require_branch_owner(rfc, viewer, creator)
|
||||
grantee = db.conn().execute(
|
||||
@@ -777,7 +794,7 @@ def make_router(
|
||||
@router.get("/api/rfcs/{slug}/branches/{branch:path}/threads")
|
||||
async def list_branch_threads(slug: str, branch: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
_require_rfc_with_repo(slug)
|
||||
_require_rfc_with_repo(slug, viewer)
|
||||
if not _can_read_branch(slug, branch, viewer):
|
||||
raise HTTPException(403, "Branch is private")
|
||||
rows = db.conn().execute(
|
||||
@@ -795,7 +812,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/threads")
|
||||
async def create_branch_thread(slug: str, branch: str, body: ThreadCreateBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc_with_repo(slug)
|
||||
_require_rfc_with_repo(slug, viewer)
|
||||
if body.thread_kind == "flag" and not body.label:
|
||||
raise HTTPException(422, "Flag threads require a label")
|
||||
cur = db.conn().execute(
|
||||
@@ -825,7 +842,7 @@ def make_router(
|
||||
@router.get("/api/rfcs/{slug}/branches/{branch:path}/threads/{thread_id}/messages")
|
||||
async def get_thread_messages(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
_require_rfc_with_repo(slug)
|
||||
_require_rfc_with_repo(slug, viewer)
|
||||
if not _can_read_branch(slug, branch, viewer):
|
||||
raise HTTPException(403, "Branch is private")
|
||||
thread = _require_thread(slug, branch, thread_id)
|
||||
@@ -850,7 +867,7 @@ def make_router(
|
||||
slug: str, branch: str, thread_id: int, body: ThreadMessageBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc_with_repo(slug)
|
||||
_require_rfc_with_repo(slug, viewer)
|
||||
_require_thread(slug, branch, thread_id)
|
||||
if not _can_read_branch(slug, branch, viewer):
|
||||
raise HTTPException(403, "Branch is private")
|
||||
@@ -871,7 +888,7 @@ def make_router(
|
||||
to this (slug, branch) on or before the new cursor is marked read.
|
||||
"""
|
||||
viewer = auth.require_user(request)
|
||||
_require_rfc_with_repo(slug)
|
||||
_require_rfc_with_repo(slug, viewer)
|
||||
if not _can_read_branch(slug, branch, viewer):
|
||||
raise HTTPException(403, "Branch is private")
|
||||
last_seen = int(body.get("last_seen_message_id") or 0) or None
|
||||
@@ -879,7 +896,7 @@ def make_router(
|
||||
"""
|
||||
INSERT INTO branch_chat_seen (user_id, rfc_slug, branch_name, last_seen_message_id, seen_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
ON CONFLICT(project_id, user_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
last_seen_message_id = excluded.last_seen_message_id,
|
||||
seen_at = excluded.seen_at
|
||||
""",
|
||||
@@ -894,7 +911,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/threads/{thread_id}/resolve")
|
||||
async def resolve_thread(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
rfc = _require_rfc_with_repo(slug, viewer)
|
||||
thread = _require_thread(slug, branch, thread_id)
|
||||
creator = _branch_creator(slug, branch)
|
||||
if not _can_resolve_thread(rfc, thread, creator, viewer):
|
||||
@@ -917,7 +934,7 @@ def make_router(
|
||||
slug: str, branch: str, thread_id: int, body: ChatTurnBody, request: Request
|
||||
):
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
rfc = _require_rfc_with_repo(slug, viewer)
|
||||
thread = _require_thread(slug, branch, thread_id)
|
||||
if not _can_read_branch(slug, branch, viewer):
|
||||
raise HTTPException(403, "Branch is private")
|
||||
@@ -1003,7 +1020,7 @@ def make_router(
|
||||
@router.get("/api/rfcs/{slug}/branches/{branch:path}")
|
||||
async def get_branch_view(slug: str, branch: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
rfc = _require_rfc_with_repo(slug, viewer)
|
||||
if not _can_read_branch(slug, branch, viewer):
|
||||
raise HTTPException(403, "Branch is private")
|
||||
|
||||
@@ -1074,31 +1091,34 @@ def make_router(
|
||||
# Permission + state helpers (closures, share `config` etc.)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _require_rfc(slug: str):
|
||||
def _require_rfc(slug: str, viewer):
|
||||
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
# §22.5 visibility gate (subtractive, §22.7): a gated project's entries
|
||||
# 404 to non-members — indistinguishable from an unknown slug.
|
||||
auth.require_project_readable(viewer, row["project_id"])
|
||||
return row
|
||||
|
||||
def _require_rfc_with_repo(slug: str):
|
||||
"""Used by every branch-scoped endpoint. For active RFCs, a repo is
|
||||
required. For super-drafts, the meta repo is the implicit target —
|
||||
no per-RFC repo check needed."""
|
||||
row = _require_rfc(slug)
|
||||
def _require_rfc_with_repo(slug: str, viewer):
|
||||
"""Used by every branch-scoped endpoint. Under the meta-only
|
||||
topology (§1) the meta repo is the implicit target for every
|
||||
entry — super-draft and active alike — so there is no per-RFC
|
||||
repo check. The name is retained for call-site stability; a
|
||||
withdrawn entry is still rejected."""
|
||||
row = _require_rfc(slug, viewer)
|
||||
if row["state"] == "withdrawn":
|
||||
raise HTTPException(409, "RFC is withdrawn")
|
||||
if row["state"] == "active" and not row["repo"]:
|
||||
raise HTTPException(409, "RFC has no repo")
|
||||
return row
|
||||
|
||||
def _require_active_rfc(slug: str):
|
||||
row = _require_rfc_with_repo(slug)
|
||||
def _require_active_rfc(slug: str, viewer):
|
||||
row = _require_rfc_with_repo(slug, viewer)
|
||||
if row["state"] != "active":
|
||||
raise HTTPException(409, f"RFC is {row['state']}, not active")
|
||||
return row
|
||||
|
||||
def _require_super_draft(slug: str):
|
||||
row = _require_rfc(slug)
|
||||
def _require_super_draft(slug: str, viewer):
|
||||
row = _require_rfc(slug, viewer)
|
||||
if row["state"] != "super-draft":
|
||||
raise HTTPException(409, f"RFC is {row['state']}, not super-draft")
|
||||
return row
|
||||
@@ -1106,28 +1126,34 @@ def make_router(
|
||||
def _is_super_draft(rfc) -> bool:
|
||||
return rfc["state"] == "super-draft"
|
||||
|
||||
def _is_meta_resident(rfc) -> bool:
|
||||
"""Meta-only topology (§1): an entry lives in the meta repo's
|
||||
`rfcs/<slug>.md` (super-draft or active-in-place) unless it carries
|
||||
a legacy per-RFC `repo:` — which nothing does after the RFC-0001
|
||||
fold-back (§13.6)."""
|
||||
return not rfc["repo"]
|
||||
|
||||
def _is_meta_branch_name(name: str) -> bool:
|
||||
"""A branch name shaped like one of the bot's meta-repo prefixes.
|
||||
§9.8's pre-graduation history affordance points the new RFC view
|
||||
at branches matching `edit-<slug>-...` even after the entry is
|
||||
active; treating those names as meta-repo targets lets the read
|
||||
path dispatch correctly without a separate endpoint."""
|
||||
Retained for the legacy per-RFC-repo read path; under meta-only
|
||||
every entry is already a meta target via `_is_meta_resident`."""
|
||||
return name != "main" and name.startswith((
|
||||
"edit-", "edit/", "metadata-", "metadata/", "claim/", "propose/",
|
||||
"graduate-",
|
||||
))
|
||||
|
||||
def _is_meta_target(rfc, branch: str) -> bool:
|
||||
"""Either a super-draft branch (active edit branch or the
|
||||
canonical body) or an active RFC's pre-graduation meta-repo
|
||||
branch surfaced through the §9.8 history affordance."""
|
||||
if _is_super_draft(rfc):
|
||||
"""A meta-resident entry (super-draft or active-in-place, §1)
|
||||
targets the meta repo for every branch. The branch-name fallback
|
||||
covers the retired per-RFC-repo case for any legacy entry that
|
||||
still carries a `repo:`."""
|
||||
if _is_meta_resident(rfc):
|
||||
return True
|
||||
return _is_meta_branch_name(branch)
|
||||
|
||||
def _repo_for(rfc, branch: str = "main") -> tuple[str, str]:
|
||||
if _is_meta_target(rfc, branch):
|
||||
return config.gitea_org, config.meta_repo
|
||||
return config.gitea_org, (projects_mod.default_content_repo(config) or "")
|
||||
owner, repo = rfc["repo"].split("/", 1)
|
||||
return owner, repo
|
||||
|
||||
@@ -1161,7 +1187,7 @@ def make_router(
|
||||
return entry_mod.serialize(entry)
|
||||
|
||||
async def _refresh_cache_for(rfc) -> None:
|
||||
if _is_super_draft(rfc):
|
||||
if _is_meta_resident(rfc):
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
await cache.refresh_meta_branches(config, gitea)
|
||||
else:
|
||||
@@ -1238,6 +1264,12 @@ def make_router(
|
||||
return row["on_behalf_of"] if row else None
|
||||
|
||||
def _can_read_branch(slug: str, branch: str, viewer) -> bool:
|
||||
# §22.5 visibility gate first (subtractive, §22.7): in a gated project
|
||||
# nothing — not even main or a read_public branch — is readable by a
|
||||
# non-member.
|
||||
pid = auth.project_of_rfc(slug)
|
||||
if not auth.can_read_project(viewer, pid):
|
||||
return False
|
||||
if branch == "main":
|
||||
return True
|
||||
vis = _branch_vis(slug, branch)
|
||||
@@ -1245,7 +1277,7 @@ def make_router(
|
||||
return True
|
||||
if viewer is None:
|
||||
return False
|
||||
if viewer.role in ("owner", "admin"):
|
||||
if auth.is_project_superuser(viewer, pid):
|
||||
return True
|
||||
creator = _branch_creator(slug, branch)
|
||||
if creator and viewer.gitea_login == creator:
|
||||
@@ -1270,14 +1302,20 @@ def make_router(
|
||||
return False
|
||||
if branch == "main":
|
||||
return False
|
||||
# §9.8: pre-graduation history branches are read-only on the
|
||||
# post-graduation surface. The contributor can re-cut against the
|
||||
# new repo's main if they still want the work, but the meta-repo
|
||||
# branches that lived on the super-draft are not editable from
|
||||
# the active-RFC view.
|
||||
if rfc["state"] == "active" and _is_meta_branch_name(branch):
|
||||
# §9.8 (LEGACY per-repo only): pre-graduation history branches are
|
||||
# read-only on the post-graduation surface of a per-RFC-repo active
|
||||
# entry. Under the meta-only topology (§1, §13.4) an active RFC's
|
||||
# `edit-<slug>-…` branches are its *current* editable branches, not
|
||||
# a frozen pre-graduation set, so this guard applies only when a
|
||||
# legacy `repo:` is set (nothing, after the RFC-0001 fold-back).
|
||||
if rfc["state"] == "active" and rfc["repo"] and _is_meta_branch_name(branch):
|
||||
return False
|
||||
if viewer.role in ("owner", "admin"):
|
||||
pid = auth.project_of_rfc(slug)
|
||||
# §22.5 visibility gate (subtractive): no contribute in an unreadable
|
||||
# project.
|
||||
if not auth.can_read_project(viewer, pid):
|
||||
return False
|
||||
if auth.is_project_superuser(viewer, pid):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
@@ -1288,7 +1326,10 @@ def make_router(
|
||||
return True
|
||||
vis = _branch_vis(slug, branch)
|
||||
if vis["contribute_mode"] == "any-contributor":
|
||||
return True
|
||||
# "any contributor" means anyone with project-level write standing
|
||||
# (§22.6/§22.7) — the implicit-public baseline on a public project,
|
||||
# or an explicit project_contributor/admin elsewhere.
|
||||
return auth.can_contribute_in_project(viewer, pid)
|
||||
if vis["contribute_mode"] == "specific":
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
@@ -1302,14 +1343,16 @@ def make_router(
|
||||
|
||||
def _require_can_contribute(slug: str, branch: str, viewer) -> None:
|
||||
rfc = db.conn().execute(
|
||||
"SELECT state, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?",
|
||||
"SELECT state, repo, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if not _can_contribute(rfc, slug, branch, viewer):
|
||||
raise HTTPException(403, "You do not have contribute access to this branch")
|
||||
|
||||
def _require_branch_owner(rfc, viewer, creator: str | None) -> None:
|
||||
if viewer.role in ("owner", "admin"):
|
||||
# §22.6: a project_admin is the per-RFC owner/arbiter authority lifted
|
||||
# to project scope, so it (and a deployment owner/admin) clears here.
|
||||
if auth.is_project_superuser(viewer, rfc["project_id"]):
|
||||
return
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
@@ -1320,11 +1363,12 @@ def make_router(
|
||||
raise HTTPException(403, "Only the branch creator, an RFC owner/arbiter, or an admin/owner may change branch settings")
|
||||
|
||||
def _can_edit_metadata(rfc, viewer) -> bool:
|
||||
"""§9.5: super-draft owners/arbiters per §6.3 plus app admins/owners.
|
||||
Until §13.1's claim runs, the super-draft has no owners, so the set
|
||||
collapses to app admins/owners only — sensible because admin oversight
|
||||
is the only path to canonicalizing edits on an unclaimed entry."""
|
||||
if viewer.role in ("owner", "admin"):
|
||||
"""§9.5: super-draft owners/arbiters per §6.3 plus project_admin /
|
||||
app admins/owners (§22.6). Until §13.1's claim runs, the super-draft
|
||||
has no owners, so the set collapses to the superuser tier only —
|
||||
sensible because admin oversight is the only path to canonicalizing
|
||||
edits on an unclaimed entry."""
|
||||
if auth.is_project_superuser(viewer, rfc["project_id"]):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
@@ -1337,7 +1381,7 @@ def make_router(
|
||||
"can_read": _can_read_branch(slug, branch, viewer),
|
||||
"can_contribute": _can_contribute(rfc, slug, branch, viewer) if viewer else False,
|
||||
"can_change_branch_settings": viewer is not None and (
|
||||
viewer.role in ("owner", "admin")
|
||||
auth.is_project_superuser(viewer, rfc["project_id"])
|
||||
or (creator is not None and viewer.gitea_login == creator)
|
||||
or viewer.gitea_login in (owners + arbiters)
|
||||
),
|
||||
@@ -1383,7 +1427,7 @@ def make_router(
|
||||
def _can_resolve_thread(rfc, thread, creator: str | None, viewer) -> bool:
|
||||
if viewer is None:
|
||||
return False
|
||||
if viewer.role in ("owner", "admin"):
|
||||
if auth.is_project_superuser(viewer, rfc["project_id"]):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
"""v0.29.0 / roadmap #28 Part 3 — offer-to-contribute-to-a-pending-RFC.
|
||||
|
||||
When the #28 scanner (see ``rfc_links.py``) matches a term in submitted
|
||||
PR/comment text to a **pending** RFC — a super-draft
|
||||
(``cached_rfcs.state='super-draft'``: accepted as an idea, owned, with a
|
||||
contribution surface, but not yet graduated to an active RFC) — the
|
||||
reader is offered an "ask to contribute" popover. This module is the
|
||||
backend for that flow:
|
||||
|
||||
* ``GET /api/rfcs/{slug}/contribution-target`` — what the
|
||||
contribute form needs (RFC title, owner display, the viewer's
|
||||
eligibility + whether they already have a pending ask).
|
||||
* ``POST /api/rfcs/{slug}/contribution-requests`` — submit the ask
|
||||
(who-I-am / why / optional use-case); lands a row + one §15
|
||||
notification per owner.
|
||||
* ``POST /api/rfcs/{slug}/contribution-requests/{id}/accept`` — owner:
|
||||
accept, which fires #12's owner-invite flow with the requester as the
|
||||
invitee (opening the RFC's discussion/contribution surface), then
|
||||
echoes a notification back to the requester.
|
||||
* ``POST /api/rfcs/{slug}/contribution-requests/{id}/decline`` — owner:
|
||||
decline; the request closes and the requester is notified.
|
||||
|
||||
"Pending" is scoped to a super-draft because that is the state with an
|
||||
owner to route to, a contribution surface to open, and a row in
|
||||
``cached_rfcs`` for the ``rfc_invitations`` FK the accept path reuses.
|
||||
Pre-merge idea PRs are deliberately out of scope (no contribution
|
||||
surface yet) — a documented future extension, mirroring the
|
||||
conservative scoping in ``rfc_links.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import api_invitations, auth, db, notify
|
||||
|
||||
# Field caps — generous for free text, bounded so a request row (and the
|
||||
# notification payload that carries it) can't be used to store unbounded
|
||||
# blobs. Mirrors the order-of-magnitude of the propose/tag-suggest caps.
|
||||
_WHO_MAX = 2000
|
||||
_WHY_MAX = 4000
|
||||
_USE_CASE_MAX = 4000
|
||||
_TERM_MAX = 200
|
||||
|
||||
|
||||
class ContributionRequestBody(BaseModel):
|
||||
# The term in the PR/comment text that surfaced the offer (the
|
||||
# super-draft's title/slug). Carried for the owner's context line.
|
||||
matched_term: str = Field(min_length=1, max_length=_TERM_MAX)
|
||||
who_i_am: str = Field(min_length=1, max_length=_WHO_MAX)
|
||||
why: str = Field(min_length=1, max_length=_WHY_MAX)
|
||||
use_case: str | None = Field(default=None, max_length=_USE_CASE_MAX)
|
||||
|
||||
|
||||
def _require_super_draft(slug: str, viewer):
|
||||
"""The contribute surface only operates on a *pending* RFC. 404 on
|
||||
unknown; 409 on a state that isn't a super-draft (active RFCs use the
|
||||
Part-1 link, not a contribute offer; withdrawn is closed). The §22.5
|
||||
visibility gate is subtractive: a gated project's entries 404 to
|
||||
non-members (§22.7)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT slug, title, state, owners_json, proposed_by, project_id FROM cached_rfcs WHERE slug = ?",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
auth.require_project_readable(viewer, row["project_id"])
|
||||
if row["state"] != "super-draft":
|
||||
raise HTTPException(409, "RFC is not a pending super-draft")
|
||||
return row
|
||||
|
||||
|
||||
def _require_request(slug: str, request_id: int):
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT id, rfc_slug, requester_user_id, matched_term, who_i_am, why,
|
||||
use_case, status
|
||||
FROM contribution_requests WHERE id = ? AND rfc_slug = ?
|
||||
""",
|
||||
(request_id, slug),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Contribution request not found")
|
||||
return row
|
||||
|
||||
|
||||
def _viewer_relationship(viewer, slug: str) -> str | None:
|
||||
"""Why this viewer can't *request* to contribute — or None if they can.
|
||||
Owners/admins already have the RFC; existing collaborators are already
|
||||
in. Both get a clear 409 rather than a useless self-request."""
|
||||
if auth.is_rfc_owner(viewer, slug) or auth.is_project_superuser(viewer, auth.project_of_rfc(slug)):
|
||||
return "You already own or administer this RFC."
|
||||
if auth.is_rfc_collaborator(viewer, slug):
|
||||
return "You're already a collaborator on this RFC."
|
||||
return None
|
||||
|
||||
|
||||
def make_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# GET — what the contribute form needs to render + gate itself.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/contribution-target")
|
||||
async def contribution_target(slug: str, request: Request) -> dict[str, Any]:
|
||||
row = db.conn().execute(
|
||||
"SELECT slug, title, state, owners_json, proposed_by, project_id FROM cached_rfcs WHERE slug = ?",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
viewer = auth.current_user(request)
|
||||
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
|
||||
auth.require_project_readable(viewer, row["project_id"])
|
||||
|
||||
from . import rfc_links # local import: avoid a module import cycle
|
||||
|
||||
owner = rfc_links._owner_display(db.conn(), row["owners_json"], row["proposed_by"])
|
||||
|
||||
eligible = True
|
||||
reason: str | None = None
|
||||
already_requested = False
|
||||
if row["state"] != "super-draft":
|
||||
eligible, reason = False, "This RFC is no longer pending."
|
||||
elif viewer is None:
|
||||
eligible, reason = False, "Sign in to ask to contribute."
|
||||
elif viewer.permission_state != "granted":
|
||||
eligible, reason = False, "Your beta access request is in review."
|
||||
else:
|
||||
reason = _viewer_relationship(viewer, slug)
|
||||
if reason is not None:
|
||||
eligible = False
|
||||
else:
|
||||
already_requested = bool(
|
||||
db.conn().execute(
|
||||
"""
|
||||
SELECT 1 FROM contribution_requests
|
||||
WHERE rfc_slug = ? AND requester_user_id = ? AND status = 'pending'
|
||||
LIMIT 1
|
||||
""",
|
||||
(slug, viewer.user_id),
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
return {
|
||||
"slug": row["slug"],
|
||||
"title": row["title"],
|
||||
"owner": owner,
|
||||
"eligible": eligible and not already_requested,
|
||||
"reason": reason,
|
||||
"already_requested": already_requested,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — submit a contribute request.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/contribution-requests")
|
||||
async def create_contribution_request(
|
||||
slug: str, body: ContributionRequestBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_super_draft(slug, viewer)
|
||||
|
||||
reason = _viewer_relationship(viewer, slug)
|
||||
if reason is not None:
|
||||
raise HTTPException(409, reason)
|
||||
|
||||
who_i_am = body.who_i_am.strip()
|
||||
why = body.why.strip()
|
||||
use_case = (body.use_case or "").strip() or None
|
||||
matched_term = body.matched_term.strip()
|
||||
if not who_i_am or not why:
|
||||
raise HTTPException(422, "Both 'who I am' and 'why' are required.")
|
||||
|
||||
try:
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO contribution_requests
|
||||
(rfc_slug, requester_user_id, matched_term, who_i_am, why, use_case)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(slug, viewer.user_id, matched_term, who_i_am, why, use_case),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
# The partial unique index — one open request per (RFC, user).
|
||||
raise HTTPException(409, "You already have a pending request to contribute to this RFC.")
|
||||
request_id = cur.lastrowid
|
||||
|
||||
# One actionable notification per owner; stamp the first onto the
|
||||
# row as the inbox-action handle (any owner can act on the request).
|
||||
notif_ids = notify.fan_out_contribution_request(
|
||||
rfc_slug=slug,
|
||||
requester_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
matched_term=matched_term,
|
||||
who_i_am=who_i_am,
|
||||
why=why,
|
||||
use_case=use_case,
|
||||
)
|
||||
if notif_ids:
|
||||
db.conn().execute(
|
||||
"UPDATE contribution_requests SET notification_id = ? WHERE id = ?",
|
||||
(notif_ids[0], request_id),
|
||||
)
|
||||
|
||||
return {"id": request_id, "rfc_slug": slug, "status": "pending"}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — owner accepts → fire #12's invite flow.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/contribution-requests/{request_id}/accept")
|
||||
async def accept_contribution_request(
|
||||
slug: str, request_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_super_draft(slug, viewer)
|
||||
if not auth.can_invite_to_rfc(viewer, slug):
|
||||
raise HTTPException(403, "Only the RFC's owner can act on contribution requests")
|
||||
|
||||
req = _require_request(slug, request_id)
|
||||
if req["status"] != "pending":
|
||||
raise HTTPException(409, f"This request was already {req['status']}.")
|
||||
|
||||
requester = db.conn().execute(
|
||||
"SELECT id, email FROM users WHERE id = ?", (req["requester_user_id"],)
|
||||
).fetchone()
|
||||
if requester is None or not (requester["email"] or "").strip():
|
||||
raise HTTPException(422, "The requester has no email address on file to invite.")
|
||||
|
||||
# Fire #12's owner-invite flow with the requester as the invitee.
|
||||
# If a pending contributor invitation already exists (the owner
|
||||
# invited them out-of-band first), reuse it rather than failing.
|
||||
try:
|
||||
invitation = api_invitations.issue_invitation(
|
||||
slug=slug,
|
||||
inviter_user_id=viewer.user_id,
|
||||
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
|
||||
invitee_email=requester["email"],
|
||||
role_in_rfc="contributor",
|
||||
rfc_title=rfc["title"],
|
||||
)
|
||||
invitation_id = invitation["id"]
|
||||
except HTTPException as exc:
|
||||
if exc.status_code != 409:
|
||||
raise
|
||||
existing = db.conn().execute(
|
||||
"""
|
||||
SELECT id FROM rfc_invitations
|
||||
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
|
||||
AND role_in_rfc = 'contributor' AND status = 'pending'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(slug, requester["email"].strip()),
|
||||
).fetchone()
|
||||
invitation_id = existing["id"] if existing else None
|
||||
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE contribution_requests
|
||||
SET status = 'accepted', decided_at = datetime('now'),
|
||||
decided_by_user_id = ?, invitation_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(viewer.user_id, invitation_id, request_id),
|
||||
)
|
||||
notify.notify_contribution_decided(
|
||||
rfc_slug=slug,
|
||||
requester_user_id=req["requester_user_id"],
|
||||
decider_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
accepted=True,
|
||||
)
|
||||
return {"ok": True, "status": "accepted", "invitation_id": invitation_id}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — owner declines.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/contribution-requests/{request_id}/decline")
|
||||
async def decline_contribution_request(
|
||||
slug: str, request_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_super_draft(slug, viewer)
|
||||
if not auth.can_invite_to_rfc(viewer, slug):
|
||||
raise HTTPException(403, "Only the RFC's owner can act on contribution requests")
|
||||
|
||||
req = _require_request(slug, request_id)
|
||||
if req["status"] != "pending":
|
||||
raise HTTPException(409, f"This request was already {req['status']}.")
|
||||
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE contribution_requests
|
||||
SET status = 'declined', decided_at = datetime('now'),
|
||||
decided_by_user_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(viewer.user_id, request_id),
|
||||
)
|
||||
notify.notify_contribution_decided(
|
||||
rfc_slug=slug,
|
||||
requester_user_id=req["requester_user_id"],
|
||||
decider_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
accepted=False,
|
||||
)
|
||||
return {"ok": True, "status": "declined"}
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,106 @@
|
||||
"""§22.9 runtime deployment/project config (replaces VITE_APP_NAME) + §22.10
|
||||
old-URL 308 redirects.
|
||||
|
||||
GET /api/deployment — the deployment name/tagline + the projects the caller can
|
||||
see (§22.5: gated filtered by membership, unlisted omitted from enumeration),
|
||||
plus the corpus-served `default_project_id` the M3-frontend guard keys on.
|
||||
GET /api/projects/:id — one project's runtime config + optional theme overlay,
|
||||
gated behind the §22.5 read gate (404 for a non-member of a gated project).
|
||||
GET /rfc/{slug}, /proposals/{n} — §22.10 server-side 308s onto the new
|
||||
`/p/<default>/…` routes (the SPA no longer owns these paths; nginx proxies them
|
||||
to the backend instead of serving index.html).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from . import auth, db, projects as projects_mod
|
||||
from .config import Config
|
||||
|
||||
|
||||
def make_router(config: Config) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/deployment")
|
||||
async def get_deployment(request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
dep = db.conn().execute(
|
||||
"SELECT name, tagline FROM deployment WHERE id = 1"
|
||||
).fetchone()
|
||||
# §22.5: enumerate only public + (member-)gated; unlisted is never listed.
|
||||
visible = set(auth.visible_project_ids(viewer))
|
||||
rows = db.conn().execute(
|
||||
"SELECT id, name, type, visibility FROM projects "
|
||||
"WHERE visibility != 'unlisted' ORDER BY name"
|
||||
).fetchall()
|
||||
projects = [
|
||||
{"id": r["id"], "name": r["name"], "type": r["type"], "visibility": r["visibility"]}
|
||||
for r in rows
|
||||
if r["id"] in visible
|
||||
]
|
||||
return {
|
||||
"name": (dep["name"] if dep else "") or "",
|
||||
"tagline": (dep["tagline"] if dep else "") or "",
|
||||
# §22.10 / M3-frontend guard contract: which project the backend
|
||||
# serves the corpus for (the default, until Plan B serves per
|
||||
# project). The frontend renders corpus routes only for this id and
|
||||
# shows a "content not yet served" placeholder for any other.
|
||||
"default_project_id": projects_mod.resolved_default_id(config),
|
||||
"projects": projects,
|
||||
}
|
||||
|
||||
@router.get("/api/projects/{project_id}")
|
||||
async def get_project(project_id: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
# §22.5 read gate: a gated project 404s to a non-member (shape matches
|
||||
# an unknown id). unlisted is readable by direct id.
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
row = db.conn().execute(
|
||||
"SELECT id, name, type, visibility, initial_state, config_json "
|
||||
"FROM projects WHERE id = ?",
|
||||
(project_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
try:
|
||||
cfg = json.loads(row["config_json"] or "{}")
|
||||
except (ValueError, TypeError):
|
||||
cfg = {}
|
||||
dep = db.conn().execute("SELECT tagline FROM deployment WHERE id = 1").fetchone()
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"tagline": (dep["tagline"] if dep else "") or "",
|
||||
"type": row["type"],
|
||||
"visibility": row["visibility"],
|
||||
"initial_state": row["initial_state"],
|
||||
"theme": cfg.get("theme") or {},
|
||||
}
|
||||
|
||||
# §22.10 / §5 — server-side 308s off the old corpus-root URLs onto the
|
||||
# `/p/<default>/…` routes. 308 (not 301/302) preserves method + body and
|
||||
# is permanent, so external "RFC-0001" links and bookmarks land correctly.
|
||||
# nginx routes /rfc/ and /proposals/ to the backend so these are reached
|
||||
# before the SPA's index.html fallback.
|
||||
@router.get("/rfc/{slug}")
|
||||
async def redirect_old_rfc(slug: str) -> RedirectResponse:
|
||||
default_id = projects_mod.resolved_default_id(config)
|
||||
return RedirectResponse(url=f"/p/{default_id}/e/{slug}", status_code=308)
|
||||
|
||||
@router.get("/rfc/{slug}/pr/{pr_number}")
|
||||
async def redirect_old_rfc_pr(slug: str, pr_number: int) -> RedirectResponse:
|
||||
default_id = projects_mod.resolved_default_id(config)
|
||||
return RedirectResponse(
|
||||
url=f"/p/{default_id}/e/{slug}/pr/{pr_number}", status_code=308
|
||||
)
|
||||
|
||||
@router.get("/proposals/{pr_number}")
|
||||
async def redirect_old_proposal(pr_number: int) -> RedirectResponse:
|
||||
default_id = projects_mod.resolved_default_id(config)
|
||||
return RedirectResponse(url=f"/p/{default_id}/proposals/{pr_number}", status_code=308)
|
||||
|
||||
return router
|
||||
@@ -40,7 +40,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, chat as chat_layer, db
|
||||
from . import auth, chat as chat_layer, db, rfc_links
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -84,7 +84,7 @@ def make_router() -> APIRouter:
|
||||
@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)
|
||||
_require_rfc_readable(slug, viewer)
|
||||
# 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
|
||||
@@ -115,7 +115,7 @@ def make_router() -> APIRouter:
|
||||
slug: str, body: DiscussionThreadCreateBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc_readable(slug)
|
||||
_require_rfc_readable(slug, viewer)
|
||||
# v0.16.0 (roadmap item #12): the per-RFC discussion is now a
|
||||
# gated surface. The platform-level `require_contributor` above
|
||||
# ensures the user is signed in + admin-granted; this layer
|
||||
@@ -155,8 +155,8 @@ def make_router() -> APIRouter:
|
||||
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)
|
||||
viewer = auth.current_user(request)
|
||||
_require_rfc_readable(slug, viewer)
|
||||
thread = _require_discussion_thread(slug, thread_id)
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
@@ -171,9 +171,17 @@ def make_router() -> APIRouter:
|
||||
""",
|
||||
(thread_id,),
|
||||
).fetchall()
|
||||
# Roadmap #28 Part 1: enrich each discussion comment with RFC
|
||||
# auto-link segments scanned against the live accepted-RFC corpus
|
||||
# (read-time; see rfc_links.py). exclude_slug suppresses self-links
|
||||
# to this RFC inside its own discussion.
|
||||
link_index = rfc_links.build_index(db.conn(), exclude_slug=slug)
|
||||
messages = [_serialize_message(r) for r in rows]
|
||||
for m in messages:
|
||||
m["text_segments"] = link_index.segment(m["text"])
|
||||
return {
|
||||
"thread": _serialize_thread(thread),
|
||||
"messages": [_serialize_message(r) for r in rows],
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
@@ -185,7 +193,7 @@ def make_router() -> APIRouter:
|
||||
slug: str, thread_id: int, body: DiscussionMessageBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc_readable(slug)
|
||||
_require_rfc_readable(slug, viewer)
|
||||
# v0.16.0 (item #12): same per-RFC gate as create_discussion_thread.
|
||||
if not auth.can_discuss_rfc(viewer, slug):
|
||||
raise HTTPException(
|
||||
@@ -210,7 +218,7 @@ def make_router() -> APIRouter:
|
||||
slug: str, thread_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_readable(slug)
|
||||
rfc = _require_rfc_readable(slug, viewer)
|
||||
thread = _require_discussion_thread(slug, thread_id)
|
||||
if not _can_resolve(rfc, thread, viewer):
|
||||
raise HTTPException(
|
||||
@@ -237,17 +245,24 @@ def make_router() -> APIRouter:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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."""
|
||||
def _require_rfc_readable(slug: str, viewer):
|
||||
"""Per the v0.3.0 anonymous-read contract: any cached RFC in a *readable*
|
||||
project is readable by anyone. The §22.5 visibility gate is subtractive on
|
||||
top (§22.7): a gated project's entries 404 to non-members. 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")
|
||||
auth.require_project_readable(viewer, row["project_id"])
|
||||
if row["state"] == "withdrawn":
|
||||
raise HTTPException(409, "RFC is withdrawn")
|
||||
# §13.7: a retired entry is soft-deleted — refuse reads of every shape
|
||||
# (a 404, not a 409: the entry is not surfaced anywhere a browser looks).
|
||||
if row["state"] == "retired":
|
||||
raise HTTPException(404, "RFC not found")
|
||||
return row
|
||||
|
||||
|
||||
@@ -297,7 +312,7 @@ def _ensure_discussion_thread(slug: str, viewer) -> int:
|
||||
def _can_resolve(rfc, thread, viewer) -> bool:
|
||||
if viewer is None:
|
||||
return False
|
||||
if viewer.role in ("owner", "admin"):
|
||||
if auth.is_project_superuser(viewer, rfc["project_id"]):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
|
||||
+377
-387
File diff suppressed because it is too large
Load Diff
@@ -126,76 +126,22 @@ def make_router() -> APIRouter:
|
||||
@router.post("/api/rfcs/{slug}/invitations")
|
||||
async def create_invitation(slug: str, body: CreateInvitationBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc(slug)
|
||||
rfc = _require_rfc(slug, viewer)
|
||||
if not auth.can_invite_to_rfc(viewer, slug):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"Only the RFC's owner can invite collaborators",
|
||||
)
|
||||
|
||||
invitee_email = body.invitee_email.strip()
|
||||
role_in_rfc = body.role_in_rfc
|
||||
|
||||
# Refuse re-inviting an email that already has a pending
|
||||
# invitation on this RFC at the same role. Different-role
|
||||
# re-invite is allowed (upgrade discussant → contributor)
|
||||
# — the new row supersedes the old in the UI listing's
|
||||
# natural ordering, and acceptance of either picks up the
|
||||
# corresponding role.
|
||||
existing = db.conn().execute(
|
||||
"""
|
||||
SELECT id FROM rfc_invitations
|
||||
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
|
||||
AND role_in_rfc = ? AND status = 'pending'
|
||||
LIMIT 1
|
||||
""",
|
||||
(slug, invitee_email, role_in_rfc),
|
||||
).fetchone()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"{invitee_email} already has a pending {role_in_rfc} invitation for this RFC",
|
||||
)
|
||||
|
||||
token = _mint_token()
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO rfc_invitations
|
||||
(rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
|
||||
token, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now', ?))
|
||||
""",
|
||||
(
|
||||
slug,
|
||||
viewer.user_id,
|
||||
invitee_email,
|
||||
role_in_rfc,
|
||||
token,
|
||||
f"+{INVITATION_TTL_DAYS} days",
|
||||
),
|
||||
)
|
||||
invitation_id = cur.lastrowid
|
||||
|
||||
# Send the email — synchronous. A send failure logs and
|
||||
# returns; the row stays so the owner can recover via the
|
||||
# listing (which carries the token for an out-of-band share).
|
||||
_send_invitation_email(
|
||||
to_address=invitee_email,
|
||||
return issue_invitation(
|
||||
slug=slug,
|
||||
inviter_user_id=viewer.user_id,
|
||||
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
|
||||
invitee_email=body.invitee_email,
|
||||
role_in_rfc=body.role_in_rfc,
|
||||
rfc_title=rfc["title"],
|
||||
role_in_rfc=role_in_rfc,
|
||||
token=token,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": invitation_id,
|
||||
"rfc_slug": slug,
|
||||
"invitee_email": invitee_email,
|
||||
"role_in_rfc": role_in_rfc,
|
||||
"status": "pending",
|
||||
"token": token,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# GET /api/rfcs/<slug>/invitations
|
||||
# The owner's listing of every invitation on the RFC, regardless
|
||||
@@ -205,7 +151,7 @@ def make_router() -> APIRouter:
|
||||
@router.get("/api/rfcs/{slug}/invitations")
|
||||
async def list_invitations(slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc(slug)
|
||||
_require_rfc(slug, viewer)
|
||||
if not auth.can_invite_to_rfc(viewer, slug):
|
||||
raise HTTPException(
|
||||
403,
|
||||
@@ -261,7 +207,7 @@ def make_router() -> APIRouter:
|
||||
@router.post("/api/rfcs/{slug}/invitations/{invitation_id}/revoke")
|
||||
async def revoke_invitation(slug: str, invitation_id: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc(slug)
|
||||
_require_rfc(slug, viewer)
|
||||
if not auth.can_invite_to_rfc(viewer, slug):
|
||||
raise HTTPException(
|
||||
403,
|
||||
@@ -427,15 +373,18 @@ def make_router() -> APIRouter:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _require_rfc(slug: str):
|
||||
def _require_rfc(slug: str, viewer):
|
||||
"""The invitation surface only operates on a known, non-withdrawn
|
||||
RFC. We refuse 404 on unknown and 409 on withdrawn — mirrors the
|
||||
discussion endpoints' `_require_rfc_readable` shape."""
|
||||
discussion endpoints' `_require_rfc_readable` shape. The §22.5
|
||||
visibility gate is subtractive: a gated project's entries 404 to
|
||||
non-members (§22.7)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT slug, title, state FROM cached_rfcs WHERE slug = ?", (slug,),
|
||||
"SELECT slug, title, state, project_id FROM cached_rfcs WHERE slug = ?", (slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
auth.require_project_readable(viewer, row["project_id"])
|
||||
if row["state"] == "withdrawn":
|
||||
raise HTTPException(409, "RFC is withdrawn")
|
||||
return row
|
||||
@@ -473,6 +422,78 @@ def _effective_status(row) -> str:
|
||||
return "expired" if is_past else "pending"
|
||||
|
||||
|
||||
def issue_invitation(
|
||||
*,
|
||||
slug: str,
|
||||
inviter_user_id: int,
|
||||
inviter_display: str,
|
||||
invitee_email: str,
|
||||
role_in_rfc: str,
|
||||
rfc_title: str,
|
||||
) -> dict:
|
||||
"""Mint + persist + email one ``rfc_invitations`` row.
|
||||
|
||||
The single chokepoint for issuing an invitation: the owner's manual
|
||||
`POST /api/rfcs/{slug}/invitations` endpoint and roadmap #28 Part 3's
|
||||
accept path both route through here, so the dup-guard, token mint,
|
||||
insert, and transactional email stay identical.
|
||||
|
||||
Refuses (409) re-inviting an email that already has a pending
|
||||
invitation on this RFC at the same role. A different-role re-invite is
|
||||
allowed (the discussant → contributor upgrade) — the new row
|
||||
supersedes the old in the listing's natural ordering, and acceptance
|
||||
of either picks up the corresponding role.
|
||||
|
||||
Returns the new row's dict (including the raw token, for the owner's
|
||||
out-of-band share / the caller's record-keeping). A send failure logs
|
||||
and returns; the row stays so the owner can recover via the listing.
|
||||
"""
|
||||
invitee_email = invitee_email.strip()
|
||||
existing = db.conn().execute(
|
||||
"""
|
||||
SELECT id FROM rfc_invitations
|
||||
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
|
||||
AND role_in_rfc = ? AND status = 'pending'
|
||||
LIMIT 1
|
||||
""",
|
||||
(slug, invitee_email, role_in_rfc),
|
||||
).fetchone()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"{invitee_email} already has a pending {role_in_rfc} invitation for this RFC",
|
||||
)
|
||||
|
||||
token = _mint_token()
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO rfc_invitations
|
||||
(rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
|
||||
token, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now', ?))
|
||||
""",
|
||||
(slug, inviter_user_id, invitee_email, role_in_rfc, token, f"+{INVITATION_TTL_DAYS} days"),
|
||||
)
|
||||
invitation_id = cur.lastrowid
|
||||
|
||||
_send_invitation_email(
|
||||
to_address=invitee_email,
|
||||
inviter_display=inviter_display,
|
||||
rfc_title=rfc_title,
|
||||
role_in_rfc=role_in_rfc,
|
||||
token=token,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": invitation_id,
|
||||
"rfc_slug": slug,
|
||||
"invitee_email": invitee_email,
|
||||
"role_in_rfc": role_in_rfc,
|
||||
"status": "pending",
|
||||
"token": token,
|
||||
}
|
||||
|
||||
|
||||
def _mint_token() -> str:
|
||||
"""A 256-bit URL-safe token. The token shape is opaque to the
|
||||
consumer; the email link encodes it as a query param."""
|
||||
|
||||
@@ -213,14 +213,16 @@ def make_router(config: Config) -> APIRouter:
|
||||
@router.post("/api/rfcs/{slug}/watch")
|
||||
async def set_watch(slug: str, body: WatchBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
rfc = db.conn().execute("SELECT slug FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
rfc = db.conn().execute("SELECT project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
if rfc is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
|
||||
auth.require_project_readable(viewer, rfc["project_id"])
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at)
|
||||
VALUES (?, ?, ?, 'explicit', datetime('now'), datetime('now'))
|
||||
ON CONFLICT(user_id, rfc_slug) DO UPDATE SET
|
||||
ON CONFLICT(project_id, user_id, rfc_slug) DO UPDATE SET
|
||||
state = excluded.state,
|
||||
set_by = 'explicit',
|
||||
set_at = excluded.set_at
|
||||
@@ -557,7 +559,26 @@ def make_router(config: Config) -> APIRouter:
|
||||
# stays unauthenticated for dev (the v1 contract).
|
||||
import os as _os
|
||||
expected = _os.environ.get("WEBHOOK_EMAIL_BOUNCE_SECRET", "").strip()
|
||||
if expected:
|
||||
# v0.25.0 (audit 0026 M5): fail closed. An unset secret used to
|
||||
# leave this endpoint fully unauthenticated — anyone could suppress
|
||||
# any user's mail by POSTing their address (email_opt_out_all flip
|
||||
# below). Now an unset secret DISABLES the endpoint (503) instead
|
||||
# of opening it. A dev that genuinely wants it open opts in
|
||||
# explicitly with RFC_APP_INSECURE_BOUNCE_WEBHOOK=1, mirroring the
|
||||
# RFC_APP_INSECURE_WEBHOOKS dev-bypass on the Gitea hook.
|
||||
if not expected:
|
||||
if _os.environ.get("RFC_APP_INSECURE_BOUNCE_WEBHOOK", "").strip() == "1":
|
||||
log.warning(
|
||||
"email-bounce webhook running UNAUTHENTICATED "
|
||||
"(RFC_APP_INSECURE_BOUNCE_WEBHOOK=1) — never set this in production"
|
||||
)
|
||||
else:
|
||||
log.error(
|
||||
"email-bounce webhook refused: WEBHOOK_EMAIL_BOUNCE_SECRET is unset "
|
||||
"(set the secret to enable, or RFC_APP_INSECURE_BOUNCE_WEBHOOK=1 for dev)"
|
||||
)
|
||||
raise HTTPException(503, "Bounce webhook not configured")
|
||||
else:
|
||||
received = request.headers.get("X-Webhook-Secret", "")
|
||||
import hmac as _hmac
|
||||
if not received or not _hmac.compare_digest(expected, received):
|
||||
|
||||
+51
-30
@@ -23,7 +23,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver
|
||||
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver, projects as projects_mod, rfc_links
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
@@ -85,7 +85,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/pr-draft")
|
||||
async def draft_pr_text(slug: str, branch: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_active_rfc(slug)
|
||||
rfc = _require_active_rfc(slug, viewer)
|
||||
owner, repo = _owner_repo(rfc)
|
||||
path = _file_path_for(rfc)
|
||||
if not _branch_has_commits_ahead(slug, branch):
|
||||
@@ -128,7 +128,7 @@ def make_router(
|
||||
403,
|
||||
"This RFC's owner has not invited you to contribute PRs",
|
||||
)
|
||||
rfc = _require_active_rfc(slug)
|
||||
rfc = _require_active_rfc(slug, viewer)
|
||||
if branch == "main":
|
||||
raise HTTPException(409, "PRs open from non-main branches")
|
||||
owner, repo = _owner_repo(rfc)
|
||||
@@ -153,7 +153,7 @@ def make_router(
|
||||
"""
|
||||
INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
|
||||
VALUES (?, ?, 1, 'just-me')
|
||||
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET read_public = 1
|
||||
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET read_public = 1
|
||||
""",
|
||||
(slug, branch),
|
||||
)
|
||||
@@ -189,7 +189,7 @@ def make_router(
|
||||
"""
|
||||
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
|
||||
VALUES ('pr', ?, ?, ?)
|
||||
ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
||||
ON CONFLICT(project_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
||||
""",
|
||||
(slug, pr["number"], use_case),
|
||||
)
|
||||
@@ -207,12 +207,18 @@ def make_router(
|
||||
@router.get("/api/rfcs/{slug}/prs/{pr_number}")
|
||||
async def get_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
rfc = _require_active_rfc(slug)
|
||||
rfc = _require_active_rfc(slug, viewer)
|
||||
pr_row = _require_pr(slug, pr_number)
|
||||
owner, repo = _owner_repo(rfc)
|
||||
path = _file_path_for(rfc)
|
||||
head_branch = pr_row["head_branch"]
|
||||
|
||||
# Roadmap #28 Part 1: build the RFC auto-link index once for this
|
||||
# PR view (read-time enrichment against the live accepted-RFC
|
||||
# corpus; see rfc_links.py). exclude_slug suppresses self-links to
|
||||
# this RFC inside its own PR.
|
||||
link_index = rfc_links.build_index(db.conn(), exclude_slug=slug)
|
||||
|
||||
# §11.3: PRs are always public; no visibility check.
|
||||
main_fetched = await gitea.read_file(owner, repo, path, ref="main")
|
||||
main_body = _extract_body(rfc, (main_fetched or ("", ""))[0])
|
||||
@@ -259,6 +265,13 @@ def make_router(
|
||||
for r in msg_rows:
|
||||
messages_by_thread.setdefault(r["thread_id"], []).append(_serialize_message(r))
|
||||
|
||||
# Roadmap #28 Part 1: enrich every comment with RFC auto-link
|
||||
# segments (read-time; see rfc_links.py). The description is
|
||||
# enriched alongside it in the return dict below.
|
||||
for _msgs in messages_by_thread.values():
|
||||
for _m in _msgs:
|
||||
_m["text_segments"] = link_index.segment(_m["text"])
|
||||
|
||||
# Per-user seen cursor per §10.3. Anonymous viewers get no
|
||||
# cursor — they always see "everything new" but cannot advance
|
||||
# the cursor (no row to write to).
|
||||
@@ -325,6 +338,7 @@ def make_router(
|
||||
"pr_number": pr_number,
|
||||
"title": pr_row["title"],
|
||||
"description": pr_row["description"],
|
||||
"description_segments": link_index.segment(pr_row["description"]),
|
||||
"proposed_use_case": _pr_use_case(pr_number),
|
||||
"state": pr_row["state"],
|
||||
"opened_by": pr_row["opened_by"],
|
||||
@@ -359,7 +373,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/prs/{pr_number}/seen")
|
||||
async def advance_seen(slug: str, pr_number: int, body: PRSeenBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_active_rfc(slug)
|
||||
_require_active_rfc(slug, viewer)
|
||||
_require_pr(slug, pr_number)
|
||||
# Take the max of stored and incoming for both cursors so a
|
||||
# stale tab firing a seen-cursor advance after a fresher tab
|
||||
@@ -384,7 +398,7 @@ def make_router(
|
||||
INSERT INTO pr_seen
|
||||
(user_id, rfc_slug, pr_number, last_seen_commit_sha, last_seen_message_id, seen_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id, rfc_slug, pr_number) DO UPDATE SET
|
||||
ON CONFLICT(project_id, user_id, rfc_slug, pr_number) DO UPDATE SET
|
||||
last_seen_commit_sha = excluded.last_seen_commit_sha,
|
||||
last_seen_message_id = excluded.last_seen_message_id,
|
||||
seen_at = excluded.seen_at
|
||||
@@ -406,7 +420,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/prs/{pr_number}/review")
|
||||
async def post_review_thread(slug: str, pr_number: int, body: PRReviewBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_active_rfc(slug)
|
||||
_require_active_rfc(slug, viewer)
|
||||
pr_row = _require_pr(slug, pr_number)
|
||||
head_branch = pr_row["head_branch"]
|
||||
cur = db.conn().execute(
|
||||
@@ -433,7 +447,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/prs/{pr_number}/merge")
|
||||
async def merge_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_active_rfc(slug)
|
||||
rfc = _require_active_rfc(slug, viewer)
|
||||
pr_row = _require_pr(slug, pr_number)
|
||||
if not _can_merge(rfc, viewer):
|
||||
raise HTTPException(403, "Only arbiters, RFC owners, and app admins/owners may merge")
|
||||
@@ -465,7 +479,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/prs/{pr_number}/withdraw")
|
||||
async def withdraw_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_active_rfc(slug)
|
||||
rfc = _require_active_rfc(slug, viewer)
|
||||
pr_row = _require_pr(slug, pr_number)
|
||||
if not _can_withdraw(rfc, pr_row, viewer):
|
||||
raise HTTPException(403, "Only the contributor or an RFC owner/arbiter (or app admin/owner) may withdraw")
|
||||
@@ -496,7 +510,7 @@ def make_router(
|
||||
slug: str, pr_number: int, body: PRDescriptionBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_active_rfc(slug)
|
||||
rfc = _require_active_rfc(slug, viewer)
|
||||
pr_row = _require_pr(slug, pr_number)
|
||||
if not _can_edit_pr_text(rfc, pr_row, viewer):
|
||||
raise HTTPException(403, "Only the contributor or an RFC owner/arbiter (or admin/owner) may edit")
|
||||
@@ -521,7 +535,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/prs/{pr_number}/resolution-branch")
|
||||
async def start_resolution_branch(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_active_rfc(slug)
|
||||
rfc = _require_active_rfc(slug, viewer)
|
||||
pr_row = _require_pr(slug, pr_number)
|
||||
if pr_row["state"] != "open":
|
||||
raise HTTPException(409, f"PR is {pr_row['state']}, not open")
|
||||
@@ -589,7 +603,7 @@ def make_router(
|
||||
repo=repo,
|
||||
slug=slug,
|
||||
file_path=_file_path_for(rfc),
|
||||
is_super_draft=_is_super_draft(rfc),
|
||||
is_super_draft=_is_meta_resident(rfc),
|
||||
original_branch=original_branch,
|
||||
resolution_branch=resolution_branch,
|
||||
)
|
||||
@@ -647,42 +661,49 @@ def make_router(
|
||||
# Helpers (closures over config/gitea/etc.)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _require_rfc(slug: str):
|
||||
def _require_rfc(slug: str, viewer):
|
||||
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
# §22.5 visibility gate (subtractive, §22.7) — even §11.3 "PRs always
|
||||
# public" yields to a gated project: non-members get 404.
|
||||
auth.require_project_readable(viewer, row["project_id"])
|
||||
return row
|
||||
|
||||
def _require_active_rfc(slug: str):
|
||||
def _require_active_rfc(slug: str, viewer):
|
||||
"""Used by the §10 PR-flow read and write paths. Per §17's routing-
|
||||
collapse rule, a super-draft RFC also routes here — its body-edit
|
||||
PRs are meta-repo PRs with pr_kind='meta_body_edit', but the API
|
||||
surface is identical."""
|
||||
row = _require_rfc(slug)
|
||||
surface is identical. Under the meta-only topology (§1) an active
|
||||
RFC is meta-resident too (repo is null) — that is normal, not an
|
||||
error, so there is no per-RFC-repo precondition."""
|
||||
row = _require_rfc(slug, viewer)
|
||||
if row["state"] not in ("active", "super-draft"):
|
||||
raise HTTPException(409, f"RFC is {row['state']}")
|
||||
if row["state"] == "active" and not row["repo"]:
|
||||
raise HTTPException(409, "RFC has no repo")
|
||||
return row
|
||||
|
||||
def _is_super_draft(rfc) -> bool:
|
||||
return rfc["state"] == "super-draft"
|
||||
def _is_meta_resident(rfc) -> bool:
|
||||
"""Meta-only topology (§1): an entry lives in the meta repo's
|
||||
`rfcs/<slug>.md` (super-draft or active-in-place) unless it carries
|
||||
a legacy per-RFC `repo:` — which nothing does after the RFC-0001
|
||||
fold-back (§13.6). Drives the body/path/repo dispatch below."""
|
||||
return not rfc["repo"]
|
||||
|
||||
def _owner_repo(rfc) -> tuple[str, str]:
|
||||
if _is_super_draft(rfc):
|
||||
return config.gitea_org, config.meta_repo
|
||||
if _is_meta_resident(rfc):
|
||||
return config.gitea_org, (projects_mod.default_content_repo(config) or "")
|
||||
owner, repo = rfc["repo"].split("/", 1)
|
||||
return owner, repo
|
||||
|
||||
def _file_path_for(rfc) -> str:
|
||||
if _is_super_draft(rfc):
|
||||
if _is_meta_resident(rfc):
|
||||
return f"rfcs/{rfc['slug']}.md"
|
||||
return RFC_FILE_PATH
|
||||
|
||||
def _extract_body(rfc, file_contents: str) -> str:
|
||||
"""For super-draft entries the file on disk is the full
|
||||
"""For meta-resident entries the file on disk is the full
|
||||
frontmatter+body envelope; the editable body is entry.body."""
|
||||
if not _is_super_draft(rfc):
|
||||
if not _is_meta_resident(rfc):
|
||||
return file_contents
|
||||
try:
|
||||
entry = entry_mod.parse(file_contents)
|
||||
@@ -746,7 +767,7 @@ def make_router(
|
||||
return row["original_pr_number"] if row else None
|
||||
|
||||
async def _refresh_after_pr_write(rfc) -> None:
|
||||
if _is_super_draft(rfc):
|
||||
if _is_meta_resident(rfc):
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
await cache.refresh_meta_branches(config, gitea)
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
@@ -762,10 +783,10 @@ def make_router(
|
||||
|
||||
|
||||
def _can_merge(rfc, viewer) -> bool:
|
||||
"""§6.1 admin/owner OR §6.3 RFC owners/arbiters."""
|
||||
"""§6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters."""
|
||||
if viewer is None:
|
||||
return False
|
||||
if viewer.role in ("owner", "admin"):
|
||||
if auth.is_project_superuser(viewer, rfc["project_id"]):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
|
||||
+202
-15
@@ -19,6 +19,7 @@ from fastapi import HTTPException, Request
|
||||
from . import db
|
||||
from .bot import Actor
|
||||
from .config import Config
|
||||
from .projects import DEFAULT_PROJECT_ID
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -290,6 +291,163 @@ def require_admin(request: Request) -> SessionUser:
|
||||
return user
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# §22.6 / §22.7 — project-scoped authorization (the multi-project middle tier).
|
||||
#
|
||||
# A deployment hosts N projects (§22). Authorization for an action on an RFC is
|
||||
# the *most-permissive union* of three tiers — the actor's deployment role
|
||||
# (§6.1), their project role (§22.6), and their per-RFC authority (§6.3/§12) —
|
||||
# with the §22.5 visibility gate and the §6.2 write-mute *subtractive* on top
|
||||
# (§22.7). The per-RFC capability helpers below (`can_discuss_rfc`,
|
||||
# `can_contribute_to_rfc`, `can_invite_to_rfc`) compose all three tiers, so the
|
||||
# ~20 endpoint call sites inherit multi-project behavior unchanged.
|
||||
#
|
||||
# Through Slice M2 the only project is the migration-seeded `default` one (the
|
||||
# N=1 case, §22.13); a project is resolved from an RFC slug via
|
||||
# `cached_rfcs.project_id` (unique per slug while N=1). M3's registry mirror
|
||||
# lets a deployment declare a second project; these gates already hold then.
|
||||
#
|
||||
# OPERATOR DECISIONS (M2):
|
||||
# * implicit-on-public — on a `public` project a granted deployment
|
||||
# `contributor` keeps the pre-multi-project write *baseline* (propose
|
||||
# freely; an owned RFC's discuss/contribute is still gated by the v0.16.0
|
||||
# per-RFC invite). No project_members row is needed and no backfill runs,
|
||||
# so the N=1 case stays whole. Explicit project_members rows and
|
||||
# gated/unlisted visibility are where the new tier actually bites.
|
||||
# * preserve curation — the implicit-public baseline does NOT override per-RFC
|
||||
# owner curation; only an *explicit* project_contributor/project_admin grant
|
||||
# (or a deployment owner/admin) bypasses it. So §22.7's "project_contributor
|
||||
# ⊇ rfc_collaborators(contributor)" holds for explicit grants, while a plain
|
||||
# granted contributor on public behaves exactly as it did before M2.
|
||||
# ===========================================================================
|
||||
|
||||
_DEPLOYMENT_SUPERUSER_ROLES = ("owner", "admin")
|
||||
|
||||
|
||||
def project_visibility(project_id: str) -> str:
|
||||
"""The project's §22.5 visibility ('gated' | 'public' | 'unlisted'). A
|
||||
missing row reads as 'gated' — the safe default: an unknown project is
|
||||
invisible rather than open."""
|
||||
row = db.conn().execute(
|
||||
"SELECT visibility FROM projects WHERE id = ?", (project_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return "gated"
|
||||
return row["visibility"] or "gated"
|
||||
|
||||
|
||||
def project_member_role(user: SessionUser | None, project_id: str) -> str | None:
|
||||
"""The user's *explicit* §22.6 project_members role in this project, or
|
||||
None. This is the stored row only — it does not fold in the deployment tier
|
||||
or the implicit-on-public baseline (those live in the helpers below)."""
|
||||
if user is None:
|
||||
return None
|
||||
row = db.conn().execute(
|
||||
"SELECT role FROM project_members WHERE project_id = ? AND user_id = ?",
|
||||
(project_id, user.user_id),
|
||||
).fetchone()
|
||||
return row["role"] if row else None
|
||||
|
||||
|
||||
def project_of_rfc(rfc_slug: str) -> str:
|
||||
"""The project an RFC belongs to (`cached_rfcs.project_id`). Falls back to
|
||||
the default project when the slug isn't cached or the column is unset — the
|
||||
same N=1 default migration 026 backfills."""
|
||||
row = db.conn().execute(
|
||||
"SELECT project_id FROM cached_rfcs WHERE slug = ?", (rfc_slug,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return DEFAULT_PROJECT_ID
|
||||
return row["project_id"] or DEFAULT_PROJECT_ID
|
||||
|
||||
|
||||
def is_project_superuser(user: SessionUser | None, project_id: str) -> bool:
|
||||
"""Maximal authority within a project: a deployment owner/admin (superuser
|
||||
in every project, §22.7) or an explicit `project_admin` (§22.6). Both
|
||||
subsume the per-RFC owners/arbiters tier."""
|
||||
if user is None:
|
||||
return False
|
||||
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
|
||||
return True
|
||||
return project_member_role(user, project_id) == "project_admin"
|
||||
|
||||
|
||||
def can_read_project(user: SessionUser | None, project_id: str) -> bool:
|
||||
"""The §22.5 visibility gate. `public`/`unlisted` are readable by anyone
|
||||
(anonymous included — `unlisted` is link-only but the link still reads);
|
||||
`gated` is readable only by a deployment owner/admin or a granted project
|
||||
member of any role. Used as the subtractive read gate (a gated project's
|
||||
entries 404 to non-members)."""
|
||||
vis = project_visibility(project_id)
|
||||
if vis in ("public", "unlisted"):
|
||||
return True
|
||||
# gated — members + superusers only, subject to the §6 admission floor.
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
|
||||
return True
|
||||
return project_member_role(user, project_id) is not None
|
||||
|
||||
|
||||
def require_project_readable(user: SessionUser | None, project_id: str) -> None:
|
||||
"""Raise 404 when the project is not readable by this viewer (§22.5: a
|
||||
gated project is invisible to non-members — indistinguishable from absent,
|
||||
so the shape matches an unknown slug)."""
|
||||
if not can_read_project(user, project_id):
|
||||
raise HTTPException(status_code=404, detail="RFC not found")
|
||||
|
||||
|
||||
def _has_write_baseline(user: SessionUser | None, project_id: str) -> bool:
|
||||
"""The implicit-on-public write baseline: a granted deployment
|
||||
`contributor` on a `public` project carries the pre-multi-project
|
||||
write standing (still subject to per-RFC curation). Deployment
|
||||
owner/admin are handled by `is_project_superuser`; on gated/unlisted a
|
||||
contributor has no baseline and needs an explicit project role."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
return user.role == "contributor" and project_visibility(project_id) == "public"
|
||||
|
||||
|
||||
def can_contribute_in_project(user: SessionUser | None, project_id: str) -> bool:
|
||||
"""May the user contribute *new* content to the project (propose an entry)
|
||||
— the project-level (not RFC-specific) contribute standing. The union of
|
||||
the override grants (superuser / explicit project_contributor) and the
|
||||
implicit-public baseline, subject to the visibility gate."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
if not can_read_project(user, project_id):
|
||||
return False
|
||||
if is_project_superuser(user, project_id):
|
||||
return True
|
||||
if project_member_role(user, project_id) == "project_contributor":
|
||||
return True
|
||||
return _has_write_baseline(user, project_id)
|
||||
|
||||
|
||||
def can_discuss_in_project(user: SessionUser | None, project_id: str) -> bool:
|
||||
"""May the user participate in discussion in the project at all — the
|
||||
project-level discuss standing (project_viewer ⊇ discussant, §22.7).
|
||||
A superset of `can_contribute_in_project` (a contributor can discuss)."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
if not can_read_project(user, project_id):
|
||||
return False
|
||||
if is_project_superuser(user, project_id):
|
||||
return True
|
||||
if project_member_role(user, project_id) in ("project_viewer", "project_contributor"):
|
||||
return True
|
||||
return _has_write_baseline(user, project_id)
|
||||
|
||||
|
||||
def visible_project_ids(user: SessionUser | None) -> list[str]:
|
||||
"""Project ids whose entries may surface in a listing for this viewer — the
|
||||
§22.5 read gate applied to the catalog/idea lists. (The directory's
|
||||
`unlisted`-omission and the per-project routing are M3 concerns; for the M2
|
||||
listing filter we include every project the viewer can read.)"""
|
||||
rows = db.conn().execute("SELECT id FROM projects").fetchall()
|
||||
return [r["id"] for r in rows if can_read_project(user, r["id"])]
|
||||
|
||||
|
||||
# v0.16.0 (roadmap item #12): per-RFC membership helpers.
|
||||
#
|
||||
# These don't replace `require_contributor` — they layer on top of it for
|
||||
@@ -386,18 +544,30 @@ def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
return False
|
||||
if user.permission_state != "granted":
|
||||
return False
|
||||
if user.role in ("owner", "admin"):
|
||||
pid = project_of_rfc(rfc_slug)
|
||||
# §22.5 visibility gate is subtractive (§22.7) — no capability in a project
|
||||
# the viewer cannot even read.
|
||||
if not can_read_project(user, pid):
|
||||
return False
|
||||
# §22.7 union, override grants first — these bypass per-RFC curation
|
||||
# (project_viewer ⊇ discussant; project_admin / deployment superuser ⊇ all).
|
||||
if is_project_superuser(user, pid):
|
||||
return True
|
||||
if project_member_role(user, pid) in ("project_viewer", "project_contributor"):
|
||||
return True
|
||||
# per-RFC authority (union term).
|
||||
owners = _rfc_owners_set(rfc_slug)
|
||||
if not owners:
|
||||
# No owner to gate the invite-list — fall through to the
|
||||
# platform-granted contract. The first §13.1 claim engages
|
||||
# the gate; before that, anyone platform-granted can
|
||||
# contribute (mirrors the v0.5.0 / v0.6.0 contract).
|
||||
return True
|
||||
if user.gitea_login in owners:
|
||||
return True
|
||||
return is_rfc_collaborator(user, rfc_slug, role_in_rfc=None)
|
||||
if is_rfc_collaborator(user, rfc_slug, role_in_rfc=None):
|
||||
return True
|
||||
# implicit-public baseline (curation preserved): a granted deployment
|
||||
# contributor on a public project may discuss only while the RFC is
|
||||
# unclaimed. The first §13.1 claim engages the per-RFC gate, mirroring the
|
||||
# pre-multi-project v0.16.0 contract.
|
||||
if not owners and _has_write_baseline(user, pid):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
@@ -419,16 +589,27 @@ def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
return False
|
||||
if user.permission_state != "granted":
|
||||
return False
|
||||
if user.role in ("owner", "admin"):
|
||||
pid = project_of_rfc(rfc_slug)
|
||||
if not can_read_project(user, pid):
|
||||
return False
|
||||
# §22.7 union, override grants first (project_contributor ⊇
|
||||
# rfc_collaborators(contributor); project_admin / superuser ⊇ all).
|
||||
if is_project_superuser(user, pid):
|
||||
return True
|
||||
if project_member_role(user, pid) == "project_contributor":
|
||||
return True
|
||||
# per-RFC authority (union term). A 'discussant' row is NOT sufficient —
|
||||
# PRs are the higher-privilege surface.
|
||||
owners = _rfc_owners_set(rfc_slug)
|
||||
if not owners:
|
||||
# Same fall-through as can_discuss_rfc: until an owner exists,
|
||||
# the gate is open.
|
||||
return True
|
||||
if user.gitea_login in owners:
|
||||
return True
|
||||
return is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor")
|
||||
if is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor"):
|
||||
return True
|
||||
# implicit-public baseline (curation preserved): until an owner exists, a
|
||||
# granted deployment contributor on a public project may contribute.
|
||||
if not owners and _has_write_baseline(user, pid):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
@@ -439,7 +620,13 @@ def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
return False
|
||||
if user.permission_state != "granted":
|
||||
return False
|
||||
if user.role in ("owner", "admin"):
|
||||
pid = project_of_rfc(rfc_slug)
|
||||
if not can_read_project(user, pid):
|
||||
return False
|
||||
# Deployment owner/admin or project_admin (§22.6) may invite; otherwise
|
||||
# only the RFC's frontmatter owner. Per-RFC collaborators and the
|
||||
# implicit-public baseline do not get the invite-others power.
|
||||
if is_project_superuser(user, pid):
|
||||
return True
|
||||
return is_rfc_owner(user, rfc_slug)
|
||||
|
||||
|
||||
+158
-131
@@ -27,7 +27,7 @@ import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import db, notify
|
||||
from . import db, entry as entry_mod, notify
|
||||
from .gitea import Gitea, GiteaError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -695,111 +695,7 @@ class Bot:
|
||||
)
|
||||
return sha
|
||||
|
||||
# ----- §13 graduation: per-step primitives and rollback inverses -----
|
||||
|
||||
async def create_rfc_repo_for_graduation(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
repo_name: str,
|
||||
slug: str,
|
||||
title: str,
|
||||
) -> dict:
|
||||
"""§13.3 step 1: create the per-RFC repo.
|
||||
|
||||
Empty repo (no auto-init) — `seed_graduated_rfc` writes the first
|
||||
commit on `main`. Returns the Gitea repo payload."""
|
||||
repo = await self._gitea.create_org_repo(
|
||||
org, repo_name, description=f"RFC: {title}"
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"graduate_repo_create",
|
||||
rfc_slug=slug,
|
||||
details={"repo": f"{org}/{repo_name}", "title": title},
|
||||
)
|
||||
return repo
|
||||
|
||||
async def seed_graduated_rfc(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
repo_name: str,
|
||||
slug: str,
|
||||
title: str,
|
||||
rfc_body: str,
|
||||
rfc_id: str,
|
||||
meta_full: str,
|
||||
meta_path: str,
|
||||
owners: list[str],
|
||||
arbiters: list[str],
|
||||
tags: list[str],
|
||||
) -> str:
|
||||
"""§13.3 step 2: seed RFC.md, README.md, .rfc/metadata.yaml on the
|
||||
new repo's `main`. Three create_file calls; one audit row.
|
||||
|
||||
Returns the final commit sha on main.
|
||||
"""
|
||||
import yaml as _yaml
|
||||
|
||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
||||
# 2a) RFC.md — the document. The super-draft's body is migrated
|
||||
# verbatim per §13.3; if the body is empty we seed a minimal
|
||||
# placeholder so the editor has something to render on first open.
|
||||
body = rfc_body.strip() + "\n" if rfc_body.strip() else (
|
||||
f"# {title}\n\n*RFC.md to be filled in — the super-draft graduated with an empty body.*\n"
|
||||
)
|
||||
rfc_msg = _stamp_single(f"Seed RFC.md from super-draft {slug}", actor)
|
||||
rfc_result = await self._gitea.create_file(
|
||||
org, repo_name, "RFC.md",
|
||||
content=body, message=rfc_msg, branch="main",
|
||||
author_name=actor.display_name, author_email=ae,
|
||||
)
|
||||
# 2b) README.md — header pointing back at the meta-repo entry.
|
||||
readme = (
|
||||
f"# {rfc_id} — {title}\n\n"
|
||||
f"This repository carries the canonical text of {rfc_id}.\n"
|
||||
f"The meta-repo entry is `{meta_path}` in `{meta_full}`.\n\n"
|
||||
f"The RFC body is in `RFC.md`. Contributions go through the\n"
|
||||
f"app's §8 RFC view — open a branch, propose changes, land a PR.\n"
|
||||
)
|
||||
readme_msg = _stamp_single(f"Seed README.md for {rfc_id}", actor)
|
||||
await self._gitea.create_file(
|
||||
org, repo_name, "README.md",
|
||||
content=readme, message=readme_msg, branch="main",
|
||||
author_name=actor.display_name, author_email=ae,
|
||||
)
|
||||
# 2c) .rfc/metadata.yaml — mirror of meta-repo frontmatter for
|
||||
# future tooling (linting, automation, CI lookups).
|
||||
meta_yaml = _yaml.safe_dump(
|
||||
{
|
||||
"slug": slug, "title": title, "id": rfc_id,
|
||||
"owners": owners, "arbiters": arbiters, "tags": list(tags),
|
||||
},
|
||||
sort_keys=False,
|
||||
)
|
||||
meta_msg = _stamp_single(f"Seed .rfc/metadata.yaml for {rfc_id}", actor)
|
||||
meta_result = await self._gitea.create_file(
|
||||
org, repo_name, ".rfc/metadata.yaml",
|
||||
content=meta_yaml, message=meta_msg, branch="main",
|
||||
author_name=actor.display_name, author_email=ae,
|
||||
)
|
||||
last_sha = (
|
||||
meta_result.get("commit", {}).get("sha")
|
||||
or rfc_result.get("commit", {}).get("sha")
|
||||
or ""
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"graduate_repo_seed",
|
||||
rfc_slug=slug,
|
||||
branch_name="main",
|
||||
bot_commit_sha=last_sha,
|
||||
details={"repo": f"{org}/{repo_name}", "rfc_id": rfc_id},
|
||||
)
|
||||
return last_sha
|
||||
# ----- §13 graduation (meta-only): open + merge the flip PR -----
|
||||
|
||||
async def open_graduation_pr(
|
||||
self,
|
||||
@@ -810,12 +706,15 @@ class Bot:
|
||||
slug: str,
|
||||
new_file_contents: str,
|
||||
prior_sha: str,
|
||||
rfc_id: str,
|
||||
repo_full: str,
|
||||
rfc_id: str | None,
|
||||
owners: list[str],
|
||||
) -> dict:
|
||||
"""§13.3 step 3: open a PR against the meta repo that strips the
|
||||
super-draft body and fills graduation frontmatter fields. Branch
|
||||
"""§13.3 (meta-only): open a PR against the meta repo that flips the
|
||||
entry's frontmatter to `state: active` with the graduation stamps
|
||||
and — **optionally** — the integer `id`, **keeping the body
|
||||
unchanged** (§1 meta-only topology; no repo is created and no body
|
||||
is stripped). When `rfc_id` is None the entry graduates without a
|
||||
number (id stays null, slug is canonical per §2.3, §13.2). Branch
|
||||
name uses the `graduate-<slug>-<6hex>` shape — dash-separated like
|
||||
the other meta-repo branches per the §19.2 path-routing candidate.
|
||||
"""
|
||||
@@ -824,7 +723,7 @@ class Bot:
|
||||
branch = f"graduate-{slug}-{secrets.token_hex(3)}"
|
||||
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
||||
commit_subject = f"Graduate {slug} → {rfc_id}"
|
||||
commit_subject = f"Graduate {slug} → {rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
|
||||
commit_message = _stamp_single(commit_subject, actor)
|
||||
result = await self._gitea.update_file(
|
||||
org, meta_repo, f"rfcs/{slug}.md",
|
||||
@@ -839,16 +738,17 @@ class Bot:
|
||||
or result.get("content", {}).get("sha")
|
||||
or ""
|
||||
)
|
||||
pr_title = f"Graduate {slug} → {rfc_id}"
|
||||
pr_title = f"Graduate {slug} → {rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
|
||||
owners_str = ", ".join(owners) if owners else "(none)"
|
||||
id_line = f"- ID: `{rfc_id}`\n" if rfc_id else "- ID: (none — identified by slug)\n"
|
||||
pr_body_text = (
|
||||
f"Graduates super-draft `{slug}` to active.\n\n"
|
||||
f"- ID: `{rfc_id}`\n"
|
||||
f"- Repo: `{repo_full}`\n"
|
||||
f"{id_line}"
|
||||
f"- Owners: {owners_str}\n\n"
|
||||
f"The meta-repo entry becomes frontmatter-only; the canonical body\n"
|
||||
f"moves to `RFC.md` in the new repo. The graduation sequence is\n"
|
||||
f"transactional per §13.3."
|
||||
f"This is an in-place state flip per the meta-only topology\n"
|
||||
f"(SPEC §1, §13.3): the entry `rfcs/{slug}.md` keeps its body and\n"
|
||||
f"stays in the meta repo. Only the frontmatter changes — `state`,\n"
|
||||
f"`id`, and the graduation stamps."
|
||||
)
|
||||
_subject, pr_body = _stamp("", pr_body_text, actor)
|
||||
pr = await self._gitea.create_pull(
|
||||
@@ -862,7 +762,7 @@ class Bot:
|
||||
branch_name=branch,
|
||||
pr_number=pr["number"],
|
||||
bot_commit_sha=commit_sha,
|
||||
details={"pr_title": pr_title, "rfc_id": rfc_id, "repo": repo_full},
|
||||
details={"pr_title": pr_title, "rfc_id": rfc_id},
|
||||
)
|
||||
return pr
|
||||
|
||||
@@ -875,7 +775,7 @@ class Bot:
|
||||
pr_number: int,
|
||||
head_branch: str,
|
||||
slug: str,
|
||||
rfc_id: str,
|
||||
rfc_id: str | None,
|
||||
) -> None:
|
||||
"""§13.3 step 4: auto-merge the graduation PR with the admin as
|
||||
merge actor. Distinct action_kind so the audit log carries the
|
||||
@@ -891,7 +791,7 @@ class Bot:
|
||||
reports the transient state (belt-and-suspenders against the same
|
||||
race appearing in a different shape).
|
||||
"""
|
||||
subject = f"Graduate {slug} → {rfc_id}"
|
||||
subject = f"Graduate {slug} → {rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
|
||||
body = _trailer(actor)
|
||||
|
||||
try:
|
||||
@@ -912,27 +812,106 @@ class Bot:
|
||||
details={"rfc_id": rfc_id},
|
||||
)
|
||||
|
||||
# ----- §13.3 rollback inverses -----
|
||||
# ----- §13.7 retire / un-retire: open + merge a state-flip PR -----
|
||||
|
||||
async def delete_rfc_repo(
|
||||
async def open_retire_flip_pr(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
repo_name: str,
|
||||
meta_repo: str,
|
||||
slug: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""Undo of `create_rfc_repo_for_graduation`. Records `graduate_repo_delete`
|
||||
in the audit log with the rollback reason so the §13.3 stack's
|
||||
rendered failure surface can be reconstructed from `actions`."""
|
||||
await self._gitea.delete_repo(org, repo_name)
|
||||
new_file_contents: str,
|
||||
prior_sha: str,
|
||||
verb: str,
|
||||
target_state: str,
|
||||
) -> dict:
|
||||
"""§13.7: open a PR flipping `rfcs/<slug>.md` to `state:
|
||||
<target_state>` — for retire (`verb='retire'`, target `retired`)
|
||||
or un-retire (`verb='unretire'`, target the restored prior state).
|
||||
Only the frontmatter `state` changes; the body and every other
|
||||
field (including the integer `id`) are kept, so an un-retire
|
||||
restores the entry exactly. Branch shape mirrors graduation's
|
||||
`<verb>-<slug>-<6hex>`.
|
||||
"""
|
||||
import secrets
|
||||
|
||||
branch = f"{verb}-{slug}-{secrets.token_hex(3)}"
|
||||
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
||||
verb_title = "Retire" if verb == "retire" else "Un-retire"
|
||||
commit_subject = f"{verb_title} {slug}"
|
||||
commit_message = _stamp_single(commit_subject, actor)
|
||||
result = await self._gitea.update_file(
|
||||
org, meta_repo, f"rfcs/{slug}.md",
|
||||
content=new_file_contents,
|
||||
sha=prior_sha,
|
||||
message=commit_message,
|
||||
branch=branch,
|
||||
author_name=actor.display_name, author_email=ae,
|
||||
)
|
||||
commit_sha = (
|
||||
result.get("commit", {}).get("sha")
|
||||
or result.get("content", {}).get("sha")
|
||||
or ""
|
||||
)
|
||||
pr_title = f"{verb_title} {slug}"
|
||||
pr_body_text = (
|
||||
f"{verb_title}s `{slug}` (state → `{target_state}`).\n\n"
|
||||
f"This is an in-place frontmatter flip per SPEC §13.7 — the\n"
|
||||
f"entry `rfcs/{slug}.md` keeps its body and every other field;\n"
|
||||
f"only `state` changes."
|
||||
)
|
||||
_subject, pr_body = _stamp("", pr_body_text, actor)
|
||||
pr = await self._gitea.create_pull(
|
||||
org, meta_repo,
|
||||
title=pr_title, body=pr_body, head=branch, base="main",
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"graduate_repo_delete",
|
||||
f"{verb}_pr_open",
|
||||
rfc_slug=slug,
|
||||
details={"repo": f"{org}/{repo_name}", "reason": reason},
|
||||
branch_name=branch,
|
||||
pr_number=pr["number"],
|
||||
bot_commit_sha=commit_sha,
|
||||
details={"pr_title": pr_title, "target_state": target_state},
|
||||
)
|
||||
return pr
|
||||
|
||||
async def merge_retire_flip_pr(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
meta_repo: str,
|
||||
pr_number: int,
|
||||
head_branch: str,
|
||||
slug: str,
|
||||
verb: str,
|
||||
) -> None:
|
||||
"""§13.7: auto-merge the retire / un-retire flip PR (same
|
||||
mergeable-wait + retry shape as `merge_graduation_pr`)."""
|
||||
verb_title = "Retire" if verb == "retire" else "Un-retire"
|
||||
subject = f"{verb_title} {slug}"
|
||||
body = _trailer(actor)
|
||||
try:
|
||||
await self._gitea.wait_for_mergeable(org, meta_repo, pr_number)
|
||||
except TimeoutError as e:
|
||||
raise GiteaError(409, str(e)) from e
|
||||
await _merge_with_retry(
|
||||
self._gitea, org, meta_repo, pr_number,
|
||||
merge_message_title=subject, merge_message_body=body,
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
f"{verb}_pr_merge",
|
||||
rfc_slug=slug,
|
||||
branch_name=head_branch,
|
||||
pr_number=pr_number,
|
||||
details={},
|
||||
)
|
||||
|
||||
# ----- §13.3 (meta-only): cleanup of an unmerged flip PR -----
|
||||
|
||||
async def close_graduation_pr(
|
||||
self,
|
||||
@@ -1090,6 +1069,54 @@ class Bot:
|
||||
)
|
||||
return pr
|
||||
|
||||
# ----- §22.4c: mark-reviewed (direct main write) -----
|
||||
|
||||
async def mark_entry_reviewed(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
meta_repo: str,
|
||||
slug: str,
|
||||
reviewed_by: str,
|
||||
reviewed_at: str,
|
||||
) -> None:
|
||||
"""Clear §22.4c unreviewed on an active entry by rewriting its
|
||||
frontmatter on main. Stamps the commit with the §6.5 On-behalf-of
|
||||
trailer and writes an actions-log row, mirroring the graduation
|
||||
stamp's bot-write shape."""
|
||||
path = f"rfcs/{slug}.md"
|
||||
result = await self._gitea.read_file(org, meta_repo, path, ref="main")
|
||||
if result is None:
|
||||
raise GiteaError(404, f"{path} not found")
|
||||
text, sha = result
|
||||
e = entry_mod.parse(text)
|
||||
e.unreviewed = False
|
||||
e.reviewed_at = reviewed_at
|
||||
e.reviewed_by = reviewed_by
|
||||
commit_message = _stamp_single(f"Mark {slug} reviewed", actor)
|
||||
result = await self._gitea.update_file(
|
||||
org, meta_repo, path,
|
||||
content=entry_mod.serialize(e),
|
||||
sha=sha,
|
||||
message=commit_message,
|
||||
branch="main",
|
||||
author_name=actor.display_name,
|
||||
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
||||
)
|
||||
commit_sha = (
|
||||
result.get("commit", {}).get("sha")
|
||||
or result.get("content", {}).get("sha")
|
||||
or ""
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"mark_reviewed",
|
||||
rfc_slug=slug,
|
||||
bot_commit_sha=commit_sha,
|
||||
details={"reviewed_by": reviewed_by, "reviewed_at": reviewed_at},
|
||||
)
|
||||
|
||||
# ----- Per-RFC repo: seeding (test/dev fixtures, future graduation) -----
|
||||
|
||||
async def ensure_rfc_repo_seed(
|
||||
|
||||
+118
-33
@@ -27,7 +27,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from . import db, entry as entry_mod
|
||||
from . import db, entry as entry_mod, projects as projects_mod, registry as registry_mod
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
|
||||
@@ -35,16 +35,29 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
|
||||
"""Re-read rfcs/ on the meta repo and reconcile cached_rfcs.
|
||||
"""Re-read rfcs/ on every project's content repo and reconcile cached_rfcs.
|
||||
|
||||
Idempotent. Safe to call on every meta-repo webhook and on every
|
||||
reconciler sweep.
|
||||
§22 (Plan B): a deployment has N projects (§22.1), each with its own
|
||||
content_repo (§22.3). Mirror each into cached_rfcs stamped with that
|
||||
project's id, so a second project's corpus renders under /p/<id>/. Idempotent;
|
||||
safe on every content-repo webhook and reconciler sweep.
|
||||
"""
|
||||
org, repo = config.gitea_org, config.meta_repo
|
||||
org = config.gitea_org
|
||||
rows = db.conn().execute(
|
||||
"SELECT id, content_repo FROM projects WHERE content_repo IS NOT NULL AND content_repo != ''"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
log.warning("refresh_meta_repo: no projects with a content_repo yet; skipping")
|
||||
return
|
||||
for prow in rows:
|
||||
await _refresh_project_corpus(org, prow["id"], prow["content_repo"], gitea)
|
||||
|
||||
|
||||
async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: Gitea) -> None:
|
||||
try:
|
||||
files = await gitea.list_dir(org, repo, "rfcs", ref="main")
|
||||
except GiteaError as e:
|
||||
log.warning("refresh_meta_repo: cannot list rfcs/: %s", e)
|
||||
log.warning("refresh_meta_repo: project %s: cannot list rfcs/: %s", project_id, e)
|
||||
return
|
||||
|
||||
seen_slugs: set[str] = set()
|
||||
@@ -58,24 +71,28 @@ async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
|
||||
try:
|
||||
entry = entry_mod.parse(text)
|
||||
except Exception as parse_err:
|
||||
log.warning("refresh_meta_repo: skipping %s: %s", f["path"], parse_err)
|
||||
log.warning("refresh_meta_repo: %s: skipping %s: %s", project_id, f["path"], parse_err)
|
||||
continue
|
||||
if not entry.slug:
|
||||
log.warning("refresh_meta_repo: skipping %s: missing slug", f["path"])
|
||||
log.warning("refresh_meta_repo: %s: skipping %s: missing slug", project_id, f["path"])
|
||||
continue
|
||||
seen_slugs.add(entry.slug)
|
||||
_upsert_cached_rfc(entry, body_sha=sha)
|
||||
_upsert_cached_rfc(entry, body_sha=sha, project_id=project_id)
|
||||
|
||||
# Mark entries removed from the meta repo as withdrawn-without-trace.
|
||||
# In practice the spec keeps withdrawn entries in rfcs/ as historical
|
||||
# record (§3), so this branch fires only for entries deleted out of
|
||||
# band. We leave the row but flag it for reconciler attention.
|
||||
existing = {row["slug"] for row in db.conn().execute("SELECT slug FROM cached_rfcs")}
|
||||
# Entries removed from a project's rfcs/ — the spec keeps withdrawn entries
|
||||
# as historical record (§3), so this fires only for out-of-band deletes;
|
||||
# leave the row, scoped to this project, for reconciler attention.
|
||||
existing = {
|
||||
row["slug"]
|
||||
for row in db.conn().execute(
|
||||
"SELECT slug FROM cached_rfcs WHERE project_id = ?", (project_id,)
|
||||
)
|
||||
}
|
||||
for missing in existing - seen_slugs:
|
||||
log.info("refresh_meta_repo: %s no longer in rfcs/ — leaving cache row in place", missing)
|
||||
log.info("refresh_meta_repo: %s/%s no longer in rfcs/ — leaving cache row", project_id, missing)
|
||||
|
||||
|
||||
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
|
||||
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, project_id: str = "default") -> None:
|
||||
# §6.6: models_json stays NULL when the frontmatter key is absent
|
||||
# (inherit operator universe) and '[]' for the explicit opt-out.
|
||||
models_json = json.dumps(entry.models) if entry.models is not None else None
|
||||
@@ -87,9 +104,11 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
|
||||
INSERT INTO cached_rfcs
|
||||
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
|
||||
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
|
||||
models_json, funder_login, body, body_sha, last_entry_commit_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
ON CONFLICT(slug) DO UPDATE SET
|
||||
models_json, funder_login, body, body_sha,
|
||||
unreviewed, reviewed_at, reviewed_by, project_id,
|
||||
last_entry_commit_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
ON CONFLICT(project_id, slug) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
state = excluded.state,
|
||||
rfc_id = excluded.rfc_id,
|
||||
@@ -105,6 +124,9 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
|
||||
funder_login = excluded.funder_login,
|
||||
body = excluded.body,
|
||||
body_sha = excluded.body_sha,
|
||||
unreviewed = excluded.unreviewed,
|
||||
reviewed_at = excluded.reviewed_at,
|
||||
reviewed_by = excluded.reviewed_by,
|
||||
last_entry_commit_at = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
@@ -125,6 +147,10 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
|
||||
funder_login,
|
||||
entry.body,
|
||||
body_sha,
|
||||
1 if entry.unreviewed else 0,
|
||||
entry.reviewed_at,
|
||||
entry.reviewed_by,
|
||||
project_id,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -184,7 +210,7 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
|
||||
"""
|
||||
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
|
||||
VALUES (?, ?, ?, 'open', ?)
|
||||
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
|
||||
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
head_sha = excluded.head_sha,
|
||||
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
|
||||
last_commit_at = excluded.last_commit_at
|
||||
@@ -219,6 +245,19 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
|
||||
open_pulls, closed_pulls = [], []
|
||||
for pull in open_pulls + closed_pulls:
|
||||
head_branch = pull.get("head", {}).get("ref", "")
|
||||
# Same deleted-branch recovery as refresh_meta_pulls: a merged-and-
|
||||
# deleted PR's `head.ref` collapses to `refs/pull/<N>/head`. Here
|
||||
# the slug is known (param), so state still updates correctly and
|
||||
# no ghost forms — but blindly storing the sentinel would clobber
|
||||
# the real branch name api_prs.py relies on as a fallback ref when
|
||||
# the merge commit is gone. Recover it from the stored row.
|
||||
if not head_branch or head_branch.startswith("refs/pull/"):
|
||||
prior = db.conn().execute(
|
||||
"SELECT head_branch FROM cached_prs WHERE repo = ? AND pr_number = ?",
|
||||
(repo_full, pull["number"]),
|
||||
).fetchone()
|
||||
if prior and prior["head_branch"]:
|
||||
head_branch = prior["head_branch"]
|
||||
state = _state_from_pull(pull)
|
||||
gitea_opener = (pull.get("user") or {}).get("login") or ""
|
||||
opened_by = _resolve_actor(
|
||||
@@ -307,7 +346,11 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
|
||||
structurally `edit/<slug>/<auto-name>` per §9.5, with dashes in place
|
||||
of slashes per the §19.2 path-routing candidate.
|
||||
"""
|
||||
org, repo = config.gitea_org, config.meta_repo
|
||||
org = config.gitea_org
|
||||
repo = projects_mod.default_content_repo(config)
|
||||
if not repo:
|
||||
log.warning("refresh_meta_branches: default project has no content_repo yet; skipping")
|
||||
return
|
||||
try:
|
||||
branches = await gitea.list_branches(org, repo)
|
||||
except GiteaError as e:
|
||||
@@ -329,16 +372,20 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
|
||||
if not slug:
|
||||
continue
|
||||
rfc = db.conn().execute(
|
||||
"SELECT state FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
"SELECT state, repo FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
if not rfc or rfc["state"] != "super-draft":
|
||||
# Meta-only topology (§1): edit branches live on the meta repo for
|
||||
# every meta-resident entry — super-drafts and active RFCs alike
|
||||
# (active RFCs are graduated in place and keep editing here, §13).
|
||||
# A legacy per-RFC repo (repo set) is the only thing excluded.
|
||||
if not rfc or rfc["repo"] or rfc["state"] not in ("super-draft", "active"):
|
||||
continue
|
||||
edit_keys_seen.add((slug, name))
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
|
||||
VALUES (?, ?, ?, 'open', ?)
|
||||
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
|
||||
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
head_sha = excluded.head_sha,
|
||||
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
|
||||
last_commit_at = excluded.last_commit_at
|
||||
@@ -352,14 +399,15 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
|
||||
# diverges from this single point.
|
||||
if meta_main_sha:
|
||||
super_drafts = db.conn().execute(
|
||||
"SELECT slug FROM cached_rfcs WHERE state = 'super-draft'"
|
||||
"SELECT slug FROM cached_rfcs "
|
||||
"WHERE repo IS NULL AND state IN ('super-draft', 'active')"
|
||||
).fetchall()
|
||||
for r in super_drafts:
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
|
||||
VALUES (?, 'main', ?, 'open', ?)
|
||||
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
|
||||
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
head_sha = excluded.head_sha,
|
||||
last_commit_at = excluded.last_commit_at
|
||||
""",
|
||||
@@ -374,7 +422,8 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
|
||||
SELECT b.rfc_slug, b.branch_name
|
||||
FROM cached_branches b
|
||||
JOIN cached_rfcs r ON r.slug = b.rfc_slug
|
||||
WHERE r.state = 'super-draft'
|
||||
WHERE r.repo IS NULL
|
||||
AND r.state IN ('super-draft', 'active')
|
||||
AND b.state != 'deleted'
|
||||
AND b.branch_name != 'main'
|
||||
"""
|
||||
@@ -418,19 +467,50 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
|
||||
`On-behalf-of:` trailer from the PR body, then to the raw Gitea
|
||||
login as last resort.
|
||||
"""
|
||||
org, repo = config.gitea_org, config.meta_repo
|
||||
org = config.gitea_org
|
||||
bot_login = config.gitea_bot_user
|
||||
rows = db.conn().execute(
|
||||
"SELECT id, content_repo FROM projects WHERE content_repo IS NOT NULL AND content_repo != ''"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
log.warning("refresh_meta_pulls: no projects with a content_repo yet; skipping")
|
||||
return
|
||||
for prow in rows:
|
||||
await _refresh_project_pulls(org, prow["id"], prow["content_repo"], gitea, bot_login)
|
||||
|
||||
|
||||
async def _refresh_project_pulls(
|
||||
org: str, project_id: str, repo: str, gitea: Gitea, bot_login: str
|
||||
) -> None:
|
||||
repo_full = f"{org}/{repo}"
|
||||
try:
|
||||
open_pulls = await gitea.list_pulls(org, repo, state="open")
|
||||
closed_pulls = await gitea.list_pulls(org, repo, state="closed")
|
||||
except GiteaError as e:
|
||||
log.warning("refresh_meta_pulls: %s", e)
|
||||
log.warning("refresh_meta_pulls: project %s: %s", project_id, e)
|
||||
return
|
||||
|
||||
bot_login = config.gitea_bot_user
|
||||
|
||||
for pull in open_pulls + closed_pulls:
|
||||
head_branch = pull.get("head", {}).get("ref", "")
|
||||
# A merged-and-deleted PR's branch is no longer reported by Gitea
|
||||
# as its real name — the `head.ref` collapses to the synthetic
|
||||
# `refs/pull/<N>/head` sentinel (or empty). The slug + kind both
|
||||
# derive from the branch name, so a deleted branch would parse to
|
||||
# slug=None and the row would be skipped forever, freezing the
|
||||
# cached_prs row at its last-seen `state='open'` — a permanent
|
||||
# ghost "pending idea" for an entry that has actually merged
|
||||
# (caught when the operator authoring lane in ROADMAP #35 merged
|
||||
# an idea PR with the branch deleted; the web UX leaves branches
|
||||
# in place so it never tripped this). Recover the original branch
|
||||
# from the row we already stored when the PR was open — that row
|
||||
# retains the real `head_branch` (migration 002).
|
||||
if not head_branch or head_branch.startswith("refs/pull/"):
|
||||
prior = db.conn().execute(
|
||||
"SELECT head_branch FROM cached_prs WHERE repo = ? AND pr_number = ?",
|
||||
(repo_full, pull["number"]),
|
||||
).fetchone()
|
||||
if prior and prior["head_branch"]:
|
||||
head_branch = prior["head_branch"]
|
||||
slug = _slug_from_head_branch(head_branch)
|
||||
if slug is None:
|
||||
continue
|
||||
@@ -464,8 +544,8 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
|
||||
INSERT INTO cached_prs
|
||||
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
|
||||
opened_by, opened_at, merged_at, closed_at,
|
||||
head_branch, base_branch, head_sha, merge_commit_sha)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
head_branch, base_branch, head_sha, merge_commit_sha, project_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(repo, pr_number) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
description = excluded.description,
|
||||
@@ -492,6 +572,7 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
|
||||
(pull.get("base") or {}).get("ref") or "main",
|
||||
(pull.get("head") or {}).get("sha"),
|
||||
merge_commit_sha,
|
||||
project_id,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -625,6 +706,10 @@ class Reconciler:
|
||||
async def sweep(self) -> None:
|
||||
log.info("reconciler: starting sweep")
|
||||
try:
|
||||
try:
|
||||
await registry_mod.refresh_registry(self._config, self._gitea)
|
||||
except Exception:
|
||||
log.exception("reconciler: registry refresh failed; keeping last-good projects")
|
||||
await refresh_meta_repo(self._config, self._gitea)
|
||||
await refresh_meta_branches(self._config, self._gitea)
|
||||
await refresh_meta_pulls(self._config, self._gitea)
|
||||
|
||||
@@ -32,7 +32,7 @@ class Config:
|
||||
gitea_bot_user: str
|
||||
gitea_bot_token: str
|
||||
gitea_org: str
|
||||
meta_repo: str
|
||||
registry_repo: str
|
||||
oauth_client_id: str
|
||||
oauth_client_secret: str
|
||||
app_url: str
|
||||
@@ -44,14 +44,15 @@ class Config:
|
||||
anthropic_api_key: str = ""
|
||||
google_api_key: str = ""
|
||||
openai_api_key: str = ""
|
||||
default_project_id: str = ""
|
||||
|
||||
@property
|
||||
def redirect_uri(self) -> str:
|
||||
return f"{self.app_url}/auth/callback"
|
||||
|
||||
@property
|
||||
def meta_repo_full(self) -> str:
|
||||
return f"{self.gitea_org}/{self.meta_repo}"
|
||||
def registry_repo_full(self) -> str:
|
||||
return f"{self.gitea_org}/{self.registry_repo}"
|
||||
|
||||
|
||||
def load_config() -> Config:
|
||||
@@ -79,7 +80,7 @@ def load_config() -> Config:
|
||||
gitea_bot_user=_required("GITEA_BOT_USER"),
|
||||
gitea_bot_token=_required("GITEA_BOT_TOKEN"),
|
||||
gitea_org=_required("GITEA_ORG"),
|
||||
meta_repo=_optional("META_REPO", "meta"),
|
||||
registry_repo=_required("REGISTRY_REPO"),
|
||||
oauth_client_id=_required("OAUTH_CLIENT_ID"),
|
||||
oauth_client_secret=_required("OAUTH_CLIENT_SECRET"),
|
||||
app_url=_optional("APP_URL", "http://localhost:8000").rstrip("/"),
|
||||
@@ -91,4 +92,5 @@ def load_config() -> Config:
|
||||
anthropic_api_key=_optional("ANTHROPIC_API_KEY"),
|
||||
google_api_key=_optional("GOOGLE_API_KEY"),
|
||||
openai_api_key=_optional("OPENAI_API_KEY"),
|
||||
default_project_id=_optional("DEFAULT_PROJECT_ID"),
|
||||
)
|
||||
|
||||
+23
-1
@@ -48,7 +48,29 @@ def run_migrations(config: Config) -> None:
|
||||
if version in applied:
|
||||
continue
|
||||
sql = path.read_text()
|
||||
conn.executescript("BEGIN; " + sql + "; COMMIT;")
|
||||
if "-- migrate:no-foreign-keys" in sql:
|
||||
# SQLite table rebuilds (changing a PRIMARY KEY / UNIQUE, e.g.
|
||||
# folding project_id into a composite key) follow the official
|
||||
# 12-step ALTER procedure, which requires FK enforcement OFF —
|
||||
# and `PRAGMA foreign_keys` is a no-op *inside* a transaction, so
|
||||
# it must be toggled here, around the script. The connection is
|
||||
# in autocommit mode (isolation_level=None), so the PRAGMA takes
|
||||
# effect immediately. We re-enable and run foreign_key_check
|
||||
# after, failing the migration loudly if the rebuild left any
|
||||
# dangling reference.
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
conn.executescript("BEGIN; " + sql + "; COMMIT;")
|
||||
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
|
||||
if violations:
|
||||
raise RuntimeError(
|
||||
f"migration {version} left foreign-key violations: "
|
||||
f"{[tuple(v) for v in violations]}"
|
||||
)
|
||||
finally:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
else:
|
||||
conn.executescript("BEGIN; " + sql + "; COMMIT;")
|
||||
conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (version,))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
+32
-21
@@ -97,6 +97,13 @@ class IssueOutcome:
|
||||
raw_token: str
|
||||
row_id: int
|
||||
|
||||
@property
|
||||
def cookie_value(self) -> str:
|
||||
"""The value to put in the `rfc_device_trust` cookie: the row-id
|
||||
selector joined to the raw token (v0.25.0 / audit 0026 M1). The
|
||||
selector lets `lookup` read one indexed row instead of scanning."""
|
||||
return f"{self.row_id}.{self.raw_token}"
|
||||
|
||||
|
||||
def _new_token() -> str:
|
||||
return secrets.token_urlsafe(TOKEN_BYTES)
|
||||
@@ -173,33 +180,37 @@ def lookup(raw_token: str) -> LookupOutcome:
|
||||
if not raw:
|
||||
return LookupOutcome(ok=False, user=None, reason="invalid")
|
||||
|
||||
# The unique index on `device_token_hash` would let us SELECT by
|
||||
# hash if bcrypt were a stable hash, but bcrypt incorporates a
|
||||
# per-row salt — equal tokens produce different hashes. We walk
|
||||
# the candidate set instead. In practice the set is small (a
|
||||
# human has a handful of trusted devices) and bcrypt is cheap on
|
||||
# the order of milliseconds; the walk is bounded by the user's
|
||||
# active device count.
|
||||
# v0.25.0 (audit 0026 M1): the cookie is "<row_id>.<raw_token>". We
|
||||
# parse the row-id selector and read exactly ONE row by its indexed
|
||||
# primary key, then bcrypt-check the token against that single row.
|
||||
#
|
||||
# We don't pre-filter by `revoked_at IS NULL` here so that a
|
||||
# token presented for a recently-revoked row produces a
|
||||
# 'revoked' outcome (the endpoint surfaces a different shape).
|
||||
# Same for expired: we let the walk hit and classify after.
|
||||
rows = db.conn().execute(
|
||||
# The previous shape read EVERY device_trust row (all users, including
|
||||
# revoked/expired) and bcrypt-checked each — an unauthenticated
|
||||
# CPU-amplification DoS reachable at /auth/device-trust/start that
|
||||
# grew without bound as the table accumulated. bcrypt's per-row salt
|
||||
# is why we can't SELECT by hash; carrying the row-id in the cookie is
|
||||
# the standard fix (the id is not secret; the token still is).
|
||||
selector, sep, token = raw.partition(".")
|
||||
if not sep or not selector.isdigit() or not token:
|
||||
# Legacy bare-token cookies (pre-v0.25.0) and malformed values land
|
||||
# here. We refuse rather than fall back to a full-table scan, so
|
||||
# the amplification path is fully closed; affected users simply
|
||||
# re-authenticate once via OTC/passcode and get a new cookie.
|
||||
return LookupOutcome(ok=False, user=None, reason="invalid")
|
||||
|
||||
matched = db.conn().execute(
|
||||
"""
|
||||
SELECT id, user_id, device_token_hash, expires_at, revoked_at
|
||||
FROM device_trust
|
||||
ORDER BY id DESC
|
||||
WHERE id = ?
|
||||
""",
|
||||
).fetchall()
|
||||
(int(selector),),
|
||||
).fetchone()
|
||||
|
||||
matched = None
|
||||
for row in rows:
|
||||
if _check(raw, row["device_token_hash"]):
|
||||
matched = row
|
||||
break
|
||||
|
||||
if matched is None:
|
||||
# One bcrypt check, against the selected row only. A wrong/forged token
|
||||
# for a real id reads as 'unknown' (cookie cleared), same as a missing
|
||||
# row — a probing client can't distinguish the two.
|
||||
if matched is None or not _check(token, matched["device_token_hash"]):
|
||||
return LookupOutcome(ok=False, user=None, reason="unknown")
|
||||
|
||||
if matched["revoked_at"] is not None:
|
||||
|
||||
@@ -60,12 +60,17 @@ def build_envelope(
|
||||
`from_name` is the display label that goes through `formataddr`
|
||||
so spaces / commas in the display string are encoded correctly.
|
||||
|
||||
`body_plain` is mandatory. `body_html`, if supplied, lands as the
|
||||
second part of a `multipart/alternative` body — mail clients
|
||||
that prefer HTML render it; clients that don't fall back to the
|
||||
plain part. The text/plain part comes first per RFC 2046, so a
|
||||
plain-text client that picks the first body gets the readable
|
||||
text.
|
||||
`body_plain` is mandatory. `body_html` is **reserved and not yet
|
||||
enabled** (security-audit-0026 I3): no send path supplies it today —
|
||||
every rfc-app mail is plain text — and passing it raises
|
||||
`NotImplementedError`. The parameter is kept in the signature for
|
||||
documented future symmetry: when HTML mail is enabled it will land
|
||||
as the second part of a `multipart/alternative` body (text/plain
|
||||
first per RFC 2046, so a plain-text client picking the first part
|
||||
still gets the readable text). Enabling it is a deliberate act — the
|
||||
caller MUST HTML-escape any user content into `body_html` first (cf.
|
||||
the C1 stored-XSS class: a mail client renders the HTML) and remove
|
||||
the guard below in the same change.
|
||||
|
||||
`reply_to`, when set, lets a send path point replies at a
|
||||
different mailbox than the From line (e.g., a watcher
|
||||
@@ -131,13 +136,20 @@ def build_envelope(
|
||||
# idempotent and not require auth. See
|
||||
# `api_notifications.py` for the receiver.
|
||||
msg["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
|
||||
if body_html:
|
||||
# multipart/alternative: text/plain first, text/html second.
|
||||
# `set_content` sets the first part (and the message's main
|
||||
# body); `add_alternative` adds the second part and
|
||||
# restructures the message as multipart/alternative.
|
||||
msg.set_content(body_plain)
|
||||
msg.add_alternative(body_html, subtype="html")
|
||||
else:
|
||||
msg.set_content(body_plain)
|
||||
if body_html is not None:
|
||||
# I3 (security-audit-0026): the multipart/alternative HTML path
|
||||
# is intentionally NOT enabled. No send path passes `body_html`
|
||||
# today, and emitting an HTML body built from user-supplied
|
||||
# content without escaping it first would reintroduce the C1
|
||||
# stored-XSS class in the mail channel (the recipient's client
|
||||
# renders the HTML). Fail loudly here rather than silently
|
||||
# shipping HTML: enabling HTML mail is a deliberate change that
|
||||
# MUST HTML-escape user content at the call site and remove this
|
||||
# guard together. The text/plain path below is the only live one.
|
||||
raise NotImplementedError(
|
||||
"HTML email is not enabled (security-audit-0026 I3): do not "
|
||||
"pass body_html until user content is HTML-escaped at the "
|
||||
"call site and this guard is intentionally removed."
|
||||
)
|
||||
msg.set_content(body_plain)
|
||||
return msg
|
||||
|
||||
+20
-1
@@ -30,7 +30,7 @@ _ABSENT = object()
|
||||
class Entry:
|
||||
slug: str
|
||||
title: str
|
||||
state: str = "super-draft" # super-draft | active | withdrawn
|
||||
state: str = "super-draft" # super-draft | active | withdrawn | retired (§3, §13.7)
|
||||
id: str | None = None # 'RFC-NNNN' or None
|
||||
repo: str | None = None
|
||||
proposed_by: str = ""
|
||||
@@ -50,6 +50,13 @@ class Entry:
|
||||
# operator credentials per §18 are used. The binding is inert until
|
||||
# the named user has a funder_consents row (the hybrid two-key rule).
|
||||
funder: str | None = None
|
||||
# §22.4c: an `active` entry that landed without a human review gate
|
||||
# carries unreviewed=True until an owner clears it. Orthogonal to
|
||||
# `state`; only meaningful for active entries. reviewed_at/reviewed_by
|
||||
# are the provenance of the clear, paralleling graduated_at/by.
|
||||
unreviewed: bool = False
|
||||
reviewed_at: str | None = None
|
||||
reviewed_by: str | None = None
|
||||
body: str = ""
|
||||
|
||||
|
||||
@@ -66,6 +73,7 @@ def parse(text: str) -> Entry:
|
||||
models = [str(m) for m in raw_models]
|
||||
raw_funder = fm.get("funder")
|
||||
funder = str(raw_funder).strip() if raw_funder else None
|
||||
unreviewed = bool(fm.get("unreviewed") or False)
|
||||
return Entry(
|
||||
slug=str(fm.get("slug") or ""),
|
||||
title=str(fm.get("title") or ""),
|
||||
@@ -81,6 +89,9 @@ def parse(text: str) -> Entry:
|
||||
tags=list(fm.get("tags") or []),
|
||||
models=models,
|
||||
funder=funder,
|
||||
unreviewed=unreviewed,
|
||||
reviewed_at=fm.get("reviewed_at") or None,
|
||||
reviewed_by=fm.get("reviewed_by") or None,
|
||||
body=body,
|
||||
)
|
||||
|
||||
@@ -110,6 +121,14 @@ def serialize(entry: Entry) -> str:
|
||||
# second meaning here as with `models:`; one set of semantics.
|
||||
if entry.funder:
|
||||
fm["funder"] = entry.funder
|
||||
# §22.4c: emit unreviewed only when True (a super-draft / reviewed
|
||||
# active entry leaves the key absent → frontmatter stays minimal).
|
||||
if entry.unreviewed:
|
||||
fm["unreviewed"] = True
|
||||
if entry.reviewed_at:
|
||||
fm["reviewed_at"] = entry.reviewed_at
|
||||
if entry.reviewed_by:
|
||||
fm["reviewed_by"] = entry.reviewed_by
|
||||
yaml_text = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False).rstrip()
|
||||
body = entry.body.lstrip("\n")
|
||||
if body:
|
||||
|
||||
@@ -220,7 +220,7 @@ def add_consent(user_id: int, slug: str) -> None:
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO funder_consents (user_id, rfc_slug) VALUES (?, ?)
|
||||
ON CONFLICT(user_id, rfc_slug) DO NOTHING
|
||||
ON CONFLICT(project_id, user_id, rfc_slug) DO NOTHING
|
||||
""",
|
||||
(user_id, slug),
|
||||
)
|
||||
|
||||
+17
-8
@@ -30,7 +30,7 @@ import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import db
|
||||
from . import db, projects as projects_mod
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
|
||||
@@ -284,9 +284,10 @@ async def _delete_branch_via_bot(
|
||||
reason: str,
|
||||
) -> bool:
|
||||
"""Call `bot.delete_branch` with the system actor. Resolves the
|
||||
`(org, repo)` pair from the slug: super-draft edit branches and
|
||||
graduation branches live on the meta repo; active-RFC branches
|
||||
live on the per-RFC repo named by `cached_rfcs.repo`.
|
||||
`(org, repo)` pair from the slug: under the meta-only topology (§1)
|
||||
every meta-resident entry's edit branches and graduation branches
|
||||
live on the meta repo; a legacy per-RFC repo (a `repo:` that survives
|
||||
from before the fold-back, §13.6) is named by `cached_rfcs.repo`.
|
||||
|
||||
Returns True on a clean delete; False if the rfc row is missing
|
||||
(we leave the branch row in place — a subsequent reconciler sweep
|
||||
@@ -297,12 +298,20 @@ async def _delete_branch_via_bot(
|
||||
if rfc is None:
|
||||
log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch)
|
||||
return False
|
||||
if rfc["state"] == "super-draft":
|
||||
owner, repo = config.gitea_org, config.meta_repo
|
||||
elif rfc["state"] == "active" and rfc["repo"] and "/" in rfc["repo"]:
|
||||
if not rfc["repo"]:
|
||||
repo = projects_mod.default_content_repo(config)
|
||||
if not repo:
|
||||
log.warning(
|
||||
"hygiene: default project has no content_repo; skipping branch delete for %s/%s",
|
||||
slug, branch,
|
||||
)
|
||||
return False
|
||||
owner = config.gitea_org
|
||||
elif "/" in rfc["repo"]:
|
||||
owner, repo = rfc["repo"].split("/", 1)
|
||||
else:
|
||||
log.warning("hygiene: cannot resolve repo for %s state=%s", slug, rfc["state"])
|
||||
log.warning("hygiene: cannot resolve repo for %s state=%s repo=%r",
|
||||
slug, rfc["state"], rfc["repo"])
|
||||
return False
|
||||
try:
|
||||
await bot.delete_branch(
|
||||
|
||||
+10
-4
@@ -144,10 +144,16 @@ def create_invite(
|
||||
The invitee `users` row is provisioned with:
|
||||
* `permission_state='granted'` — the admin's hand is the grant;
|
||||
the v0.8.0 self-serve `pending` queue is for the other path.
|
||||
* `last_seen_at = NULL` — the discriminator for "invited but
|
||||
not yet arrived" per the §16 / roadmap design. Every sign-in
|
||||
path stamps `last_seen_at` to now, so a NULL value means the
|
||||
invited user has not clicked through yet.
|
||||
* `created_at` / `last_seen_at` — NOT set here, so both fall
|
||||
through to the column default `datetime('now')` (the column is
|
||||
`NOT NULL`; see `migrations/001_users_and_audit.sql` and the
|
||||
longer note below). The "invited but not yet arrived" state is
|
||||
therefore NOT carried on the user row — it is the existence of
|
||||
an unclaimed `user_invite_tokens` row, surfaced as the listing's
|
||||
`pending_invite` field. Consumers that want a truthful
|
||||
last-seen MUST treat a pending-invite row as never-seen rather
|
||||
than trusting `last_seen_at` (every real sign-in path stamps it
|
||||
to now, but an unclaimed invite has never hit one).
|
||||
* `gitea_id = NULL`, `gitea_login = NULL` — same as a v0.7.0
|
||||
OTC-provisioned user; the OAuth identity is grandfathered if
|
||||
the user ever lands through that path.
|
||||
|
||||
+85
-20
@@ -7,6 +7,7 @@ no need for a separate worker.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -27,7 +28,10 @@ from . import (
|
||||
invites as invites_mod,
|
||||
otc,
|
||||
passcode as passcode_mod,
|
||||
projects,
|
||||
providers as providers_mod,
|
||||
ratelimit,
|
||||
registry as registry_mod,
|
||||
turnstile,
|
||||
webhooks,
|
||||
)
|
||||
@@ -98,6 +102,27 @@ async def lifespan(app: FastAPI):
|
||||
db.run_migrations(config)
|
||||
db.init(config)
|
||||
gitea = Gitea(config)
|
||||
# §22.2: mirror the registry before anything reads projects/content_repo.
|
||||
# First boot has no last-good rows, so a missing/invalid registry is fatal
|
||||
# (loud-fail per separation-of-concerns); the reconciler sweep keeps it
|
||||
# fresh thereafter and tolerates a later bad PR.
|
||||
try:
|
||||
await registry_mod.refresh_registry(config, gitea)
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"registry mirror failed at startup ({config.registry_repo_full}/projects.yaml): {e}"
|
||||
) from e
|
||||
# §22.13 step 1: re-stamp the M1 bootstrap 'default' project id to the
|
||||
# deployment's configured id (DEFAULT_PROJECT_ID) once the registry row
|
||||
# exists, so the original corpus lands at a meaningful /p/<id>/ and
|
||||
# 'default' is never a public URL. Idempotent no-op once done.
|
||||
projects.restamp_default_project(config)
|
||||
if projects.default_content_repo(config) is None:
|
||||
raise RuntimeError(
|
||||
f"registry does not describe the default project "
|
||||
f"{projects.resolved_default_id(config)!r} (no content_repo). "
|
||||
f"Add it to {config.registry_repo_full}/projects.yaml."
|
||||
)
|
||||
bot = Bot(gitea)
|
||||
reconciler = cache.Reconciler(config, gitea)
|
||||
digest_sched = digest.DigestScheduler()
|
||||
@@ -126,7 +151,7 @@ async def lifespan(app: FastAPI):
|
||||
reconciler.start()
|
||||
digest_sched.start()
|
||||
hygiene_sched.start()
|
||||
log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo)
|
||||
log.info("RFC app started — registry %s", config.registry_repo_full)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -142,12 +167,20 @@ def create_app() -> FastAPI:
|
||||
# eagerly via load_config(). Everything else waits for lifespan.
|
||||
config = load_config()
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
# v0.25.0 (audit 0026 M4): the session cookie is the primary 30-day
|
||||
# auth credential and must carry `Secure` in production so it never
|
||||
# travels cleartext. Default to Secure; a dev box serving over plain
|
||||
# http opts out with SESSION_COOKIE_SECURE=false. Production (OHM is
|
||||
# HTTPS-only with an HTTP->HTTPS 301) leaves this unset → Secure on.
|
||||
session_secure = os.environ.get("SESSION_COOKIE_SECURE", "true").strip().lower() not in (
|
||||
"0", "false", "no", "off",
|
||||
)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=config.secret_key,
|
||||
session_cookie="rfc_session",
|
||||
max_age=60 * 60 * 24 * 30,
|
||||
https_only=False,
|
||||
https_only=session_secure,
|
||||
)
|
||||
return app
|
||||
|
||||
@@ -155,24 +188,25 @@ def create_app() -> FastAPI:
|
||||
app = create_app()
|
||||
|
||||
|
||||
def _set_device_trust_cookie(response: Response, raw_token: str) -> None:
|
||||
def _set_device_trust_cookie(response: Response, cookie_value: str) -> None:
|
||||
"""Attach the v0.11.0 device-trust cookie to the response.
|
||||
|
||||
HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. The
|
||||
cookie value is the raw token; server-side storage is the hash.
|
||||
The cookie is "essential" per the v0.13.0 cookie-consent contract
|
||||
(it is part of authentication), so we set it regardless of the
|
||||
user's analytics / other-cookies choice.
|
||||
HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. As of
|
||||
v0.25.0 (audit 0026 M1) the value is `IssueOutcome.cookie_value` —
|
||||
"<row_id>.<raw_token>" — so `device_trust.lookup` can read one indexed
|
||||
row instead of scanning; server-side storage remains the bcrypt hash
|
||||
of the token half only. The cookie is "essential" per the v0.13.0
|
||||
cookie-consent contract (it is part of authentication), so we set it
|
||||
regardless of the user's analytics / other-cookies choice.
|
||||
|
||||
Secure=True means the cookie is only ever sent over HTTPS. The
|
||||
SessionMiddleware in `create_app` keeps `https_only=False` for
|
||||
dev parity, but the device-trust cookie holds a 30-day credential
|
||||
and must not travel cleartext — production deployments serve over
|
||||
HTTPS, so Secure on the device-trust cookie is non-negotiable.
|
||||
Secure=True means the cookie is only ever sent over HTTPS — the
|
||||
device-trust cookie holds a 30-day credential and must never travel
|
||||
cleartext. (The session cookie now also defaults to Secure; see M4 in
|
||||
`create_app`.)
|
||||
"""
|
||||
response.set_cookie(
|
||||
key=device_trust_mod.COOKIE_NAME,
|
||||
value=raw_token,
|
||||
value=cookie_value,
|
||||
max_age=device_trust_mod.COOKIE_MAX_AGE_SECONDS,
|
||||
path="/",
|
||||
secure=True,
|
||||
@@ -245,6 +279,10 @@ def _oauth_router(config) -> APIRouter:
|
||||
|
||||
@router.post("/auth/otc/request")
|
||||
async def otc_request(body: OtcRequestBody, request: Request):
|
||||
# v0.25.0 (audit 0026 H1/L2): per-IP brake at the cheapest point,
|
||||
# before the Turnstile network call or any bcrypt/SMTP work.
|
||||
if not ratelimit.otc_request_limiter.allow(ratelimit.client_key(request)):
|
||||
raise HTTPException(429, "Too many requests; please wait a few minutes")
|
||||
# v0.12.0 / roadmap item #10: gate the request on a successful
|
||||
# Turnstile siteverify before the bcrypt hash + SMTP send. The
|
||||
# check runs first so a failed challenge spends no rate budget
|
||||
@@ -252,7 +290,7 @@ def _oauth_router(config) -> APIRouter:
|
||||
# secret AND TURNSTILE_REQUIRED=false (the default), the gate
|
||||
# opens — see `backend/app/turnstile.py` for the full matrix.
|
||||
client_ip = request.client.host if request.client else None
|
||||
ts = turnstile.verify_token(body.turnstile_token, client_ip=client_ip)
|
||||
ts = await turnstile.verify_token(body.turnstile_token, client_ip=client_ip)
|
||||
if not ts.ok:
|
||||
if ts.reason == "misconfigured":
|
||||
# TURNSTILE_REQUIRED=true but the secret is unset. This
|
||||
@@ -278,9 +316,25 @@ def _oauth_router(config) -> APIRouter:
|
||||
|
||||
@router.post("/auth/otc/verify")
|
||||
async def otc_verify(body: OtcVerifyBody, request: Request, response: Response):
|
||||
# v0.25.0 (audit 0026 H1): per-IP brake against fan-out guessing,
|
||||
# plus the per-email lockout enforced inside otc.verify_code.
|
||||
ip = ratelimit.client_key(request)
|
||||
if not ratelimit.verify_limiter.allow(ip):
|
||||
raise HTTPException(429, "Too many attempts; please wait a few minutes")
|
||||
result = otc.verify_code(body.email, body.code)
|
||||
if result.reason == "locked":
|
||||
raise HTTPException(
|
||||
423,
|
||||
{
|
||||
"detail": "Too many failed attempts; wait a few minutes or request a new code",
|
||||
"locked_until": result.locked_until,
|
||||
},
|
||||
)
|
||||
if not result.ok or result.user is None:
|
||||
raise HTTPException(400, "Invalid or expired code")
|
||||
# Legit sign-in: clear this IP's window so a user who fat-fingered
|
||||
# a couple of codes isn't left throttled.
|
||||
ratelimit.verify_limiter.reset(ip)
|
||||
auth.store_session(request, result.user)
|
||||
# v0.8.0: surface `needs_profile` so the Login.jsx surface can
|
||||
# decide whether to advance to the first/last/why capture step
|
||||
@@ -313,7 +367,7 @@ def _oauth_router(config) -> APIRouter:
|
||||
if body.trust_device:
|
||||
ua = request.headers.get("user-agent", "")
|
||||
outcome = device_trust_mod.issue(result.user.user_id, ua)
|
||||
_set_device_trust_cookie(response, outcome.raw_token)
|
||||
_set_device_trust_cookie(response, outcome.cookie_value)
|
||||
return {
|
||||
"ok": True,
|
||||
"user": {
|
||||
@@ -337,12 +391,17 @@ def _oauth_router(config) -> APIRouter:
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/auth/passcode/check")
|
||||
async def passcode_check(email: str = ""):
|
||||
async def passcode_check(request: Request, email: str = ""):
|
||||
"""Does this email have a passcode set? Anonymous endpoint —
|
||||
the Login.jsx flow calls this after the user types their email
|
||||
to decide whether to render a passcode input or fall back to
|
||||
OTC. We surface only the boolean; lockout state, the hash, and
|
||||
the set-at stamp are not leaked here."""
|
||||
the set-at stamp are not leaked here.
|
||||
|
||||
v0.25.0 (audit 0026 L3): per-IP rate limit so the has-passcode
|
||||
boolean can't be bulk-harvested to enumerate accounts."""
|
||||
if not ratelimit.check_limiter.allow(ratelimit.client_key(request)):
|
||||
raise HTTPException(429, "Too many requests; please wait a few minutes")
|
||||
status = passcode_mod.passcode_status(email)
|
||||
return {"has_passcode": status.has_passcode}
|
||||
|
||||
@@ -376,6 +435,11 @@ def _oauth_router(config) -> APIRouter:
|
||||
v0.11.0: the body's `trust_device` flag, if true, mints a
|
||||
fresh device-trust row and sets the long-lived cookie. Same
|
||||
opt-in contract as `/auth/otc/verify`."""
|
||||
# v0.25.0 (audit 0026 H1): per-IP brake in front of the per-account
|
||||
# passcode lockout, so fan-out across emails is throttled too.
|
||||
ip = ratelimit.client_key(request)
|
||||
if not ratelimit.verify_limiter.allow(ip):
|
||||
raise HTTPException(429, "Too many attempts; please wait a few minutes")
|
||||
result = passcode_mod.verify_passcode(body.email, body.passcode)
|
||||
if result.reason == "locked":
|
||||
raise HTTPException(
|
||||
@@ -387,11 +451,12 @@ def _oauth_router(config) -> APIRouter:
|
||||
)
|
||||
if not result.ok or result.user is None:
|
||||
raise HTTPException(400, "Invalid passcode")
|
||||
ratelimit.verify_limiter.reset(ip)
|
||||
auth.store_session(request, result.user)
|
||||
if body.trust_device:
|
||||
ua = request.headers.get("user-agent", "")
|
||||
outcome = device_trust_mod.issue(result.user.user_id, ua)
|
||||
_set_device_trust_cookie(response, outcome.raw_token)
|
||||
_set_device_trust_cookie(response, outcome.cookie_value)
|
||||
return {
|
||||
"ok": True,
|
||||
"user": {
|
||||
@@ -459,7 +524,7 @@ def _oauth_router(config) -> APIRouter:
|
||||
if body.trust_device:
|
||||
ua = request.headers.get("user-agent", "")
|
||||
outcome = device_trust_mod.issue(result.user.user_id, ua)
|
||||
_set_device_trust_cookie(response, outcome.raw_token)
|
||||
_set_device_trust_cookie(response, outcome.cookie_value)
|
||||
|
||||
# Has the user already set a passcode? (Could only happen via
|
||||
# an admin pre-population path that doesn't exist yet, but
|
||||
|
||||
@@ -270,6 +270,86 @@ def fan_out_new_beta_request(
|
||||
)
|
||||
|
||||
|
||||
def fan_out_contribution_request(
|
||||
*,
|
||||
rfc_slug: str,
|
||||
requester_user_id: int,
|
||||
request_id: int,
|
||||
matched_term: str,
|
||||
who_i_am: str,
|
||||
why: str,
|
||||
use_case: str | None,
|
||||
) -> list[int]:
|
||||
"""Roadmap #28 Part 3: a reader asked to contribute to a pending
|
||||
(super-draft) RFC. Land one actionable notification per owner and
|
||||
return their ids (the caller stamps the first onto the request row as
|
||||
the inbox-action handle).
|
||||
|
||||
Personal-direct: the owner is the named subject of the request, so the
|
||||
§15.4 email gate consults `email_personal_direct` exactly as for the
|
||||
other owner-facing personal events — no new preference column is
|
||||
needed. The request's three free-text fields ride along in the payload
|
||||
so the inbox row can show the full ask inline without a second fetch.
|
||||
Actor is the requester per §15.9.
|
||||
"""
|
||||
requester = db.conn().execute(
|
||||
"SELECT display_name FROM users WHERE id = ?", (requester_user_id,)
|
||||
).fetchone()
|
||||
display = (requester["display_name"] if requester else None) or "Someone"
|
||||
details = {
|
||||
"request_id": request_id,
|
||||
"matched_term": matched_term,
|
||||
"requester_user_id": requester_user_id,
|
||||
"requester_display": display,
|
||||
"who_i_am": who_i_am,
|
||||
"why": why,
|
||||
"use_case": use_case or "",
|
||||
}
|
||||
notif_ids: list[int] = []
|
||||
for recipient_id in _entry_owner_user_ids(rfc_slug):
|
||||
if recipient_id == requester_user_id:
|
||||
continue
|
||||
notif_ids.append(
|
||||
_emit_one(
|
||||
recipient_user_id=recipient_id,
|
||||
event_kind="contribution_request_on_pending_rfc",
|
||||
category=CATEGORY_PERSONAL,
|
||||
actor_user_id=requester_user_id,
|
||||
rfc_slug=rfc_slug,
|
||||
branch_name=None,
|
||||
pr_number=None,
|
||||
details=details,
|
||||
)
|
||||
)
|
||||
return notif_ids
|
||||
|
||||
|
||||
def notify_contribution_decided(
|
||||
*,
|
||||
rfc_slug: str,
|
||||
requester_user_id: int,
|
||||
decider_user_id: int,
|
||||
request_id: int,
|
||||
accepted: bool,
|
||||
) -> None:
|
||||
"""Roadmap #28 Part 3: tell the requester an owner accepted or declined
|
||||
their contribute request. On accept the requester also receives the
|
||||
#12 invitation email out-of-band; this inbox row is the in-app echo
|
||||
that points them at it."""
|
||||
_emit_one(
|
||||
recipient_user_id=requester_user_id,
|
||||
event_kind=(
|
||||
"contribution_request_accepted" if accepted else "contribution_request_declined"
|
||||
),
|
||||
category=CATEGORY_PERSONAL,
|
||||
actor_user_id=decider_user_id,
|
||||
rfc_slug=rfc_slug,
|
||||
branch_name=None,
|
||||
pr_number=None,
|
||||
details={"request_id": request_id},
|
||||
)
|
||||
|
||||
|
||||
def fan_out_chat_message(
|
||||
*,
|
||||
actor_user_id: int,
|
||||
@@ -769,6 +849,16 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str |
|
||||
return f"{actor} began graduating {title}."
|
||||
if event_kind == "pr_conflict_with_main":
|
||||
return f"{actor} started a resolution branch on {title}."
|
||||
if event_kind == "contribution_request_on_pending_rfc":
|
||||
# Roadmap #28 Part 3: owner-facing, actionable. The term is the
|
||||
# super-draft reference that surfaced the offer; the inbox row
|
||||
# renders Accept/Decline beneath this line.
|
||||
term = extras.get("matched_term") or title
|
||||
return f"{actor} wants to contribute to your pending RFC for '{term}'."
|
||||
if event_kind == "contribution_request_accepted":
|
||||
return f"{actor} accepted your request to contribute to {title} — check your email to accept the invitation."
|
||||
if event_kind == "contribution_request_declined":
|
||||
return f"{actor} declined your request to contribute to {title}."
|
||||
if event_kind == "new_beta_request":
|
||||
# v0.9.0: framework-scoped, not RFC-scoped. The actor (the
|
||||
# requester) and the captured full name + email read as
|
||||
@@ -884,6 +974,11 @@ def list_inbox(
|
||||
"read_at": row["read_at"],
|
||||
"category": extras.get("category"),
|
||||
"summary": render_summary(row["event_kind"], row["actor_display"], row["rfc_title"], extras),
|
||||
# The row's payload, surfaced for kinds that render inline
|
||||
# detail (e.g. #28 Part 3's contribute-request who/why/use-case
|
||||
# + Accept/Decline). Safe to expose: a recipient only ever sees
|
||||
# their own notifications.
|
||||
"extras": extras,
|
||||
})
|
||||
|
||||
if bundled:
|
||||
|
||||
@@ -85,6 +85,15 @@ def _cooldown_seconds() -> int:
|
||||
return 60
|
||||
|
||||
|
||||
# v0.25.0 / security audit 0026 (H1): per-email OTC verify lockout,
|
||||
# mirroring the passcode path (passcode.py). Five consecutive wrong codes
|
||||
# for an email lock its OTC verify for 15 minutes. The per-IP limiter in
|
||||
# ratelimit.py is the primary brute-force brake; this is the durable,
|
||||
# passcode-parity layer.
|
||||
LOCKOUT_AFTER_FAILED_ATTEMPTS = 5
|
||||
LOCKOUT_DURATION_MINUTES = 15
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Code generation + hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -206,6 +215,61 @@ class VerifyOutcome:
|
||||
ok: bool
|
||||
user: SessionUser | None
|
||||
reason: str
|
||||
# v0.25.0 (H1): ISO-8601 stamp when reason == 'locked'.
|
||||
locked_until: str | None = None
|
||||
|
||||
|
||||
def _verify_lockout_until(email: str) -> str | None:
|
||||
"""Return the active lockout stamp for `email`, or None if not locked.
|
||||
|
||||
Clears an elapsed lockout (and resets the counter) as a side effect so
|
||||
the next failure starts a fresh budget — mirrors passcode.verify_passcode.
|
||||
"""
|
||||
row = db.conn().execute(
|
||||
"SELECT failed_attempts, locked_until FROM otc_verify_state WHERE email = ?",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if row is None or not row["locked_until"]:
|
||||
return None
|
||||
still_locked = db.conn().execute(
|
||||
"SELECT datetime(?) > datetime('now') AS locked", (row["locked_until"],),
|
||||
).fetchone()["locked"]
|
||||
if still_locked:
|
||||
return row["locked_until"]
|
||||
db.conn().execute(
|
||||
"UPDATE otc_verify_state SET failed_attempts = 0, locked_until = NULL WHERE email = ?",
|
||||
(email,),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _record_verify_failure(email: str) -> None:
|
||||
"""Increment the per-email failure counter; stamp a lockout once it
|
||||
crosses the threshold. Mirrors the passcode lockout shape."""
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO otc_verify_state (email, failed_attempts)
|
||||
VALUES (?, 1)
|
||||
ON CONFLICT(email) DO UPDATE SET failed_attempts = failed_attempts + 1
|
||||
""",
|
||||
(email,),
|
||||
)
|
||||
count = db.conn().execute(
|
||||
"SELECT failed_attempts FROM otc_verify_state WHERE email = ?", (email,),
|
||||
).fetchone()["failed_attempts"]
|
||||
if count >= LOCKOUT_AFTER_FAILED_ATTEMPTS:
|
||||
db.conn().execute(
|
||||
f"""
|
||||
UPDATE otc_verify_state
|
||||
SET locked_until = datetime('now', '+{LOCKOUT_DURATION_MINUTES} minutes')
|
||||
WHERE email = ?
|
||||
""",
|
||||
(email,),
|
||||
)
|
||||
|
||||
|
||||
def _clear_verify_state(email: str) -> None:
|
||||
db.conn().execute("DELETE FROM otc_verify_state WHERE email = ?", (email,))
|
||||
|
||||
|
||||
def verify_code(email: str, code: str) -> VerifyOutcome:
|
||||
@@ -214,6 +278,13 @@ def verify_code(email: str, code: str) -> VerifyOutcome:
|
||||
if not email or not code:
|
||||
return VerifyOutcome(ok=False, user=None, reason="invalid")
|
||||
|
||||
# v0.25.0 (H1): refuse before spending any bcrypt if this email is in
|
||||
# its OTC-verify lockout window. The passcode path is unaffected — a
|
||||
# locked-out OTC user can still set/use a passcode, and vice versa.
|
||||
locked_until = _verify_lockout_until(email)
|
||||
if locked_until:
|
||||
return VerifyOutcome(ok=False, user=None, reason="locked", locked_until=locked_until)
|
||||
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT id, code_hash, expires_at, consumed_at
|
||||
@@ -237,6 +308,9 @@ def verify_code(email: str, code: str) -> VerifyOutcome:
|
||||
break
|
||||
|
||||
if matched is None:
|
||||
# A genuine wrong guess against this email — the brute-force
|
||||
# signal. Count it toward the lockout threshold (H1).
|
||||
_record_verify_failure(email)
|
||||
return VerifyOutcome(ok=False, user=None, reason="wrong")
|
||||
|
||||
if matched["consumed_at"] is not None:
|
||||
@@ -255,6 +329,8 @@ def verify_code(email: str, code: str) -> VerifyOutcome:
|
||||
"UPDATE otc_codes SET consumed_at = datetime('now') WHERE id = ?",
|
||||
(matched["id"],),
|
||||
)
|
||||
# Success wipes the per-email failure counter (H1).
|
||||
_clear_verify_state(email)
|
||||
user = provision_or_link_user(email)
|
||||
return VerifyOutcome(ok=True, user=user, reason="ok")
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Project registry — the §22 multi-project layer.
|
||||
|
||||
A deployment hosts one or more projects (§22.1). The git registry mirror
|
||||
that lets a deployment declare projects lands in M3 and drives this module.
|
||||
`seed_default_project` (the §22.13 META_REPO backfill) is retired in M3;
|
||||
the registry mirror (`registry.refresh_registry`) is authoritative.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from . import db
|
||||
from .config import Config
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PROJECT_ID = "default"
|
||||
|
||||
|
||||
def resolved_default_id(config: Config) -> str:
|
||||
"""The id of the deployment's bootstrap/default project. Plan A: always
|
||||
'default' (the re-stamp to a config slug rides Plan B). The config knob is
|
||||
read here so Plan B can flip the resolution without touching call sites."""
|
||||
return config.default_project_id.strip() or DEFAULT_PROJECT_ID
|
||||
|
||||
|
||||
def restamp_default_project(config: Config) -> None:
|
||||
"""§22.13 step 1 — one-time rename of the M1 bootstrap project id
|
||||
(DEFAULT_PROJECT_ID = 'default') to the deployment's configured default id
|
||||
(the DEFAULT_PROJECT_ID env var, e.g. 'ohm'), so the deployment's original
|
||||
corpus lands at a meaningful `/p/<id>/` and `default` is never a public URL.
|
||||
|
||||
Renames `project_id` across every project-scoped table (discovered by
|
||||
column, so it stays correct as the schema grows), then drops the stale
|
||||
bootstrap `projects` row (its data has moved to the configured row, which
|
||||
the registry mirror already created). Idempotent and a no-op when the
|
||||
configured id is still 'default' or no bootstrap rows remain. Runs at
|
||||
startup after the registry mirror, with FK enforcement off for the rename
|
||||
(the composite FKs are kept consistent because parent and child rows are
|
||||
renamed together) and a foreign_key_check backstop before commit.
|
||||
"""
|
||||
target = resolved_default_id(config)
|
||||
if target == DEFAULT_PROJECT_ID:
|
||||
return
|
||||
conn = db.conn()
|
||||
has_rows = conn.execute(
|
||||
"SELECT 1 FROM cached_rfcs WHERE project_id = ? LIMIT 1", (DEFAULT_PROJECT_ID,)
|
||||
).fetchone()
|
||||
stale_proj = conn.execute(
|
||||
"SELECT 1 FROM projects WHERE id = ? LIMIT 1", (DEFAULT_PROJECT_ID,)
|
||||
).fetchone()
|
||||
if not has_rows and not stale_proj:
|
||||
return
|
||||
if conn.execute("SELECT 1 FROM projects WHERE id = ? LIMIT 1", (target,)).fetchone() is None:
|
||||
log.warning("restamp: target project %r not in registry yet; skipping", target)
|
||||
return
|
||||
|
||||
tables = [r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]
|
||||
pid_tables = [
|
||||
t for t in tables
|
||||
if any(c["name"] == "project_id" for c in conn.execute(f"PRAGMA table_info({t})"))
|
||||
]
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
conn.execute("BEGIN")
|
||||
for t in pid_tables:
|
||||
conn.execute(
|
||||
f"UPDATE {t} SET project_id = ? WHERE project_id = ?",
|
||||
(target, DEFAULT_PROJECT_ID),
|
||||
)
|
||||
# The bootstrap row's data has moved to the configured (registry) row.
|
||||
conn.execute("DELETE FROM projects WHERE id = ?", (DEFAULT_PROJECT_ID,))
|
||||
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
|
||||
if violations:
|
||||
conn.execute("ROLLBACK")
|
||||
raise RuntimeError(
|
||||
f"restamp left foreign-key violations: {[tuple(v) for v in violations]}"
|
||||
)
|
||||
conn.execute("COMMIT")
|
||||
except Exception:
|
||||
try:
|
||||
conn.execute("ROLLBACK")
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
log.info("restamp: renamed bootstrap project %r -> %r across %d tables",
|
||||
DEFAULT_PROJECT_ID, target, len(pid_tables))
|
||||
|
||||
|
||||
def default_content_repo(config: Config) -> str | None:
|
||||
"""The content repo the single-corpus mirror reads, from the default
|
||||
project's row (filled by the registry mirror). Replaces the retired
|
||||
META_REPO. None until the registry mirror has run."""
|
||||
row = db.conn().execute(
|
||||
"SELECT content_repo FROM projects WHERE id = ?",
|
||||
(resolved_default_id(config),),
|
||||
).fetchone()
|
||||
return row["content_repo"] if row and row["content_repo"] else None
|
||||
|
||||
|
||||
def content_repo(project_id: str) -> str | None:
|
||||
"""The content repo for a specific project (§22.3). None if unknown/unset.
|
||||
The per-project successor to `default_content_repo` for the write path."""
|
||||
row = db.conn().execute(
|
||||
"SELECT content_repo FROM projects WHERE id = ?", (project_id,)
|
||||
).fetchone()
|
||||
return row["content_repo"] if row and row["content_repo"] else None
|
||||
|
||||
|
||||
def project_initial_state(project_id: str) -> str:
|
||||
"""§22.4b landing state for new entries in a project. Defaults to
|
||||
'super-draft' for an unknown/unset row (the safe, today's-flow default)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT initial_state FROM projects WHERE id = ?", (project_id,)
|
||||
).fetchone()
|
||||
if row is None or not row["initial_state"]:
|
||||
return "super-draft"
|
||||
return row["initial_state"]
|
||||
@@ -0,0 +1,92 @@
|
||||
"""In-process per-IP sliding-window rate limiter (security audit 0026, H1).
|
||||
|
||||
The auth verify endpoints (`/auth/otc/verify`, `/auth/passcode/verify`)
|
||||
had no per-IP brake, so an attacker could fan out guesses against a
|
||||
target identity bounded only by bcrypt cost. This module is the brake.
|
||||
|
||||
It is deliberately tiny: §4.2 says the app is a single process with a
|
||||
colocated SQLite file, so an in-memory dict of `key -> deque[timestamps]`
|
||||
is sufficient and needs no shared store. State resets on restart, which
|
||||
fails *open* for a brief window — acceptable because the per-email OTC
|
||||
lockout (`otc_verify_state`) and the passcode lockout both persist in the
|
||||
database and carry the durable guarantee; this limiter is the
|
||||
anti-fan-out layer on top.
|
||||
|
||||
Chosen over a per-identity lockout *as the primary control* because a
|
||||
per-IP window throttles the attacker without letting them grief a victim
|
||||
by locking that victim's account (the known downside of identity
|
||||
lockouts). Both layers run together.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
|
||||
class SlidingWindowLimiter:
|
||||
"""Allow at most `max_events` per `window_seconds` per key.
|
||||
|
||||
`allow(key)` records an event and returns True if the key is still
|
||||
within budget, False if it has exceeded it. Timestamps use a
|
||||
monotonic clock so the limiter is immune to wall-clock jumps.
|
||||
"""
|
||||
|
||||
def __init__(self, max_events: int, window_seconds: float) -> None:
|
||||
self.max_events = max_events
|
||||
self.window_seconds = window_seconds
|
||||
self._events: dict[str, deque[float]] = defaultdict(deque)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def allow(self, key: str) -> bool:
|
||||
now = time.monotonic()
|
||||
cutoff = now - self.window_seconds
|
||||
with self._lock:
|
||||
q = self._events[key]
|
||||
while q and q[0] < cutoff:
|
||||
q.popleft()
|
||||
if len(q) >= self.max_events:
|
||||
return False
|
||||
q.append(now)
|
||||
# Opportunistic cleanup so idle keys don't accumulate forever.
|
||||
if not q:
|
||||
self._events.pop(key, None)
|
||||
return True
|
||||
|
||||
def reset(self, key: str) -> None:
|
||||
"""Drop a key's window — e.g. after a successful sign-in so a
|
||||
legitimate user who fat-fingered a few times isn't throttled."""
|
||||
with self._lock:
|
||||
self._events.pop(key, None)
|
||||
|
||||
|
||||
# Module-level limiters shared across requests (one process, so module
|
||||
# state is the natural home). Tunables are intentionally generous enough
|
||||
# not to bother a human retyping a code, tight enough to kill fan-out:
|
||||
# * verify: 10 attempts / 5 min / IP across the auth verify surfaces.
|
||||
# * otc request: 5 sends / 5 min / IP (Turnstile is the primary gate;
|
||||
# this is defense in depth against a solved-challenge replay loop).
|
||||
verify_limiter = SlidingWindowLimiter(max_events=10, window_seconds=300)
|
||||
otc_request_limiter = SlidingWindowLimiter(max_events=5, window_seconds=300)
|
||||
# /auth/passcode/check is an anonymous has-passcode oracle (audit 0026 L3).
|
||||
# It's a legitimate Login-flow affordance, so the budget is generous —
|
||||
# enough for a human typing emails, tight enough to stop bulk scraping.
|
||||
check_limiter = SlidingWindowLimiter(max_events=30, window_seconds=300)
|
||||
|
||||
|
||||
def _reset_all_for_tests() -> None:
|
||||
"""Clear every module-level limiter's window. Test support only — the
|
||||
limiters are process-global singletons, so without a per-test reset
|
||||
one test's requests bleed into the next and later tests trip the
|
||||
budget (429). Not called in production."""
|
||||
for lim in (verify_limiter, otc_request_limiter, check_limiter):
|
||||
with lim._lock:
|
||||
lim._events.clear()
|
||||
|
||||
|
||||
def client_key(request) -> str:
|
||||
"""Best-effort client identity for limiting. Behind nginx the app is
|
||||
started with `--forwarded-allow-ips 127.0.0.1`, so `request.client.host`
|
||||
reflects the real client IP via Uvicorn's ProxyHeaders handling."""
|
||||
client = getattr(request, "client", None)
|
||||
return client.host if client and client.host else "unknown"
|
||||
@@ -0,0 +1,185 @@
|
||||
"""§22.2 project registry mirror — the config-side analogue of
|
||||
cache.refresh_meta_repo.
|
||||
|
||||
A deployment declares its projects in a `projects.yaml` at the root of
|
||||
REGISTRY_REPO. This module mirrors that file into the `projects` cache table
|
||||
and the `deployment` singleton. Per §22.2, `projects` rows flow from the
|
||||
registry only — never from user actions. The mirror runs on the registry-repo
|
||||
webhook and on every reconciler sweep (Option A wiring, Task 5).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import yaml
|
||||
|
||||
from . import db
|
||||
from .config import Config
|
||||
from .gitea import Gitea
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
VALID_TYPES = {"document", "specification", "bdd"}
|
||||
VALID_VISIBILITY = {"gated", "public", "unlisted"}
|
||||
VALID_INITIAL_STATE = {"super-draft", "active"}
|
||||
# §22.4b: per-type default landing state.
|
||||
_TYPE_DEFAULT_INITIAL_STATE = {
|
||||
"document": "super-draft",
|
||||
"specification": "super-draft",
|
||||
"bdd": "active",
|
||||
}
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||
|
||||
|
||||
class RegistryError(Exception):
|
||||
"""A registry document that fails validation, or a missing registry file.
|
||||
|
||||
Raised by parse_registry/refresh_registry. The caller decides severity:
|
||||
fatal at startup (no last-good to serve), tolerated on a running deployment
|
||||
(keep the last-good projects rows). See Task 5 wiring.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectEntry:
|
||||
id: str
|
||||
name: str
|
||||
type: str
|
||||
content_repo: str
|
||||
visibility: str
|
||||
initial_state: str
|
||||
config: dict = field(default_factory=dict) # theme, enabled_models
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegistryDoc:
|
||||
deployment_name: str
|
||||
deployment_tagline: str
|
||||
projects: list[ProjectEntry]
|
||||
|
||||
|
||||
def parse_registry(text: str) -> RegistryDoc:
|
||||
"""Parse + validate projects.yaml. Pure (no I/O). Raises RegistryError."""
|
||||
raw = yaml.safe_load(text) or {}
|
||||
dep = raw.get("deployment") or {}
|
||||
projects_raw = raw.get("projects") or []
|
||||
if not isinstance(projects_raw, list) or not projects_raw:
|
||||
raise RegistryError("registry must declare at least one project")
|
||||
seen: set[str] = set()
|
||||
entries: list[ProjectEntry] = []
|
||||
for p in projects_raw:
|
||||
if not isinstance(p, dict):
|
||||
raise RegistryError(f"each project entry must be a mapping, got {type(p).__name__}")
|
||||
pid = str(p.get("id") or "").strip()
|
||||
if not _SLUG_RE.match(pid):
|
||||
raise RegistryError(f"project id {pid!r} is not a valid slug")
|
||||
if pid in seen:
|
||||
raise RegistryError(f"duplicate project id {pid!r}")
|
||||
seen.add(pid)
|
||||
name = str(p.get("name") or "").strip()
|
||||
if not name:
|
||||
raise RegistryError(f"project {pid!r} missing name")
|
||||
ptype = str(p.get("type") or "").strip()
|
||||
if ptype not in VALID_TYPES:
|
||||
raise RegistryError(f"project {pid!r} has invalid type {ptype!r}")
|
||||
content_repo = str(p.get("content_repo") or "").strip()
|
||||
if not content_repo:
|
||||
raise RegistryError(f"project {pid!r} missing content_repo")
|
||||
vis = str(p.get("visibility") or "gated").strip()
|
||||
if vis not in VALID_VISIBILITY:
|
||||
raise RegistryError(f"project {pid!r} has invalid visibility {vis!r}")
|
||||
initial_state = str(
|
||||
p.get("initial_state") or _TYPE_DEFAULT_INITIAL_STATE[ptype]
|
||||
).strip()
|
||||
if initial_state not in VALID_INITIAL_STATE:
|
||||
raise RegistryError(
|
||||
f"project {pid!r} has invalid initial_state {initial_state!r}"
|
||||
)
|
||||
cfg: dict = {}
|
||||
if p.get("theme") is not None:
|
||||
cfg["theme"] = p["theme"]
|
||||
if p.get("enabled_models") is not None:
|
||||
cfg["enabled_models"] = [str(m) for m in p["enabled_models"]]
|
||||
entries.append(
|
||||
ProjectEntry(pid, name, ptype, content_repo, vis, initial_state, cfg)
|
||||
)
|
||||
return RegistryDoc(
|
||||
deployment_name=str(dep.get("name") or "").strip(),
|
||||
deployment_tagline=str(dep.get("tagline") or "").strip(),
|
||||
projects=entries,
|
||||
)
|
||||
|
||||
|
||||
def apply_registry(doc: RegistryDoc, registry_sha: str) -> None:
|
||||
"""Upsert the parsed registry into projects + deployment. Idempotent.
|
||||
|
||||
§22.4a: `type` is immutable — a change against an existing row is rejected
|
||||
(skip + log), never applied. Projects absent from the registry are left in
|
||||
place (archival is out of scope for M3; they simply stop refreshing).
|
||||
"""
|
||||
with db.tx() as conn:
|
||||
for e in doc.projects:
|
||||
existing = conn.execute(
|
||||
"SELECT type FROM projects WHERE id = ?", (e.id,)
|
||||
).fetchone()
|
||||
if existing is not None and existing["type"] != e.type:
|
||||
log.error(
|
||||
"registry: refusing immutable type change on project %s (%s -> %s)",
|
||||
e.id, existing["type"], e.type,
|
||||
)
|
||||
continue
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO projects
|
||||
(id, name, type, content_repo, visibility, initial_state,
|
||||
config_json, registry_sha, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
type = excluded.type,
|
||||
content_repo = excluded.content_repo,
|
||||
visibility = excluded.visibility,
|
||||
initial_state = excluded.initial_state,
|
||||
config_json = excluded.config_json,
|
||||
registry_sha = excluded.registry_sha,
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
(
|
||||
e.id, e.name, e.type, e.content_repo, e.visibility,
|
||||
e.initial_state, json.dumps(e.config), registry_sha,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE deployment
|
||||
SET name = ?, tagline = ?, registry_sha = ?, updated_at = datetime('now')
|
||||
WHERE id = 1
|
||||
""",
|
||||
(doc.deployment_name, doc.deployment_tagline, registry_sha),
|
||||
)
|
||||
|
||||
|
||||
async def refresh_registry(config: Config, gitea: Gitea) -> None:
|
||||
"""Mirror REGISTRY_REPO/projects.yaml into projects + deployment.
|
||||
|
||||
Idempotent. Raises RegistryError on a missing/invalid file and GiteaError
|
||||
on transport failure; the caller chooses fatal-vs-tolerated.
|
||||
"""
|
||||
item = await gitea.get_contents(
|
||||
config.gitea_org, config.registry_repo, "projects.yaml", ref="main"
|
||||
)
|
||||
if not item or item.get("type") != "file":
|
||||
raise RegistryError(
|
||||
f"{config.gitea_org}/{config.registry_repo}/projects.yaml not found"
|
||||
)
|
||||
text = base64.b64decode(item["content"]).decode("utf-8")
|
||||
# Prefer the file's last commit sha for provenance (production Gitea
|
||||
# includes it on the contents response); fall back to the blob sha.
|
||||
sha = item.get("last_commit_sha") or item.get("sha") or ""
|
||||
doc = parse_registry(text)
|
||||
apply_registry(doc, sha)
|
||||
log.info("registry: mirrored %d project(s) at %s", len(doc.projects), sha)
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Roadmap #28 — scan submitted prose for RFC-shaped references.
|
||||
|
||||
The scanner splits a plain-text PR description / comment body into a list
|
||||
of *segments* the frontend renders: plain-text runs interleaved with
|
||||
typed link segments. The backend never emits HTML — the frontend maps
|
||||
each segment onto a React node — so the surface is XSS-safe by
|
||||
construction and independent of any HTML-sanitization layer.
|
||||
|
||||
Three buckets, one scan (Parts 1–3):
|
||||
|
||||
* ``{"type": "rfc", ...}`` — Part 1. The term matches an
|
||||
**accepted** (``state='active'``) RFC; renders as a link to it.
|
||||
* ``{"type": "rfc-pending", ...}`` — Part 3. The term matches a
|
||||
**pending** RFC — a super-draft (``state='super-draft'``: accepted
|
||||
as an idea but not yet graduated to an active RFC) — which has an
|
||||
owner and a contribution surface. Renders as an "ask to contribute"
|
||||
affordance carrying the owner's display name.
|
||||
* ``{"type": "rfc-candidate", ...}`` — Part 2. The term is a
|
||||
strong-candidate that does **not** yet have a defining RFC. Renders
|
||||
(for a viewer with create rights) as a "create RFC for '<term>'"
|
||||
affordance that pre-fills the propose flow.
|
||||
|
||||
Precedence at any position is active > pending > candidate, then
|
||||
longest-match-first — an active link always wins over a contribute offer
|
||||
which always wins over a create offer for the same span.
|
||||
|
||||
**Read-time enrichment, not submit-time persistence** (unchanged from
|
||||
Part 1): drafts are never scanned, only submitted content on the read
|
||||
paths, so links/offers track the *live* corpus. The active-RFC corpus,
|
||||
super-draft corpus, and tag taxonomy are all small and cache-resident,
|
||||
so building the index and scanning a ≤20k-char body per read is cheap.
|
||||
|
||||
**Matching stays conservative by design.** A reference links/offers only
|
||||
when it is unlikely to be coincidental:
|
||||
|
||||
* ``rfc_id`` tokens (e.g. ``RFC-0001``) — inherently specific.
|
||||
* Multi-word titles (containing whitespace, e.g. ``Open Human Model``).
|
||||
* Hyphenated slugs (containing ``-``, e.g. ``open-human-model``).
|
||||
|
||||
Single common-word titles/slugs are deliberately NOT matched — they
|
||||
would turn every prose occurrence into an affordance.
|
||||
|
||||
**Part 2 candidate heuristic.** A candidate term is a **multi-word tag**
|
||||
from the #27 tag taxonomy (the de-facto set of tags the corpus already
|
||||
carries) that has no defining RFC (no active or super-draft RFC whose
|
||||
slug or title is that term). Multi-word is the same false-positive guard
|
||||
the title rule uses: a single common tag word (``identity``) would be
|
||||
far too noisy. Broader candidate detection — capitalized multi-word
|
||||
phrases mined from the text, terms repeated across recently-touched PRs,
|
||||
or the #27 Haiku (``ANTHROPIC_API_KEY``) pathway — is a sanctioned but
|
||||
deferred extension; the conservative tag-taxonomy heuristic is chosen
|
||||
here to match Part 1's false-positive-averse philosophy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Iterable, NamedTuple
|
||||
|
||||
|
||||
class Term(NamedTuple):
|
||||
"""One match key plus what to emit when it hits.
|
||||
|
||||
``key`` is the lowercase span to match (word-boundary, longest-first).
|
||||
``kind`` is ``'active' | 'pending' | 'candidate'`` and selects the
|
||||
emitted segment shape. ``slug``/``title`` carry the target RFC (active
|
||||
+ pending); ``owner`` is the pending RFC's owner display name;
|
||||
``term`` is the candidate's canonical display spelling.
|
||||
"""
|
||||
|
||||
key: str
|
||||
kind: str = "active"
|
||||
slug: str = ""
|
||||
title: str = ""
|
||||
owner: str = ""
|
||||
term: str = ""
|
||||
|
||||
|
||||
# Lower number = higher precedence when two keys of equal length match at
|
||||
# the same position. A real link beats a contribute offer beats a create
|
||||
# offer.
|
||||
_KIND_PRIORITY = {"active": 0, "pending": 1, "candidate": 2}
|
||||
|
||||
|
||||
def _coerce(t: Term | tuple) -> Term:
|
||||
"""Accept the legacy ``(key, slug, title)`` 3-tuple (treated as an
|
||||
active term) alongside :class:`Term`, so direct unit-test callers and
|
||||
older call sites keep working."""
|
||||
if isinstance(t, Term):
|
||||
return t
|
||||
key, slug, title = t # legacy active 3-tuple
|
||||
return Term(key=key, kind="active", slug=slug, title=title)
|
||||
|
||||
|
||||
def _is_word_char(c: str) -> bool:
|
||||
"""Word-boundary test. Hyphen and underscore count as word chars so a
|
||||
match can't begin or end in the middle of a kebab/snake token."""
|
||||
return c.isalnum() or c in ("-", "_")
|
||||
|
||||
|
||||
def _emit(term: Term, label: str) -> dict[str, Any]:
|
||||
"""The segment dict for a matched ``term``; ``label`` preserves source
|
||||
casing."""
|
||||
if term.kind == "pending":
|
||||
return {
|
||||
"type": "rfc-pending",
|
||||
"slug": term.slug,
|
||||
"label": label,
|
||||
"title": term.title,
|
||||
"owner": term.owner,
|
||||
}
|
||||
if term.kind == "candidate":
|
||||
return {"type": "rfc-candidate", "label": label, "term": term.term}
|
||||
return {"type": "rfc", "slug": term.slug, "label": label, "title": term.title}
|
||||
|
||||
|
||||
def segment_text(text: str | None, terms: Iterable[Term | tuple]) -> list[dict[str, Any]]:
|
||||
"""Split ``text`` into text / link segments against ``terms``.
|
||||
|
||||
``terms`` are :class:`Term` objects (or legacy ``(key, slug, title)``
|
||||
active 3-tuples). Matching is case-insensitive, respects word
|
||||
boundaries on both ends, and prefers the longest key — then higher
|
||||
:data:`_KIND_PRIORITY` — at any position.
|
||||
|
||||
Always returns at least one segment; for empty/None input that is a
|
||||
single empty text segment, so callers can render uniformly.
|
||||
"""
|
||||
ordered = sorted(
|
||||
(_coerce(t) for t in terms),
|
||||
key=lambda t: (-len(t.key), _KIND_PRIORITY.get(t.kind, 9)),
|
||||
)
|
||||
if not text:
|
||||
return [{"type": "text", "text": text or ""}]
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
buf: list[str] = []
|
||||
low = text.lower()
|
||||
n = len(text)
|
||||
i = 0
|
||||
while i < n:
|
||||
match: tuple[Term, int] | None = None
|
||||
for term in ordered:
|
||||
klen = len(term.key)
|
||||
if klen == 0 or not low.startswith(term.key, i):
|
||||
continue
|
||||
before = text[i - 1] if i > 0 else ""
|
||||
after = text[i + klen] if i + klen < n else ""
|
||||
if _is_word_char(before) or _is_word_char(after):
|
||||
continue
|
||||
match = (term, klen)
|
||||
break
|
||||
if match is not None:
|
||||
term, klen = match
|
||||
if buf:
|
||||
out.append({"type": "text", "text": "".join(buf)})
|
||||
buf = []
|
||||
out.append(_emit(term, text[i:i + klen]))
|
||||
i += klen
|
||||
else:
|
||||
buf.append(text[i])
|
||||
i += 1
|
||||
if buf:
|
||||
out.append({"type": "text", "text": "".join(buf)})
|
||||
return out
|
||||
|
||||
|
||||
def _keys_for(slug: str, title: str, rfc_id: str | None) -> Iterable[str]:
|
||||
"""The match keys an RFC contributes. See the module docstring for why
|
||||
each gate exists (conservative, false-positive-averse)."""
|
||||
if rfc_id:
|
||||
rid = rfc_id.strip()
|
||||
if len(rid) >= 2:
|
||||
yield rid.lower()
|
||||
if title:
|
||||
t = title.strip()
|
||||
# Multi-word titles only — a single common word is too noisy.
|
||||
if len(t) >= 2 and (" " in t or "\t" in t):
|
||||
yield t.lower()
|
||||
if slug:
|
||||
s = slug.strip()
|
||||
# Hyphenated slugs only — a single-token slug is a bare word.
|
||||
if len(s) >= 2 and "-" in s:
|
||||
yield s.lower()
|
||||
|
||||
|
||||
def _slugify(term: str) -> str:
|
||||
"""Deterministic kebab-case — mirrors the propose modal's slugify so a
|
||||
tag's would-be slug compares correctly against existing RFC slugs."""
|
||||
return re.sub(r"-+$", "", re.sub(r"^-+", "", re.sub(r"[^a-z0-9]+", "-", term.lower().strip())))
|
||||
|
||||
|
||||
class LinkIndex:
|
||||
"""A reusable term index built once per request and applied to many
|
||||
bodies (a PR's description plus every comment on it)."""
|
||||
|
||||
def __init__(self, terms: Iterable[Term | tuple]):
|
||||
# Coerce + order once; segment_text re-sorts defensively but a
|
||||
# pre-sorted list keeps the per-body cost to the scan itself.
|
||||
self._terms: list[Term] = sorted(
|
||||
(_coerce(t) for t in terms),
|
||||
key=lambda t: (-len(t.key), _KIND_PRIORITY.get(t.kind, 9)),
|
||||
)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._terms)
|
||||
|
||||
def segment(self, text: str | None) -> list[dict[str, Any]]:
|
||||
return segment_text(text, self._terms)
|
||||
|
||||
|
||||
def _owner_display(conn, owners_json: str | None, proposed_by: str | None) -> str:
|
||||
"""The display name to show for a pending RFC's owner. First entry of
|
||||
``owners_json`` resolved to its user row's display name, falling back
|
||||
to the bare login, then ``proposed_by``, then a neutral noun."""
|
||||
login = None
|
||||
try:
|
||||
owners = json.loads(owners_json or "[]")
|
||||
if isinstance(owners, list):
|
||||
login = next((o for o in owners if isinstance(o, str) and o.strip()), None)
|
||||
except (ValueError, TypeError):
|
||||
login = None
|
||||
if login:
|
||||
row = conn.execute(
|
||||
"SELECT display_name FROM users WHERE gitea_login = ?", (login,)
|
||||
).fetchone()
|
||||
if row and row["display_name"]:
|
||||
return row["display_name"]
|
||||
return login
|
||||
return (proposed_by or "").strip() or "the proposer"
|
||||
|
||||
|
||||
def _tag_universe(conn) -> list[str]:
|
||||
"""Distinct tags across the cached corpus (the #27 de-facto taxonomy),
|
||||
preserving original spelling; case-deduped."""
|
||||
rows = conn.execute("SELECT tags_json FROM cached_rfcs").fetchall()
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for r in rows:
|
||||
try:
|
||||
tags = json.loads(r["tags_json"] or "[]")
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if not isinstance(tags, list):
|
||||
continue
|
||||
for t in tags:
|
||||
if not isinstance(t, str):
|
||||
continue
|
||||
tag = t.strip()
|
||||
low = tag.lower()
|
||||
if tag and low not in seen:
|
||||
seen.add(low)
|
||||
out.append(tag)
|
||||
return out
|
||||
|
||||
|
||||
def build_index(
|
||||
conn,
|
||||
*,
|
||||
exclude_slug: str | None = None,
|
||||
include_pending: bool = True,
|
||||
include_candidates: bool = True,
|
||||
) -> LinkIndex:
|
||||
"""Build a :class:`LinkIndex` over the three buckets.
|
||||
|
||||
``exclude_slug`` drops the RFC the surrounding surface is itself scoped
|
||||
to, so an RFC's own title/id/slug don't self-link (or self-offer)
|
||||
inside its own PR or discussion. Precedence is enforced by insertion
|
||||
order — active keys are added first and a later bucket never overrides
|
||||
an already-claimed key.
|
||||
"""
|
||||
terms: list[Term] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(key: str, term: Term) -> None:
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
terms.append(term)
|
||||
|
||||
# --- Part 1: accepted (active) RFCs. ORDER BY slug makes key
|
||||
# de-duplication deterministic when two RFCs would contribute the
|
||||
# same key (first slug wins). ---
|
||||
active_rows = conn.execute(
|
||||
"SELECT slug, title, rfc_id FROM cached_rfcs WHERE state = 'active' ORDER BY slug"
|
||||
).fetchall()
|
||||
# Track every slug + title that *has* a defining RFC, so Part 2 never
|
||||
# offers to create one that already exists (active or pending).
|
||||
defined_slugs: set[str] = set()
|
||||
defined_titles: set[str] = set()
|
||||
for r in active_rows:
|
||||
slug = r["slug"]
|
||||
defined_slugs.add((slug or "").lower())
|
||||
defined_titles.add((r["title"] or "").strip().lower())
|
||||
if exclude_slug is not None and slug == exclude_slug:
|
||||
continue
|
||||
title = r["title"] or ""
|
||||
rfc_id = r["rfc_id"] if "rfc_id" in r.keys() else None
|
||||
for key in _keys_for(slug, title, rfc_id):
|
||||
add(key, Term(key=key, kind="active", slug=slug, title=title))
|
||||
|
||||
# --- Part 3: pending (super-draft) RFCs. ---
|
||||
pending_rows = conn.execute(
|
||||
"""
|
||||
SELECT slug, title, rfc_id, owners_json, proposed_by
|
||||
FROM cached_rfcs WHERE state = 'super-draft' ORDER BY slug
|
||||
"""
|
||||
).fetchall()
|
||||
for r in pending_rows:
|
||||
slug = r["slug"]
|
||||
defined_slugs.add((slug or "").lower())
|
||||
defined_titles.add((r["title"] or "").strip().lower())
|
||||
if not include_pending:
|
||||
continue
|
||||
if exclude_slug is not None and slug == exclude_slug:
|
||||
continue
|
||||
title = r["title"] or ""
|
||||
rfc_id = r["rfc_id"] if "rfc_id" in r.keys() else None
|
||||
owner = _owner_display(conn, r["owners_json"], r["proposed_by"])
|
||||
for key in _keys_for(slug, title, rfc_id):
|
||||
add(key, Term(key=key, kind="pending", slug=slug, title=title, owner=owner))
|
||||
|
||||
# --- Part 2: strong-candidate terms with no defining RFC. ---
|
||||
if include_candidates:
|
||||
for tag in _tag_universe(conn):
|
||||
low = tag.lower()
|
||||
# Conservative: multi-word tags only (same guard as titles).
|
||||
if " " not in tag and "\t" not in tag:
|
||||
continue
|
||||
if low in defined_titles or low in defined_slugs or _slugify(tag) in defined_slugs:
|
||||
continue
|
||||
add(low, Term(key=low, kind="candidate", term=tag))
|
||||
|
||||
return LinkIndex(terms)
|
||||
@@ -73,6 +73,19 @@ def _siteverify_url() -> str:
|
||||
return os.environ.get("TURNSTILE_SITEVERIFY_URL", "").strip() or SITEVERIFY_URL
|
||||
|
||||
|
||||
async def _siteverify_post(url: str, data: dict) -> httpx.Response:
|
||||
"""Perform the siteverify POST on an `httpx.AsyncClient`.
|
||||
|
||||
Isolated as a narrow seam (I4, security-audit-0026): the call is
|
||||
awaited so a slow CloudFlare response can't block the event loop,
|
||||
and tests patch *this function* rather than the shared
|
||||
`httpx.AsyncClient` (which other modules — gitea, docs — also
|
||||
construct, so a global patch would break app boot).
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
return await client.post(url, data=data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VerifyOutcome:
|
||||
"""Result of a Turnstile siteverify call.
|
||||
@@ -98,7 +111,7 @@ class VerifyOutcome:
|
||||
reason: str
|
||||
|
||||
|
||||
def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOutcome:
|
||||
async def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOutcome:
|
||||
"""Validate a Turnstile token against CloudFlare's siteverify endpoint.
|
||||
|
||||
Returns a VerifyOutcome describing whether the calling endpoint
|
||||
@@ -108,9 +121,14 @@ def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOu
|
||||
* 'misconfigured' → 500 "auth misconfigured"
|
||||
* 'missing-token' / 'failed' / 'network' → 400 "verification failed"
|
||||
|
||||
Tests monkeypatch `httpx.post` (or set `TURNSTILE_SITEVERIFY_URL`
|
||||
+ a MockTransport client) to avoid touching the real CloudFlare
|
||||
endpoint. No real keys are ever embedded in tests.
|
||||
Async (I4, security-audit-0026): the siteverify call is awaited on an
|
||||
`httpx.AsyncClient` so a slow CloudFlare response can't block the
|
||||
event loop (the prior synchronous `httpx.post` stalled the single
|
||||
worker for up to the 10s timeout). Callers must `await` it.
|
||||
|
||||
Tests monkeypatch `_siteverify_post` (the narrow async seam) to avoid
|
||||
touching the real CloudFlare endpoint and to keep the patch off the
|
||||
shared `httpx.AsyncClient`. No real keys are ever embedded in tests.
|
||||
"""
|
||||
secret = _secret()
|
||||
required = _required()
|
||||
@@ -133,7 +151,7 @@ def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOu
|
||||
data["remoteip"] = client_ip
|
||||
|
||||
try:
|
||||
response = httpx.post(_siteverify_url(), data=data, timeout=10.0)
|
||||
response = await _siteverify_post(_siteverify_url(), data)
|
||||
payload = response.json()
|
||||
except Exception as exc: # network, JSON parse, etc.
|
||||
log.warning("Turnstile siteverify call failed: %s", exc)
|
||||
|
||||
+16
-8
@@ -16,7 +16,7 @@ import os
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
|
||||
from . import cache, db
|
||||
from . import cache, db, projects as projects_mod, registry as registry_mod
|
||||
from .config import Config
|
||||
from .gitea import Gitea
|
||||
|
||||
@@ -79,9 +79,22 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
|
||||
except Exception:
|
||||
payload = {}
|
||||
repo_full = (payload.get("repository") or {}).get("full_name") or ""
|
||||
meta_full = f"{config.gitea_org}/{config.meta_repo}"
|
||||
registry_full = f"{config.gitea_org}/{config.registry_repo}"
|
||||
content_repo = projects_mod.default_content_repo(config)
|
||||
if not content_repo:
|
||||
log.warning("webhook: default project content_repo is unknown; corpus refresh skipped")
|
||||
content_full = f"{config.gitea_org}/{content_repo}" if content_repo else None
|
||||
try:
|
||||
if repo_full == meta_full or not repo_full:
|
||||
if repo_full == registry_full:
|
||||
# §22.2: a registry-repo push re-mirrors the projects table.
|
||||
# Tolerate a malformed projects.yaml (keep last-good rows); let a
|
||||
# transport error bubble to the outer 500 so an unreachable Gitea
|
||||
# on a registry push is loud rather than silently dropped.
|
||||
try:
|
||||
await registry_mod.refresh_registry(config, gitea)
|
||||
except registry_mod.RegistryError:
|
||||
log.exception("registry webhook: invalid projects.yaml; keeping last-good")
|
||||
elif content_full and (repo_full == content_full or not repo_full):
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
await cache.refresh_meta_branches(config, gitea)
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
@@ -90,11 +103,6 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
|
||||
if slug:
|
||||
await cache.refresh_rfc_repo(config, gitea, slug)
|
||||
else:
|
||||
# v0.18.0: the proposal's "unknown-repo logging"
|
||||
# gesture — a hook on a fork or a stale repo binding
|
||||
# used to silently 200-OK here, hiding the
|
||||
# misconfiguration. Now the operator sees it in
|
||||
# the log.
|
||||
log.info(
|
||||
"webhook received for unknown repo: repo_full=%s event=%s "
|
||||
"(no cached_rfcs row matched; hook may be on a fork or stale)",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- v0.25.0 / security audit 0026, finding H1.
|
||||
--
|
||||
-- The OTC verify path had no attempt-limit or lockout, unlike the
|
||||
-- passcode path (015_passcode.sql gave users.passcode_failed_attempts +
|
||||
-- passcode_locked_until). This table gives the OTC verify endpoint the
|
||||
-- same per-identity lockout shape. It is keyed by email rather than
|
||||
-- user_id because an OTC sign-in may not have a users row yet — the row
|
||||
-- is provisioned only on a *successful* verify, so the lockout state has
|
||||
-- to survive independently of it.
|
||||
--
|
||||
-- The per-IP rate limiter (app/ratelimit.py) is the primary brute-force
|
||||
-- defense; this table is the parity layer that mirrors the passcode
|
||||
-- lockout and persists across restarts.
|
||||
CREATE TABLE IF NOT EXISTS otc_verify_state (
|
||||
email TEXT PRIMARY KEY,
|
||||
failed_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TEXT
|
||||
);
|
||||
@@ -0,0 +1,59 @@
|
||||
-- v0.29.0 / roadmap #28 Part 3 — offer-to-contribute-to-a-pending-RFC.
|
||||
--
|
||||
-- When the #28 scanner matches a term in submitted PR/comment text to a
|
||||
-- *pending* RFC (a super-draft: accepted-as-an-idea but not yet graduated
|
||||
-- to an active RFC), the reader is offered a "ask to contribute" popover.
|
||||
-- Submitting it lands a row here AND a notification in each owner's §15
|
||||
-- inbox; the owner can accept (which fires #12's owner-invite flow with
|
||||
-- the requester as the invitee) or decline (the requester is notified and
|
||||
-- the request closes).
|
||||
--
|
||||
-- A "pending RFC" is scoped to a super-draft (cached_rfcs.state =
|
||||
-- 'super-draft'): it is in cached_rfcs (so the rfc_invitations FK that the
|
||||
-- accept path reuses resolves), it carries owners (owners_json) to route
|
||||
-- the request to, and it already has a discussion/contribution surface to
|
||||
-- open. Pre-merge idea PRs (not yet in cached_rfcs, no contribution
|
||||
-- surface) are deliberately out of scope — see backend/app/rfc_links.py.
|
||||
--
|
||||
-- The request row is the persistent record; the inbox notification is the
|
||||
-- owner-facing actionable surface keyed back to it via `notification_id`.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contribution_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL
|
||||
REFERENCES cached_rfcs(slug) ON DELETE CASCADE,
|
||||
requester_user_id INTEGER NOT NULL
|
||||
REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- The term in the PR/comment text that surfaced the offer (e.g. the
|
||||
-- super-draft's title). Carried for the owner's context line and the
|
||||
-- requester's "what RFC" anchor; not a foreign key.
|
||||
matched_term TEXT NOT NULL,
|
||||
-- The three contribute-request fields (§15 / #26 vocabulary).
|
||||
-- `who_i_am` and `why` are required; `use_case` mirrors #26's
|
||||
-- optional ground-truth field.
|
||||
who_i_am TEXT NOT NULL,
|
||||
why TEXT NOT NULL,
|
||||
use_case TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'accepted', 'declined')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
decided_at TEXT,
|
||||
decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- The rfc_invitations row minted on accept (the #12 reuse), and the
|
||||
-- owner-facing notification row that carries the Accept/Decline action.
|
||||
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
|
||||
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_contribution_requests_rfc
|
||||
ON contribution_requests(rfc_slug, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_contribution_requests_requester
|
||||
ON contribution_requests(requester_user_id, status);
|
||||
|
||||
-- At most one open (pending) request per (RFC, requester): a second ask
|
||||
-- while one is still pending is a 409, not a duplicate row. A decided
|
||||
-- request (accepted/declined) does not block a fresh ask later.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_contribution_requests_one_open
|
||||
ON contribution_requests(rfc_slug, requester_user_id)
|
||||
WHERE status = 'pending';
|
||||
@@ -0,0 +1,61 @@
|
||||
-- §3 / §13.7: add the `retired` soft-delete state to cached_rfcs.
|
||||
--
|
||||
-- SQLite cannot ALTER a CHECK constraint in place, so we rebuild the table
|
||||
-- with the expanded constraint and copy the rows across. cached_rfcs is a
|
||||
-- §4 cache (reconstructible from Gitea by the reconciler), and nothing
|
||||
-- holds a foreign key into it, so the rebuild is safe; we preserve the
|
||||
-- existing rows anyway to avoid a needless full re-read on upgrade.
|
||||
--
|
||||
-- The rebuilt table must carry EVERY column cached_rfcs has accumulated,
|
||||
-- including the ones added by later migrations via ALTER TABLE ADD COLUMN:
|
||||
-- 009_per_rfc_models -> models_json
|
||||
-- 010_funder -> funder_login
|
||||
-- 021_proposed_use_case -> proposed_use_case
|
||||
-- They are appended last (matching the live column order) and copied
|
||||
-- across explicitly so nothing is dropped.
|
||||
--
|
||||
-- The migration runner wraps this file in a single BEGIN/COMMIT, so the
|
||||
-- swap is atomic.
|
||||
|
||||
CREATE TABLE cached_rfcs_new (
|
||||
slug TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn', 'retired')),
|
||||
rfc_id TEXT, -- 'RFC-NNNN' or NULL (NULL is also valid for an active RFC graduated without a number, §13.2)
|
||||
repo TEXT, -- 'org/repo' or NULL; always NULL under the meta-only topology (§1)
|
||||
proposed_by TEXT,
|
||||
proposed_at TEXT,
|
||||
graduated_at TEXT,
|
||||
graduated_by TEXT,
|
||||
owners_json TEXT NOT NULL DEFAULT '[]',
|
||||
arbiters_json TEXT NOT NULL DEFAULT '[]',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
body TEXT,
|
||||
body_sha TEXT,
|
||||
last_main_commit_at TEXT,
|
||||
last_entry_commit_at TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
models_json TEXT, -- 009_per_rfc_models
|
||||
funder_login TEXT, -- 010_funder
|
||||
proposed_use_case TEXT -- 021_proposed_use_case
|
||||
);
|
||||
|
||||
INSERT INTO cached_rfcs_new
|
||||
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
|
||||
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
|
||||
body, body_sha, last_main_commit_at, last_entry_commit_at, updated_at,
|
||||
models_json, funder_login, proposed_use_case)
|
||||
SELECT
|
||||
slug, title, state, rfc_id, repo, proposed_by, proposed_at,
|
||||
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
|
||||
body, body_sha, last_main_commit_at, last_entry_commit_at, updated_at,
|
||||
models_json, funder_login, proposed_use_case
|
||||
FROM cached_rfcs;
|
||||
|
||||
DROP TABLE cached_rfcs;
|
||||
ALTER TABLE cached_rfcs_new RENAME TO cached_rfcs;
|
||||
|
||||
CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state);
|
||||
CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs (
|
||||
COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
||||
);
|
||||
@@ -0,0 +1,105 @@
|
||||
-- §22 (multi-project deployments) — Slice M1: the project spine.
|
||||
--
|
||||
-- A deployment now hosts one or more *projects*, each a corpus with its own
|
||||
-- content repo, slug/RFC-NNNN namespace, catalog, roster, and branding. The
|
||||
-- pre-multi-project single-corpus deployment is the N=1 case: this migration
|
||||
-- generates one 'default' project and stamps every existing RFC-scoped row
|
||||
-- to it, so the app keeps running exactly as before with the spine
|
||||
-- underneath (see docs/design/multi-project-spec.md §22.13).
|
||||
--
|
||||
-- STRATEGY — additive, no table rebuilds. Every slug-bearing table gets a
|
||||
-- `project_id TEXT NOT NULL DEFAULT 'default'` column. The constant default
|
||||
-- means every existing INSERT in the codebase that does not yet mention
|
||||
-- project_id keeps working and lands rows in the default project; no query
|
||||
-- breaks because, with a single project, slugs remain globally unique. The
|
||||
-- column carries no inline REFERENCES clause: SQLite's ALTER TABLE ADD
|
||||
-- COLUMN forbids a FK column with a non-NULL default. project_id referential
|
||||
-- integrity is therefore enforced at the app layer for now; the FK lands
|
||||
-- with the table rebuilds below.
|
||||
--
|
||||
-- ============================================================================
|
||||
-- DEFERRED to the slice that activates project #2 (M3/M4). Until a second
|
||||
-- project exists these are correct as-is; the moment two projects can share a
|
||||
-- slug or a Gitea PR number they become cross-project collision bugs and MUST
|
||||
-- be rebuilt (SQLite needs a create-copy-drop-rename per table) to fold
|
||||
-- project_id into the key, and to add the project_id FK:
|
||||
-- * cached_rfcs PRIMARY KEY (slug) -> (project_id, slug)
|
||||
-- * cached_branches UNIQUE (rfc_slug, branch_name) -> +project_id
|
||||
-- * branch_visibility UNIQUE (rfc_slug, branch_name) -> +project_id
|
||||
-- * branch_contribute_grants UNIQUE (rfc_slug, branch_name, grantee_user_id) -> +project_id
|
||||
-- * stars UNIQUE (user_id, rfc_slug) -> +project_id
|
||||
-- * watches UNIQUE (user_id, rfc_slug) -> +project_id
|
||||
-- * pr_seen UNIQUE (user_id, rfc_slug, pr_number) -> +project_id
|
||||
-- * branch_chat_seen UNIQUE (user_id, rfc_slug, branch_name) -> +project_id
|
||||
-- * funder_consents PRIMARY KEY (user_id, rfc_slug) -> +project_id
|
||||
-- * rfc_collaborators UNIQUE INDEX (rfc_slug, user_id) -> +project_id
|
||||
-- * contribution_requests UNIQUE INDEX (rfc_slug, requester_user_id) WHERE pending -> +project_id
|
||||
-- * proposed_use_cases UNIQUE (scope, pr_number) -> +project_id
|
||||
-- (PR numbers are per-content-repo = per-project)
|
||||
-- cached_prs UNIQUE (repo, pr_number) is already globally unique (repo is the
|
||||
-- full 'org/repo' string, distinct per project) and needs no rebuild.
|
||||
-- ============================================================================
|
||||
|
||||
-- The project registry cache (mirrored from the git registry by the M3
|
||||
-- reconciler; rows are never written from user actions). content_repo is
|
||||
-- NULL until the mirror — or the M1 startup backfill (§22.13 step 1) — sets
|
||||
-- it from the deployment's configured repo.
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
content_repo TEXT,
|
||||
visibility TEXT NOT NULL DEFAULT 'gated'
|
||||
CHECK (visibility IN ('gated', 'public', 'unlisted')),
|
||||
config_json TEXT,
|
||||
registry_sha TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- The default project. visibility='public' preserves the pre-multi-project
|
||||
-- open-by-default posture (§22.5, §22.13). name is a placeholder the startup
|
||||
-- backfill / registry overwrites with the deployment's display name.
|
||||
INSERT OR IGNORE INTO projects (id, name, visibility)
|
||||
VALUES ('default', 'default', 'public');
|
||||
|
||||
-- Per-(user, project) membership and the §22.6 middle-tier role. This is the
|
||||
-- new tier between the §6.1 deployment role (users.role, now deployment-scope
|
||||
-- only) and the §6.3 per-RFC authority.
|
||||
CREATE TABLE IF NOT EXISTS project_members (
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'project_viewer'
|
||||
CHECK (role IN ('project_admin', 'project_contributor', 'project_viewer')),
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (project_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_members_user ON project_members(user_id);
|
||||
|
||||
-- The project_id spine across every slug-bearing table. Backfills existing
|
||||
-- rows to 'default' via the constant column default.
|
||||
ALTER TABLE cached_rfcs ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE cached_branches ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE cached_prs ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE branch_visibility ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE branch_contribute_grants ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE stars ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE threads ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE changes ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE pr_seen ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE branch_chat_seen ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE watches ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE notifications ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE actions ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE pr_resolution_branches ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE funder_consents ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE rfc_invitations ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE rfc_collaborators ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE proposed_use_cases ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE contribution_requests ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
|
||||
-- The catalog/directory lookup the M3 surfaces will make (RFCs in a project).
|
||||
-- The per-table composite-key rebuilds in the DEFERRED block above will add
|
||||
-- their own (project_id, …) indexes when they land.
|
||||
CREATE INDEX IF NOT EXISTS idx_cached_rfcs_project ON cached_rfcs(project_id);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- §22 M3 (Plan A) — additive registry/runtime-config schema.
|
||||
--
|
||||
-- This is the additive half of M3's backend. It adds the project `type` and
|
||||
-- `initial_state` columns (mirrored from the registry), the deployment
|
||||
-- singleton (deployment name/tagline mirrored from the registry), and the
|
||||
-- §22.4c review columns on cached_rfcs. NO table rebuilds: the §22.13 PK
|
||||
-- rebuilds and the default->slug re-stamp ride a later migration (Plan B),
|
||||
-- just before a second project can collide (see migration 026's DEFERRED
|
||||
-- block and docs/superpowers/specs/2026-06-03-m3-backend-design.md §1/§6).
|
||||
|
||||
ALTER TABLE projects ADD COLUMN type TEXT NOT NULL DEFAULT 'document'
|
||||
CHECK (type IN ('document', 'specification', 'bdd'));
|
||||
ALTER TABLE projects ADD COLUMN initial_state TEXT NOT NULL DEFAULT 'super-draft'
|
||||
CHECK (initial_state IN ('super-draft', 'active'));
|
||||
|
||||
-- Deployment-level identity (name, tagline) mirrored from the registry's
|
||||
-- `deployment:` block. A singleton: the CHECK pins it to one row.
|
||||
CREATE TABLE IF NOT EXISTS deployment (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
name TEXT,
|
||||
tagline TEXT,
|
||||
registry_sha TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
INSERT OR IGNORE INTO deployment (id) VALUES (1);
|
||||
|
||||
-- §22.4c review flag + provenance. unreviewed is git-truth (mirrored from
|
||||
-- entry frontmatter); it survives a cache rebuild like `state` does.
|
||||
ALTER TABLE cached_rfcs ADD COLUMN unreviewed INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE cached_rfcs ADD COLUMN reviewed_at TEXT;
|
||||
ALTER TABLE cached_rfcs ADD COLUMN reviewed_by TEXT;
|
||||
@@ -0,0 +1,276 @@
|
||||
-- migrate:no-foreign-keys
|
||||
--
|
||||
-- §22.13 / §22.4 — fold project_id into the slug-keyed PRIMARY KEY / UNIQUE
|
||||
-- constraints that migration 026 deliberately left global, so a *second*
|
||||
-- project can hold an entry with the same slug as the first. M1 (026) added
|
||||
-- project_id additively (no rebuild); this is the rebuild that activates
|
||||
-- project #2, enumerated in 026's header.
|
||||
--
|
||||
-- SQLite can't ALTER a PK/UNIQUE in place, so each table is rebuilt by the
|
||||
-- official procedure: create `<t>__new` with the new constraint, copy, DROP
|
||||
-- the live table, rename `<t>__new` -> `<t>`, recreate its indexes. FK
|
||||
-- enforcement is OFF for the whole file (the `migrate:no-foreign-keys` marker
|
||||
-- above tells the runner to toggle it and run foreign_key_check after).
|
||||
--
|
||||
-- DROP-the-live-table (rather than rename-live-to-__old) is deliberate: SQLite
|
||||
-- rewrites child FK references when you RENAME a *referenced* table, so we drop
|
||||
-- the old cached_rfcs (allowed with FK off) and rename the temp in. cached_rfcs
|
||||
-- is rebuilt FIRST so rfc_collaborators / contribution_requests can re-point
|
||||
-- their FK at its new composite (project_id, slug) key.
|
||||
|
||||
-- ── cached_rfcs: PRIMARY KEY (slug) -> (project_id, slug) ──────────────────
|
||||
CREATE TABLE cached_rfcs__new (
|
||||
slug TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn', 'retired')),
|
||||
rfc_id TEXT,
|
||||
repo TEXT,
|
||||
proposed_by TEXT,
|
||||
proposed_at TEXT,
|
||||
graduated_at TEXT,
|
||||
graduated_by TEXT,
|
||||
owners_json TEXT NOT NULL DEFAULT '[]',
|
||||
arbiters_json TEXT NOT NULL DEFAULT '[]',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
body TEXT,
|
||||
body_sha TEXT,
|
||||
last_main_commit_at TEXT,
|
||||
last_entry_commit_at TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
models_json TEXT,
|
||||
funder_login TEXT,
|
||||
proposed_use_case TEXT,
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
unreviewed INTEGER NOT NULL DEFAULT 0,
|
||||
reviewed_at TEXT,
|
||||
reviewed_by TEXT,
|
||||
PRIMARY KEY (project_id, slug)
|
||||
);
|
||||
INSERT INTO cached_rfcs__new SELECT * FROM cached_rfcs;
|
||||
DROP TABLE cached_rfcs;
|
||||
ALTER TABLE cached_rfcs__new RENAME TO cached_rfcs;
|
||||
CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state);
|
||||
CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs (
|
||||
COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
||||
);
|
||||
CREATE INDEX idx_cached_rfcs_project ON cached_rfcs(project_id);
|
||||
|
||||
-- ── rfc_invitations: FK rfc_slug -> cached_rfcs(slug) becomes composite ────
|
||||
-- (no key change of its own, but its single-column FK to cached_rfcs is now a
|
||||
-- mismatch against the composite PK, so it must be rebuilt too). Rebuilt after
|
||||
-- cached_rfcs (its FK target) and before the two tables that FK rfc_invitations.
|
||||
CREATE TABLE rfc_invitations__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
inviter_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
invitee_email TEXT NOT NULL,
|
||||
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')),
|
||||
token TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
accepted_at TEXT,
|
||||
accepted_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO rfc_invitations__new SELECT * FROM rfc_invitations;
|
||||
DROP TABLE rfc_invitations;
|
||||
ALTER TABLE rfc_invitations__new RENAME TO rfc_invitations;
|
||||
CREATE UNIQUE INDEX idx_rfc_invitations_token ON rfc_invitations (token);
|
||||
CREATE INDEX idx_rfc_invitations_rfc_status ON rfc_invitations (rfc_slug, status);
|
||||
CREATE INDEX idx_rfc_invitations_email_status ON rfc_invitations (invitee_email, status);
|
||||
|
||||
-- ── cached_branches: UNIQUE (rfc_slug, branch_name) -> +project_id ─────────
|
||||
CREATE TABLE cached_branches__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
head_sha TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed', 'deleted')),
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_commit_at TEXT,
|
||||
closed_at TEXT,
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, rfc_slug, branch_name)
|
||||
);
|
||||
INSERT INTO cached_branches__new SELECT * FROM cached_branches;
|
||||
DROP TABLE cached_branches;
|
||||
ALTER TABLE cached_branches__new RENAME TO cached_branches;
|
||||
CREATE INDEX idx_cached_branches_rfc ON cached_branches (rfc_slug, state);
|
||||
|
||||
-- ── branch_visibility: UNIQUE (rfc_slug, branch_name) -> +project_id ───────
|
||||
CREATE TABLE branch_visibility__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
read_public INTEGER NOT NULL DEFAULT 1,
|
||||
contribute_mode TEXT NOT NULL DEFAULT 'just-me' CHECK (contribute_mode IN ('just-me', 'specific', 'any-contributor')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, rfc_slug, branch_name)
|
||||
);
|
||||
INSERT INTO branch_visibility__new SELECT * FROM branch_visibility;
|
||||
DROP TABLE branch_visibility;
|
||||
ALTER TABLE branch_visibility__new RENAME TO branch_visibility;
|
||||
|
||||
-- ── branch_contribute_grants: UNIQUE (rfc_slug, branch_name, grantee) -> +pid
|
||||
CREATE TABLE branch_contribute_grants__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
grantee_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
granted_by INTEGER NOT NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, rfc_slug, branch_name, grantee_user_id)
|
||||
);
|
||||
INSERT INTO branch_contribute_grants__new SELECT * FROM branch_contribute_grants;
|
||||
DROP TABLE branch_contribute_grants;
|
||||
ALTER TABLE branch_contribute_grants__new RENAME TO branch_contribute_grants;
|
||||
CREATE INDEX idx_grants_lookup ON branch_contribute_grants (rfc_slug, branch_name);
|
||||
CREATE INDEX idx_grants_grantee ON branch_contribute_grants (grantee_user_id);
|
||||
|
||||
-- ── stars: UNIQUE (user_id, rfc_slug) -> +project_id ───────────────────────
|
||||
CREATE TABLE stars__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
starred_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, user_id, rfc_slug)
|
||||
);
|
||||
INSERT INTO stars__new SELECT * FROM stars;
|
||||
DROP TABLE stars;
|
||||
ALTER TABLE stars__new RENAME TO stars;
|
||||
CREATE INDEX idx_stars_user ON stars (user_id);
|
||||
CREATE INDEX idx_stars_rfc ON stars (rfc_slug);
|
||||
|
||||
-- ── watches: UNIQUE (user_id, rfc_slug) -> +project_id ─────────────────────
|
||||
CREATE TABLE watches__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('watching', 'following', 'muted')),
|
||||
set_by TEXT NOT NULL CHECK (set_by IN ('auto', 'explicit')),
|
||||
set_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_participation_at TEXT,
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, user_id, rfc_slug)
|
||||
);
|
||||
INSERT INTO watches__new SELECT * FROM watches;
|
||||
DROP TABLE watches;
|
||||
ALTER TABLE watches__new RENAME TO watches;
|
||||
CREATE INDEX idx_watches_user ON watches (user_id);
|
||||
CREATE INDEX idx_watches_rfc ON watches (rfc_slug);
|
||||
CREATE INDEX idx_watches_decay ON watches (state, last_participation_at);
|
||||
|
||||
-- ── pr_seen: UNIQUE (user_id, rfc_slug, pr_number) -> +project_id ──────────
|
||||
CREATE TABLE pr_seen__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
pr_number INTEGER NOT NULL,
|
||||
last_seen_commit_sha TEXT,
|
||||
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
|
||||
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, user_id, rfc_slug, pr_number)
|
||||
);
|
||||
INSERT INTO pr_seen__new SELECT * FROM pr_seen;
|
||||
DROP TABLE pr_seen;
|
||||
ALTER TABLE pr_seen__new RENAME TO pr_seen;
|
||||
|
||||
-- ── branch_chat_seen: UNIQUE (user_id, rfc_slug, branch_name) -> +project_id
|
||||
CREATE TABLE branch_chat_seen__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
|
||||
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, user_id, rfc_slug, branch_name)
|
||||
);
|
||||
INSERT INTO branch_chat_seen__new SELECT * FROM branch_chat_seen;
|
||||
DROP TABLE branch_chat_seen;
|
||||
ALTER TABLE branch_chat_seen__new RENAME TO branch_chat_seen;
|
||||
|
||||
-- ── funder_consents: PRIMARY KEY (user_id, rfc_slug) -> +project_id ────────
|
||||
CREATE TABLE funder_consents__new (
|
||||
user_id INTEGER NOT NULL,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
PRIMARY KEY (project_id, user_id, rfc_slug),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO funder_consents__new SELECT * FROM funder_consents;
|
||||
DROP TABLE funder_consents;
|
||||
ALTER TABLE funder_consents__new RENAME TO funder_consents;
|
||||
CREATE INDEX idx_funder_consents_slug ON funder_consents (rfc_slug);
|
||||
|
||||
-- ── rfc_collaborators: UNIQUE idx (rfc_slug, user_id) -> +project_id;
|
||||
-- FK rfc_slug -> cached_rfcs(slug) becomes composite (project_id, rfc_slug)
|
||||
CREATE TABLE rfc_collaborators__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
|
||||
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO rfc_collaborators__new SELECT * FROM rfc_collaborators;
|
||||
DROP TABLE rfc_collaborators;
|
||||
ALTER TABLE rfc_collaborators__new RENAME TO rfc_collaborators;
|
||||
CREATE UNIQUE INDEX idx_rfc_collaborators_unique ON rfc_collaborators (project_id, rfc_slug, user_id);
|
||||
CREATE INDEX idx_rfc_collaborators_user ON rfc_collaborators (user_id);
|
||||
|
||||
-- ── contribution_requests: UNIQUE idx (rfc_slug, requester) WHERE pending
|
||||
-- -> +project_id; FK rfc_slug -> cached_rfcs(slug) becomes composite.
|
||||
CREATE TABLE contribution_requests__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
requester_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
matched_term TEXT NOT NULL,
|
||||
who_i_am TEXT NOT NULL,
|
||||
why TEXT NOT NULL,
|
||||
use_case TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'accepted', 'declined')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
decided_at TEXT,
|
||||
decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
|
||||
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL,
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO contribution_requests__new SELECT * FROM contribution_requests;
|
||||
DROP TABLE contribution_requests;
|
||||
ALTER TABLE contribution_requests__new RENAME TO contribution_requests;
|
||||
CREATE INDEX idx_contribution_requests_rfc ON contribution_requests(rfc_slug, status);
|
||||
CREATE INDEX idx_contribution_requests_requester ON contribution_requests(requester_user_id, status);
|
||||
CREATE UNIQUE INDEX idx_contribution_requests_one_open
|
||||
ON contribution_requests(project_id, rfc_slug, requester_user_id)
|
||||
WHERE status = 'pending';
|
||||
|
||||
-- ── proposed_use_cases: UNIQUE (scope, pr_number) -> +project_id ───────────
|
||||
CREATE TABLE proposed_use_cases__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('rfc', 'pr')),
|
||||
rfc_slug TEXT NOT NULL,
|
||||
pr_number INTEGER NOT NULL,
|
||||
use_case TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, scope, pr_number)
|
||||
);
|
||||
INSERT INTO proposed_use_cases__new SELECT * FROM proposed_use_cases;
|
||||
DROP TABLE proposed_use_cases;
|
||||
ALTER TABLE proposed_use_cases__new RENAME TO proposed_use_cases;
|
||||
CREATE INDEX idx_proposed_use_cases_lookup ON proposed_use_cases (scope, pr_number);
|
||||
CREATE INDEX idx_proposed_use_cases_slug ON proposed_use_cases (scope, rfc_slug);
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Shared pytest fixtures for the backend suite.
|
||||
|
||||
Added in v0.27.0 (security audit 0026) alongside the new per-IP rate
|
||||
limiter. The limiters in `app.ratelimit` are process-global singletons,
|
||||
so their state survives across tests within a run; without a reset, the
|
||||
accumulated requests from earlier tests exhaust the budget and later
|
||||
tests see spurious 429s. This autouse fixture gives every test a clean
|
||||
limiter window.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app import ratelimit
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_rate_limiters():
|
||||
ratelimit._reset_all_for_tests()
|
||||
yield
|
||||
ratelimit._reset_all_for_tests()
|
||||
@@ -0,0 +1,132 @@
|
||||
"""§22.9/§22.5 — GET /api/deployment + GET /api/projects/:id with visibility."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def _add_project(pid, name, vis, typ="document"):
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO projects (id, name, type, content_repo, visibility, initial_state) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'super-draft')",
|
||||
(pid, name, typ, pid, vis),
|
||||
)
|
||||
|
||||
|
||||
def test_deployment_lists_public_omits_gated_and_unlisted_for_anon(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("pub", "Public", "public")
|
||||
_add_project("gat", "Gated", "gated")
|
||||
_add_project("unl", "Unlisted", "unlisted")
|
||||
r = client.get("/api/deployment")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["name"] == "Test Deployment"
|
||||
ids = {p["id"] for p in body["projects"]}
|
||||
assert "pub" in ids and "default" in ids # both public
|
||||
assert "gat" not in ids # gated, anon not a member
|
||||
assert "unl" not in ids # unlisted never enumerated
|
||||
|
||||
|
||||
def test_projects_id_404_for_gated_non_member(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("gat", "Gated", "gated")
|
||||
assert client.get("/api/projects/gat").status_code == 404
|
||||
|
||||
|
||||
def test_projects_id_returns_config_for_public(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"UPDATE projects SET config_json = ? WHERE id = 'default'",
|
||||
('{"theme": {"accent": "#5b5bd6"}}',),
|
||||
)
|
||||
r = client.get("/api/projects/default")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["id"] == "default"
|
||||
assert body["type"] == "document"
|
||||
assert body["visibility"] == "public"
|
||||
assert body["theme"] == {"accent": "#5b5bd6"}
|
||||
|
||||
|
||||
def test_projects_id_unlisted_readable_by_direct_id(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("unl", "Unlisted", "unlisted")
|
||||
assert client.get("/api/projects/unl").status_code == 200
|
||||
|
||||
|
||||
def test_projects_id_unknown_returns_404_even_for_superuser(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
assert client.get("/api/projects/does-not-exist").status_code == 404
|
||||
|
||||
|
||||
def test_projects_id_unknown_returns_404_for_anon(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/api/projects/nope").status_code == 404
|
||||
|
||||
|
||||
def test_deployment_includes_default_project_id(app_with_fake_gitea):
|
||||
# §22.10 / M3-frontend guard contract: the frontend learns which project
|
||||
# is the corpus-served (default) one from the deployment payload.
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
body = client.get("/api/deployment").json()
|
||||
assert body["default_project_id"] == "default"
|
||||
|
||||
|
||||
def test_rfc_root_url_redirects_308_to_project_scoped(app_with_fake_gitea):
|
||||
# §22.10 / §5: old corpus-root /rfc/<slug> → 308 /p/<default>/e/<slug>.
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/rfc/human", follow_redirects=False)
|
||||
assert r.status_code == 308
|
||||
assert r.headers["location"] == "/p/default/e/human"
|
||||
|
||||
|
||||
def test_rfc_pr_url_redirects_308_to_project_scoped(app_with_fake_gitea):
|
||||
# The old per-RFC PR deep link is preserved too.
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/rfc/human/pr/7", follow_redirects=False)
|
||||
assert r.status_code == 308
|
||||
assert r.headers["location"] == "/p/default/e/human/pr/7"
|
||||
|
||||
|
||||
def test_proposals_root_url_redirects_308_to_project_scoped(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/proposals/42", follow_redirects=False)
|
||||
assert r.status_code == 308
|
||||
assert r.headers["location"] == "/p/default/proposals/42"
|
||||
|
||||
|
||||
def test_gated_project_visible_and_readable_to_member(app_with_fake_gitea):
|
||||
from app import db
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("teamx", "Team X", "gated")
|
||||
provision_user_row(user_id=5, login="mia", role="contributor")
|
||||
db.conn().execute(
|
||||
"INSERT INTO project_members (project_id, user_id, role) VALUES ('teamx', 5, 'project_viewer')"
|
||||
)
|
||||
sign_in_as(client, user_id=5, gitea_login="mia", display_name="Mia", role="contributor")
|
||||
# member sees the gated project in the deployment directory
|
||||
ids = {p["id"] for p in client.get("/api/deployment").json()["projects"]}
|
||||
assert "teamx" in ids
|
||||
# member can read it directly
|
||||
r = client.get("/api/projects/teamx")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["id"] == "teamx"
|
||||
@@ -0,0 +1,24 @@
|
||||
"""§22.4c — _upsert_cached_rfc mirrors the review fields into cached_rfcs."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: F401
|
||||
|
||||
|
||||
def test_upsert_writes_review_fields(app_with_fake_gitea):
|
||||
from app import cache, db, entry as entry_mod
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
e = entry_mod.Entry(
|
||||
slug="rev", title="Rev", state="active",
|
||||
unreviewed=True, reviewed_at="2026-06-03", reviewed_by="ben",
|
||||
)
|
||||
cache._upsert_cached_rfc(e, body_sha="sha-rev")
|
||||
row = db.conn().execute(
|
||||
"SELECT unreviewed, reviewed_at, reviewed_by FROM cached_rfcs WHERE slug = 'rev'"
|
||||
).fetchone()
|
||||
assert row["unreviewed"] == 1
|
||||
assert row["reviewed_at"] == "2026-06-03"
|
||||
assert row["reviewed_by"] == "ben"
|
||||
@@ -0,0 +1,236 @@
|
||||
"""v0.29.0 / roadmap #28 Parts 2 & 3 — create-RFC offers + contribute-to-
|
||||
pending requests.
|
||||
|
||||
Two layers, mirroring test_rfc_links_vertical.py:
|
||||
|
||||
* The PR-view scanner surfaces `rfc-pending` (Part 3) and `rfc-candidate`
|
||||
(Part 2) segments alongside Part 1's `rfc` links.
|
||||
* The contribute-request flow end-to-end: a non-owner asks, each owner
|
||||
gets an actionable §15 notification, accept fires #12's invite flow,
|
||||
decline notifies the requester.
|
||||
|
||||
Reuses the FakeGitea + seed/session helpers from the existing suites.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import db
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
|
||||
from test_super_draft_vertical import seed_super_draft
|
||||
from test_rfc_links_vertical import _open_pr_on
|
||||
|
||||
|
||||
def _set_owner(slug: str, login: str) -> None:
|
||||
db.conn().execute(
|
||||
"UPDATE cached_rfcs SET owners_json = ? WHERE slug = ?",
|
||||
(json.dumps([login]), slug),
|
||||
)
|
||||
|
||||
|
||||
def _set_tags(slug: str, tags: list[str]) -> None:
|
||||
db.conn().execute(
|
||||
"UPDATE cached_rfcs SET tags_json = ? WHERE slug = ?",
|
||||
(json.dumps(tags), slug),
|
||||
)
|
||||
|
||||
|
||||
def _segs(segments, kind):
|
||||
return [s for s in segments if s["type"] == kind]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Part 2 + Part 3 — scanner surfaces on the PR view
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pending_and_candidate_segments_on_pr(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
# Host active RFC (OHM — single word, contributes no keys itself)
|
||||
# carrying a multi-word tag with no defining RFC: the Part 2
|
||||
# candidate. And a pending super-draft owned by alice: the Part 3
|
||||
# contribute target.
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
_set_tags("ohm", ["memory model", "identity"])
|
||||
seed_super_draft(fake, slug="open-human-model", title="Open Human Model",
|
||||
pitch="A framework for representing humans.", proposed_by="alice")
|
||||
_set_owner("open-human-model", "alice")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
pr_number = _open_pr_on(
|
||||
client, fake, host_slug="ohm",
|
||||
description="This builds on the Open Human Model and the memory model.",
|
||||
)
|
||||
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
|
||||
segs = pr["description_segments"]
|
||||
|
||||
pending = _segs(segs, "rfc-pending")
|
||||
assert len(pending) == 1
|
||||
assert pending[0]["slug"] == "open-human-model"
|
||||
assert pending[0]["label"] == "Open Human Model"
|
||||
assert pending[0]["owner"] == "Alice" # display_name of the owner
|
||||
|
||||
candidate = _segs(segs, "rfc-candidate")
|
||||
assert len(candidate) == 1
|
||||
assert candidate[0]["term"] == "memory model"
|
||||
# "identity" is a single-word tag — deliberately NOT a candidate.
|
||||
assert all("identity" not in s.get("term", "") for s in candidate)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Part 3 — the contribute-request flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_pending_owned_by_alice(fake):
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
seed_super_draft(fake, slug="open-human-model", title="Open Human Model",
|
||||
pitch="A framework.", proposed_by="alice")
|
||||
_set_owner("open-human-model", "alice")
|
||||
|
||||
|
||||
_REQUEST = {
|
||||
"matched_term": "Open Human Model",
|
||||
"who_i_am": "Bob, a researcher",
|
||||
"why": "I have relevant prior work to bring.",
|
||||
"use_case": "Building an identity tool.",
|
||||
}
|
||||
|
||||
|
||||
def test_request_accept_invites_and_notifies(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_pending_owned_by_alice(fake)
|
||||
|
||||
# Bob asks to contribute.
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
r = client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
|
||||
assert r.status_code == 200, r.text
|
||||
request_id = r.json()["id"]
|
||||
assert r.json()["status"] == "pending"
|
||||
|
||||
# A second ask while pending is a 409, not a duplicate row.
|
||||
assert client.post("/api/rfcs/open-human-model/contribution-requests",
|
||||
json=_REQUEST).status_code == 409
|
||||
|
||||
# Alice (owner) sees the actionable notification with full detail.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
|
||||
role="contributor", email="alice@test")
|
||||
inbox = client.get("/api/notifications").json()
|
||||
reqs = [i for i in inbox["items"]
|
||||
if i["event_kind"] == "contribution_request_on_pending_rfc"]
|
||||
assert len(reqs) == 1
|
||||
assert "wants to contribute" in reqs[0]["summary"]
|
||||
assert reqs[0]["extras"]["who_i_am"] == "Bob, a researcher"
|
||||
assert reqs[0]["extras"]["request_id"] == request_id
|
||||
|
||||
# Alice accepts → #12 invitation minted for bob's email.
|
||||
acc = client.post(f"/api/rfcs/open-human-model/contribution-requests/{request_id}/accept")
|
||||
assert acc.status_code == 200, acc.text
|
||||
assert acc.json()["status"] == "accepted"
|
||||
assert acc.json()["invitation_id"]
|
||||
|
||||
inv = db.conn().execute(
|
||||
"SELECT invitee_email, role_in_rfc, status FROM rfc_invitations "
|
||||
"WHERE rfc_slug = 'open-human-model'"
|
||||
).fetchone()
|
||||
assert inv["invitee_email"] == "bob@test"
|
||||
assert inv["role_in_rfc"] == "contributor"
|
||||
assert inv["status"] == "pending"
|
||||
|
||||
# The request is settled — re-accepting is a 409.
|
||||
assert client.post(
|
||||
f"/api/rfcs/open-human-model/contribution-requests/{request_id}/accept"
|
||||
).status_code == 409
|
||||
|
||||
# Bob gets the accepted echo in his inbox.
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
bob_kinds = [i["event_kind"] for i in client.get("/api/notifications").json()["items"]]
|
||||
assert "contribution_request_accepted" in bob_kinds
|
||||
|
||||
|
||||
def test_decline_notifies_requester(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_pending_owned_by_alice(fake)
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
request_id = client.post(
|
||||
"/api/rfcs/open-human-model/contribution-requests", json=_REQUEST
|
||||
).json()["id"]
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
|
||||
role="contributor", email="alice@test")
|
||||
dec = client.post(f"/api/rfcs/open-human-model/contribution-requests/{request_id}/decline")
|
||||
assert dec.status_code == 200, dec.text
|
||||
assert dec.json()["status"] == "declined"
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT status FROM contribution_requests WHERE id = ?", (request_id,)
|
||||
).fetchone()
|
||||
assert row["status"] == "declined"
|
||||
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
bob_kinds = [i["event_kind"] for i in client.get("/api/notifications").json()["items"]]
|
||||
assert "contribution_request_declined" in bob_kinds
|
||||
|
||||
|
||||
def test_owner_cannot_request_own_rfc(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_pending_owned_by_alice(fake)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
|
||||
role="contributor", email="alice@test")
|
||||
r = client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
|
||||
assert r.status_code == 409
|
||||
assert "own" in r.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_request_on_active_rfc_rejected(app_with_fake_gitea):
|
||||
# The contribute offer only exists for pending super-drafts; an active
|
||||
# RFC uses the Part-1 link instead.
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
r = client.post("/api/rfcs/ohm/contribution-requests", json=_REQUEST)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_contribution_target_eligibility(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_pending_owned_by_alice(fake)
|
||||
|
||||
# Anonymous: not eligible, told to sign in.
|
||||
t = client.get("/api/rfcs/open-human-model/contribution-target").json()
|
||||
assert t["eligible"] is False
|
||||
assert "sign in" in (t["reason"] or "").lower()
|
||||
assert t["owner"] == "Alice"
|
||||
|
||||
# Bob: eligible until he has a pending ask, then not.
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
assert client.get("/api/rfcs/open-human-model/contribution-target").json()["eligible"] is True
|
||||
client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
|
||||
after = client.get("/api/rfcs/open-human-model/contribution-target").json()
|
||||
assert after["already_requested"] is True
|
||||
assert after["eligible"] is False
|
||||
@@ -70,27 +70,32 @@ class _UpstreamHandler:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_httpx(monkeypatch):
|
||||
def patched_httpx(monkeypatch, app_with_fake_gitea): # noqa: F811
|
||||
"""Provide a hook the test can call to install a MockTransport.
|
||||
|
||||
Returns a closure: `install(handler)` patches
|
||||
`app.docs_sessions.httpx.AsyncClient` so every constructed client
|
||||
uses the handler's transport.
|
||||
Returns a closure: `install(handler)` patches `httpx.AsyncClient`
|
||||
(via `app.docs_sessions.httpx.AsyncClient`) so every constructed
|
||||
client uses the handler's transport.
|
||||
|
||||
NB: the upstream `app_with_fake_gitea` fixture also patches
|
||||
`httpx.AsyncClient` (to route gitea calls to a FakeGitea handler),
|
||||
and because `httpx` is a single shared module, that patch mutates
|
||||
the *same* `AsyncClient` attribute we're about to overwrite. We
|
||||
therefore import the unpatched class directly from the
|
||||
`httpx._client` module so our install path can construct a fresh
|
||||
real client around our MockTransport without going through the
|
||||
FakeGitea wrapper.
|
||||
M3 note: lifespan now calls `refresh_registry` which hits FakeGitea
|
||||
via the gitea transport. Since `httpx` is a module singleton, installing
|
||||
the docs transport would clobber the FakeGitea mock already installed
|
||||
by `app_with_fake_gitea`. We use a COMPOSITE handler: Gitea API
|
||||
requests (to `http://gitea.test/`) are delegated to FakeGitea; all
|
||||
other requests go to the test-specific handler.
|
||||
"""
|
||||
from httpx._client import AsyncClient as RealAsyncClient
|
||||
|
||||
_fake = app_with_fake_gitea[1]
|
||||
|
||||
def install(handler):
|
||||
def composite(request: httpx.Request) -> httpx.Response:
|
||||
if "gitea.test" in str(request.url):
|
||||
return _fake.handle(request)
|
||||
return handler(request)
|
||||
|
||||
def patched(*args, **kwargs):
|
||||
kwargs["transport"] = httpx.MockTransport(handler)
|
||||
kwargs["transport"] = httpx.MockTransport(composite)
|
||||
return RealAsyncClient(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("app.docs_sessions.httpx.AsyncClient", patched)
|
||||
|
||||
@@ -79,19 +79,28 @@ class _UpstreamHandler:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_httpx(monkeypatch):
|
||||
def patched_httpx(monkeypatch, app_with_fake_gitea): # noqa: F811
|
||||
"""Provide a hook the test can call to install a MockTransport.
|
||||
|
||||
Same shape as the docs_sessions fixture — `app_with_fake_gitea`
|
||||
monkeypatches `httpx.AsyncClient` for the gitea side, so we
|
||||
construct from the unpatched class directly to avoid the
|
||||
FakeGitea wrapper.
|
||||
M3 note: lifespan now calls `refresh_registry` which hits FakeGitea
|
||||
via the gitea transport. Since `httpx` is a module singleton, installing
|
||||
the docs transport would clobber the FakeGitea mock already installed
|
||||
by `app_with_fake_gitea`. We use a COMPOSITE handler: Gitea API
|
||||
requests (to `http://gitea.test/`) are delegated to FakeGitea; all
|
||||
other requests go to the test-specific handler.
|
||||
"""
|
||||
from httpx._client import AsyncClient as RealAsyncClient
|
||||
|
||||
_fake = app_with_fake_gitea[1]
|
||||
|
||||
def install(handler):
|
||||
def composite(request: httpx.Request) -> httpx.Response:
|
||||
if "gitea.test" in str(request.url):
|
||||
return _fake.handle(request)
|
||||
return handler(request)
|
||||
|
||||
def patched(*args, **kwargs):
|
||||
kwargs["transport"] = httpx.MockTransport(handler)
|
||||
kwargs["transport"] = httpx.MockTransport(composite)
|
||||
return RealAsyncClient(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("app.docs_specs.httpx.AsyncClient", patched)
|
||||
|
||||
@@ -119,19 +119,20 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
|
||||
r = client.post(f"/api/rfcs/ohm/prs/{body_pr}/merge")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# --- 7. Graduate the super-draft. ---
|
||||
# --- 7. Graduate the super-draft (in-place flip, §13). ---
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["succeeded"] is True
|
||||
d = client.get("/api/rfcs/ohm").json()
|
||||
assert d["state"] == "active"
|
||||
assert d["repo"] == "wiggleverse/rfc-0001-ohm"
|
||||
# Meta-only topology (§1): no per-RFC repo — the active RFC lives
|
||||
# in its meta entry, `repo` stays null.
|
||||
assert d["repo"] is None
|
||||
|
||||
# --- 8. Alice opens a PR on the now-active RFC's per-RFC repo. ---
|
||||
# --- 8. Alice opens a PR on the now-active RFC (meta repo). ---
|
||||
# v0.16.0 (item #12): ben is the RFC owner now; alice needs a
|
||||
# per-RFC contributor invitation to cut a branch. In the
|
||||
# production flow, ben would invite her via /invitations and
|
||||
@@ -200,8 +201,8 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
|
||||
)
|
||||
assert counters["deleted_post_merge"] >= 1, counters
|
||||
|
||||
# The branch is gone from FakeGitea + cached row flipped.
|
||||
assert active_branch not in fake.branches[("wiggleverse", "rfc-0001-ohm")]
|
||||
# The branch is gone from FakeGitea (meta repo) + cached row flipped.
|
||||
assert active_branch not in fake.branches[("wiggleverse", "meta")]
|
||||
cached = db.conn().execute(
|
||||
"SELECT state FROM cached_branches WHERE rfc_slug = 'ohm' AND branch_name = ?",
|
||||
(active_branch,),
|
||||
|
||||
@@ -11,6 +11,8 @@ from __future__ import annotations
|
||||
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.email_envelope import build_envelope
|
||||
|
||||
|
||||
@@ -160,14 +162,18 @@ def test_envelope_plain_only_body_is_text_plain():
|
||||
assert msg.get_content().strip() == "Hello, world."
|
||||
|
||||
|
||||
def test_envelope_with_html_is_multipart_alternative():
|
||||
msg = build_envelope(**_base_kwargs(body_html="<p>Hello, <b>world</b>.</p>"))
|
||||
assert msg.get_content_type() == "multipart/alternative"
|
||||
# Two parts: text/plain first (so plain-text clients picking the
|
||||
# first part get the readable text), text/html second.
|
||||
parts = list(msg.iter_parts())
|
||||
assert len(parts) == 2
|
||||
assert parts[0].get_content_type() == "text/plain"
|
||||
assert parts[1].get_content_type() == "text/html"
|
||||
assert "Hello, world." in parts[0].get_content()
|
||||
assert "<b>world</b>" in parts[1].get_content()
|
||||
def test_envelope_html_body_is_guarded_not_enabled():
|
||||
# I3 (security-audit-0026): the HTML/multipart-alternative path is
|
||||
# intentionally not enabled — passing body_html must fail loudly so
|
||||
# a future caller can't silently ship unescaped user HTML (the C1
|
||||
# stored-XSS class in the mail channel). When HTML mail is enabled
|
||||
# deliberately, this test flips to assert the multipart shape.
|
||||
with pytest.raises(NotImplementedError):
|
||||
build_envelope(**_base_kwargs(body_html="<p>Hello, <b>world</b>.</p>"))
|
||||
|
||||
|
||||
def test_envelope_html_none_is_plain_only():
|
||||
# The guard keys on `is not None`, so the default (None) stays the
|
||||
# live plain-text path — exercised here to lock the boundary.
|
||||
msg = build_envelope(**_base_kwargs(body_html=None))
|
||||
assert msg.get_content_type() == "text/plain"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""§22.4c — the unreviewed/reviewed_at/reviewed_by entry frontmatter fields."""
|
||||
from __future__ import annotations
|
||||
|
||||
from app import entry as entry_mod
|
||||
|
||||
|
||||
def test_parse_defaults_unreviewed_false_when_absent():
|
||||
text = "---\nslug: ohm\ntitle: OHM\nstate: active\n---\n\nBody.\n"
|
||||
e = entry_mod.parse(text)
|
||||
assert e.unreviewed is False
|
||||
assert e.reviewed_at is None
|
||||
assert e.reviewed_by is None
|
||||
|
||||
|
||||
def test_parse_reads_review_fields():
|
||||
text = (
|
||||
"---\nslug: ohm\ntitle: OHM\nstate: active\n"
|
||||
"unreviewed: true\nreviewed_at: '2026-06-03'\nreviewed_by: ben\n---\n\nBody.\n"
|
||||
)
|
||||
e = entry_mod.parse(text)
|
||||
assert e.unreviewed is True
|
||||
assert e.reviewed_at == "2026-06-03"
|
||||
assert e.reviewed_by == "ben"
|
||||
|
||||
|
||||
def test_serialize_emits_review_fields_only_when_meaningful():
|
||||
e = entry_mod.Entry(slug="a", title="A", state="super-draft")
|
||||
assert "unreviewed" not in entry_mod.serialize(e)
|
||||
assert "reviewed_at" not in entry_mod.serialize(e)
|
||||
e2 = entry_mod.Entry(slug="b", title="B", state="active", unreviewed=True)
|
||||
assert "unreviewed: true" in entry_mod.serialize(e2)
|
||||
|
||||
|
||||
def test_round_trip_preserves_review_fields():
|
||||
e = entry_mod.Entry(
|
||||
slug="b", title="B", state="active",
|
||||
unreviewed=False, reviewed_at="2026-06-03", reviewed_by="ben",
|
||||
)
|
||||
back = entry_mod.parse(entry_mod.serialize(e))
|
||||
assert back.reviewed_at == "2026-06-03"
|
||||
assert back.reviewed_by == "ben"
|
||||
assert back.unreviewed is False
|
||||
@@ -1,29 +1,29 @@
|
||||
"""End-to-end integration tests for the Slice 5 vertical (§13 in full).
|
||||
"""End-to-end integration tests for the §13 graduation flow under the
|
||||
meta-only topology (SPEC §1, ROADMAP #36).
|
||||
|
||||
Walks the §13.3 transactional sequence end-to-end against the in-process
|
||||
FakeGitea from test_propose_vertical.py:
|
||||
Graduation is an in-place state flip on the meta entry — no per-RFC repo
|
||||
is created, the body is kept, and there is no multi-step transaction or
|
||||
rollback (§13.3). These tests walk it against the in-process FakeGitea
|
||||
from test_propose_vertical.py:
|
||||
|
||||
* Seed an owned super-draft (skipping the propose+merge + §13.1 claim
|
||||
round-trips already proven by Slice 1 and exercised in
|
||||
test_claim_opens_meta_pr below for the §13.1 surface itself).
|
||||
* Seed an owned super-draft (the §13.1 claim flow is exercised
|
||||
separately in test_claim_opens_meta_pr).
|
||||
* GET /api/rfcs/<slug>/graduate/check returns per-field validity for
|
||||
the dialog.
|
||||
* GET /api/rfcs/<slug>/blocking-prs returns the §9.8 precondition list.
|
||||
* POST /api/rfcs/<slug>/graduate?_sync=1 runs the five-step sequence
|
||||
inline. On success: per-RFC repo exists with RFC.md / README.md /
|
||||
.rfc/metadata.yaml, meta-entry body is stripped, frontmatter is
|
||||
graduated, cached_rfcs.state is 'active'.
|
||||
* §9.8 precondition gate refuses the start when a body-edit PR is open.
|
||||
* Rollback on a mid-sequence failure unwinds repo creation cleanly.
|
||||
* §13.4 chat migration: whole-doc threads under (slug, 'main') survive
|
||||
graduation unchanged — the rfc_slug is the canonical key per §2.3,
|
||||
so no data movement is needed.
|
||||
* §9.8 pre-graduation history: the new RFC's /main response surfaces
|
||||
edit-branch threads under `pre_graduation_history`.
|
||||
the two-field dialog (integer id + owners; no repo name).
|
||||
* POST /api/rfcs/<slug>/graduate?_sync=1 opens + merges the flip PR
|
||||
inline. On success: NO per-RFC repo, the meta entry is `state:
|
||||
active` with the body KEPT and `repo` null, cached_rfcs.state flips
|
||||
to 'active'.
|
||||
* An open body-edit PR no longer blocks graduation (§9.8) — they
|
||||
coexist.
|
||||
* An open-PR failure leaves the entry a super-draft (nothing created);
|
||||
a merge failure cleans up the half-open PR/branch and leaves the
|
||||
entry a super-draft.
|
||||
* §13.4: chat threads + edit branches stay put — the slug is the
|
||||
canonical key per §2.3, so nothing moves at the flip.
|
||||
|
||||
The orchestrator's `?_sync=1` seam awaits the sequence inline so the
|
||||
test can assert post-conditions on the same event loop tick. Production
|
||||
clients use the spec-described SSE shape via `/graduate/progress`.
|
||||
The orchestrator's `?_sync=1` seam awaits the flip inline so the test can
|
||||
assert post-conditions on the same event loop tick.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -110,7 +110,9 @@ def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_graduate_check_validates_three_fields(app_with_fake_gitea):
|
||||
def test_graduate_check_validates_id_and_owners(app_with_fake_gitea):
|
||||
"""Two-field dialog under meta-only: integer id + owners. No repo
|
||||
name to validate (§13.2)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
@@ -121,57 +123,46 @@ def test_graduate_check_validates_three_fields(app_with_fake_gitea):
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Happy: a fresh RFC-0001 + rfc-0001-ohm repo name.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
|
||||
# Happy: a fresh RFC-0001.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["id"]["ok"] is True
|
||||
assert d["repo"]["ok"] is True
|
||||
assert d["owners"]["ok"] is True
|
||||
assert d["blocking_prs"]["ok"] is True
|
||||
assert d["can_submit"] is True
|
||||
# No repo field in the meta-only check response.
|
||||
assert "repo" not in d
|
||||
|
||||
# ID format error — non-numeric tail.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-abcd", "repo": "rfc-0001-ohm"})
|
||||
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-abcd"})
|
||||
d = r.json()
|
||||
assert d["id"]["ok"] is False
|
||||
assert d["can_submit"] is False
|
||||
|
||||
# Repo name pattern error — leading dot.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-0001", "repo": ".bad"})
|
||||
d = r.json()
|
||||
assert d["repo"]["ok"] is False
|
||||
|
||||
|
||||
def test_graduate_check_refuses_when_no_owners(app_with_fake_gitea):
|
||||
"""An unclaimed super-draft fails the owners precondition; can_submit
|
||||
flips false even with valid id+repo."""
|
||||
flips false even with a valid id."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
# No owners — simulates an unclaimed super-draft.
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=[])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
|
||||
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
|
||||
d = r.json()
|
||||
assert d["owners"]["ok"] is False
|
||||
assert "No owners" in d["owners"]["error"]
|
||||
assert d["can_submit"] is False
|
||||
|
||||
|
||||
def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea):
|
||||
"""The full §13.3 sequence: create repo, seed files, open PR, merge
|
||||
PR, refresh cache. End state: cached_rfcs.state='active', the meta
|
||||
entry's body is stripped, the per-RFC repo has RFC.md, the audit
|
||||
log carries graduate_start → graduate_complete bracketing the
|
||||
per-step rows."""
|
||||
def test_graduate_happy_path_flips_in_place_keeping_body(app_with_fake_gitea):
|
||||
"""The meta-only flip: open + merge a frontmatter PR. End state:
|
||||
cached_rfcs.state='active', the meta entry's body is KEPT, `repo` is
|
||||
null, NO per-RFC repo exists, and the audit log carries graduate_start
|
||||
→ graduate_pr_open → graduate_pr_merge → graduate_complete."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, entry as entry_mod
|
||||
|
||||
@@ -186,60 +177,57 @@ def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea
|
||||
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0042", "repo_name": "rfc-0042-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0042", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["finished"] is True
|
||||
assert d["succeeded"] is True
|
||||
assert d["repo"] == "wiggleverse/rfc-0042-ohm"
|
||||
# No repo in the response, no per-RFC repo on Gitea.
|
||||
assert "repo" not in d
|
||||
assert ("wiggleverse", "rfc-0042-ohm") not in fake.repos
|
||||
assert not any(
|
||||
k[1].startswith("rfc-0042") for k in fake.repos
|
||||
), f"a per-RFC repo was created: {fake.repos}"
|
||||
|
||||
# 1. Per-RFC repo exists on Gitea.
|
||||
assert ("wiggleverse", "rfc-0042-ohm") in fake.repos
|
||||
# 2. Seed files landed on main.
|
||||
assert ("wiggleverse", "rfc-0042-ohm", "main", "RFC.md") in fake.files
|
||||
assert ("wiggleverse", "rfc-0042-ohm", "main", "README.md") in fake.files
|
||||
assert ("wiggleverse", "rfc-0042-ohm", "main", ".rfc/metadata.yaml") in fake.files
|
||||
rfc_md = fake.files[("wiggleverse", "rfc-0042-ohm", "main", "RFC.md")]["content"]
|
||||
assert "Open Human Model is a framework" in rfc_md
|
||||
# 3. Meta entry body is stripped + frontmatter graduated.
|
||||
# Meta entry on main: state flipped, body KEPT, repo null.
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
graduated = entry_mod.parse(meta_text)
|
||||
assert graduated.state == "active"
|
||||
assert graduated.id == "RFC-0042"
|
||||
assert graduated.repo == "wiggleverse/rfc-0042-ohm"
|
||||
assert graduated.repo is None
|
||||
assert graduated.graduated_by == "ben"
|
||||
assert graduated.graduated_at # non-empty ISO date
|
||||
assert graduated.body.strip() == ""
|
||||
# 5. cached_rfcs.state flipped to active via the inline refresh.
|
||||
assert "Open Human Model is a framework" in graduated.body
|
||||
|
||||
# cached_rfcs flipped to active via the inline refresh; body intact.
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id, repo, body FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "active"
|
||||
assert cached["rfc_id"] == "RFC-0042"
|
||||
assert cached["repo"] == "wiggleverse/rfc-0042-ohm"
|
||||
# cached body now mirrors RFC.md from the per-RFC repo.
|
||||
assert cached["repo"] is None
|
||||
assert "Open Human Model is a framework" in cached["body"]
|
||||
|
||||
# Audit log: graduate_start, graduate_repo_create, graduate_repo_seed,
|
||||
# graduate_pr_open, graduate_pr_merge, graduate_complete, in order.
|
||||
kinds = [
|
||||
r["action_kind"]
|
||||
for r in db.conn().execute(
|
||||
row["action_kind"]
|
||||
for row in db.conn().execute(
|
||||
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
|
||||
)
|
||||
]
|
||||
for needed in ("graduate_start", "graduate_repo_create",
|
||||
"graduate_repo_seed", "graduate_pr_open",
|
||||
for needed in ("graduate_start", "graduate_pr_open",
|
||||
"graduate_pr_merge", "graduate_complete"):
|
||||
assert needed in kinds, f"missing audit row {needed}: {kinds}"
|
||||
# The retired per-repo steps must NOT appear.
|
||||
for gone in ("graduate_repo_create", "graduate_repo_seed",
|
||||
"graduate_repo_delete", "graduate_rollback"):
|
||||
assert gone not in kinds, f"retired audit row present: {gone}"
|
||||
|
||||
|
||||
def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
|
||||
"""§9.8: an open meta-repo body-edit PR against rfcs/<slug>.md blocks
|
||||
graduation before the bot starts the sequence — §13.3's rollback
|
||||
complexity does not grow."""
|
||||
def test_graduate_coexists_with_open_body_edit_pr(app_with_fake_gitea):
|
||||
"""§9.8 (meta-only): an open meta-repo body-edit PR no longer blocks
|
||||
graduation — the body is kept, so they coexist. /check stays
|
||||
submittable and the flip succeeds."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
@@ -249,13 +237,11 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
# v0.16.0 (item #12): ben is the RFC owner; alice needs a per-RFC
|
||||
# contributor invitation to cut an edit branch on the super-draft.
|
||||
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
|
||||
# Cut an edit branch and open a body-edit PR (full Slice 4 path).
|
||||
# Cut an edit branch and open a body-edit PR.
|
||||
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
|
||||
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
|
||||
thread_id = view["main_thread_id"]
|
||||
@@ -279,94 +265,31 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
|
||||
f"/api/rfcs/ohm/branches/{branch}/open-pr",
|
||||
json={"title": "Add harm", "description": "Adds harm dimension."},
|
||||
).json()["pr_number"]
|
||||
assert pr_number # PR is open
|
||||
|
||||
# /blocking-prs surfaces it.
|
||||
# /check stays submittable despite the open body-edit PR.
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/rfcs/ohm/blocking-prs")
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["pr_number"] == pr_number
|
||||
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"}).json()
|
||||
assert "blocking_prs" not in d
|
||||
assert d["can_submit"] is True
|
||||
|
||||
# /check refuses can_submit.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
|
||||
d = r.json()
|
||||
assert d["blocking_prs"]["ok"] is False
|
||||
assert d["can_submit"] is False
|
||||
|
||||
# POST refuses with 409 — the bot never starts the sequence.
|
||||
# The flip succeeds — coexists with the open body-edit PR.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
assert "blocking graduation" in r.text or "block" in r.text
|
||||
|
||||
|
||||
def test_graduate_rollback_on_step_2_seed_failure(app_with_fake_gitea):
|
||||
"""Step 2 (seed files) fails partway → the orchestrator rolls back
|
||||
step 1 (delete the repo) and records the rollback in the audit log.
|
||||
The cached_rfcs row stays at 'super-draft'."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
from app.bot import Bot
|
||||
from app.gitea import Gitea, GiteaError
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Monkey-patch the bot to fail on seed_graduated_rfc. The repo
|
||||
# has already been created in step 1; the rollback must delete it.
|
||||
orig_seed = Bot.seed_graduated_rfc
|
||||
async def boom(self, *args, **kwargs):
|
||||
raise GiteaError(500, "simulated seed failure for rollback test")
|
||||
Bot.seed_graduated_rfc = boom
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0003", "repo_name": "rfc-0003-ohm",
|
||||
"owners": ["ben"]},
|
||||
)
|
||||
finally:
|
||||
Bot.seed_graduated_rfc = orig_seed
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["finished"] is True
|
||||
assert d["succeeded"] is False
|
||||
|
||||
# Repo deleted as the rollback inverse.
|
||||
assert ("wiggleverse", "rfc-0003-ohm") not in fake.repos
|
||||
# Meta entry unchanged.
|
||||
assert r.json()["succeeded"] is True
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "super-draft"
|
||||
assert cached["rfc_id"] is None
|
||||
# Audit log carries the rollback row.
|
||||
kinds = [
|
||||
r["action_kind"]
|
||||
for r in db.conn().execute(
|
||||
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
|
||||
)
|
||||
]
|
||||
assert "graduate_start" in kinds
|
||||
assert "graduate_repo_create" in kinds
|
||||
assert "graduate_repo_delete" in kinds
|
||||
assert "graduate_rollback" in kinds
|
||||
assert "graduate_complete" not in kinds
|
||||
assert cached["state"] == "active"
|
||||
|
||||
|
||||
def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
|
||||
"""Step 3 (open PR) fails → the orchestrator rolls back steps 2 and
|
||||
1 (deleting the repo, which reclaims the seed commits at the same
|
||||
time). The meta-repo entry is untouched."""
|
||||
def test_graduate_open_pr_failure_leaves_super_draft(app_with_fake_gitea):
|
||||
"""An open-PR failure creates nothing — the entry stays a super-draft
|
||||
with its body intact and no graduation PR on the meta repo."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
from app.bot import Bot
|
||||
@@ -387,19 +310,92 @@ def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0007", "repo_name": "rfc-0007-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0007", "owners": ["ben"]},
|
||||
)
|
||||
finally:
|
||||
Bot.open_graduation_pr = orig_open_pr
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["succeeded"] is False
|
||||
# Repo torn down.
|
||||
assert ("wiggleverse", "rfc-0007-ohm") not in fake.repos
|
||||
# Meta entry's body still has the pitch (not stripped).
|
||||
|
||||
# Entry untouched: still super-draft, body intact on main.
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "super-draft"
|
||||
assert cached["rfc_id"] is None
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
assert "Open Human Model is a framework" in meta_text
|
||||
|
||||
kinds = [
|
||||
row["action_kind"]
|
||||
for row in db.conn().execute(
|
||||
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
|
||||
)
|
||||
]
|
||||
assert "graduate_start" in kinds
|
||||
assert "graduate_failed" in kinds
|
||||
assert "graduate_complete" not in kinds
|
||||
|
||||
|
||||
def test_graduate_merge_failure_cleans_up_pr(app_with_fake_gitea):
|
||||
"""A merge failure leaves the flip PR open on its dash-suffixed
|
||||
branch; the orchestrator closes the PR and deletes the branch so
|
||||
failed attempts don't accumulate. The entry stays a super-draft —
|
||||
the flip PR's commit was on a branch, not on main."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
from app.bot import Bot
|
||||
from app.gitea import GiteaError
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
orig_merge = Bot.merge_graduation_pr
|
||||
async def boom(self, *args, **kwargs):
|
||||
raise GiteaError(502, "simulated merge failure")
|
||||
Bot.merge_graduation_pr = boom
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0009", "owners": ["ben"]},
|
||||
)
|
||||
finally:
|
||||
Bot.merge_graduation_pr = orig_merge
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["succeeded"] is False
|
||||
|
||||
# Entry stays super-draft on main (the flip never merged).
|
||||
cached = db.conn().execute(
|
||||
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "super-draft"
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
assert "state: super-draft" in meta_text
|
||||
|
||||
# The dash-suffixed graduation branch was cleaned up.
|
||||
grad_branches = [
|
||||
name for (o, repo), branches in fake.branches.items()
|
||||
if (o, repo) == ("wiggleverse", "meta")
|
||||
for name in branches
|
||||
if name.startswith("graduate-ohm-")
|
||||
]
|
||||
assert grad_branches == [], f"leftover graduation branch: {grad_branches}"
|
||||
|
||||
kinds = [
|
||||
row["action_kind"]
|
||||
for row in db.conn().execute(
|
||||
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
|
||||
)
|
||||
]
|
||||
assert "graduate_pr_open" in kinds
|
||||
assert "graduate_failed" in kinds
|
||||
assert "graduate_complete" not in kinds
|
||||
|
||||
|
||||
def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
|
||||
"""A second graduation request for a slug already in-flight is refused."""
|
||||
@@ -414,17 +410,14 @@ def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Seed a synthetic in-flight state so the registry refuses the second.
|
||||
st = api_graduation._new_active(
|
||||
"ohm", rfc_id="RFC-0001", repo_name="rfc-0001-ohm",
|
||||
repo_full="wiggleverse/rfc-0001-ohm", owners=["ben"], arbiters=["ben"],
|
||||
"ohm", rfc_id="RFC-0001", owners=["ben"], arbiters=["ben"],
|
||||
)
|
||||
st.finished = False
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
finally:
|
||||
@@ -432,11 +425,9 @@ def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
|
||||
|
||||
|
||||
def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_gitea):
|
||||
"""§13.4: chat threads on the super-draft's canonical-body view
|
||||
(`branch_name='main'`) are interpreted as the new RFC's main-thread
|
||||
after graduation. The rows don't move — the rfc_slug is canonical
|
||||
per §2.3 — so the same thread surfaces from both before and after
|
||||
the graduation."""
|
||||
"""§13.4: chat threads on the entry's main view (`branch_name='main'`)
|
||||
stay put across the flip — the rfc_slug is canonical per §2.3 — so the
|
||||
same thread surfaces from /branches/main before and after graduation."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
@@ -448,9 +439,6 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Materialize a whole-doc main thread + a message on it. This
|
||||
# mirrors what reading the canonical-body view would create
|
||||
# lazily (§8.12 / api_branches._ensure_branch_chat_thread).
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, created_by)
|
||||
@@ -466,32 +454,26 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git
|
||||
(thread_id,),
|
||||
)
|
||||
|
||||
# Graduate.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0099", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# The thread row's identity is unchanged.
|
||||
row = db.conn().execute(
|
||||
"SELECT id, branch_name FROM threads WHERE id = ?", (thread_id,),
|
||||
).fetchone()
|
||||
assert row["branch_name"] == "main"
|
||||
# The new RFC's main view surfaces the same thread id as its
|
||||
# whole-doc main thread (the entry is now active, the branch
|
||||
# 'main' now points at the per-RFC repo's main, but the
|
||||
# `(rfc_slug, branch_name)` key remains the canonical anchor).
|
||||
r = client.get("/api/rfcs/ohm/branches/main")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["main_thread_id"] == thread_id
|
||||
|
||||
|
||||
def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea):
|
||||
"""§9.8: after graduation, threads on meta-repo edit branches stay
|
||||
attached to their original branch_name and surface from the new
|
||||
RFC's /main response under `pre_graduation_history`."""
|
||||
def test_edit_branch_surfaces_normally_after_graduation(app_with_fake_gitea):
|
||||
"""§13.4 (meta-only): after graduation an edit branch is a *current*
|
||||
branch of the now-active RFC — it surfaces in the normal `branches`
|
||||
list, and there is no separate `pre_graduation_history` set (that
|
||||
affordance is legacy per-repo only)."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
@@ -501,10 +483,8 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
# v0.16.0 (item #12): alice needs per-RFC contributor access.
|
||||
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
|
||||
|
||||
# Alice cuts an edit branch and starts chatting on it.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
|
||||
@@ -513,30 +493,139 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO thread_messages (thread_id, role, author_user_id, text)
|
||||
VALUES (?, 'user', 2, 'pre-graduation note on an edit branch')
|
||||
VALUES (?, 'user', 2, 'note on an edit branch')
|
||||
""",
|
||||
(thread_id,),
|
||||
)
|
||||
|
||||
# Ben graduates.
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0100", "repo_name": "rfc-0100-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0100", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# /main on the now-active RFC surfaces the pre-graduation history.
|
||||
r = client.get("/api/rfcs/ohm/main")
|
||||
d = r.json()
|
||||
d = client.get("/api/rfcs/ohm/main").json()
|
||||
assert d["state"] == "active"
|
||||
hist = d["pre_graduation_history"]
|
||||
assert len(hist) >= 1
|
||||
assert any(h["branch_name"] == branch for h in hist)
|
||||
target = next(h for h in hist if h["branch_name"] == branch)
|
||||
assert target["message_count"] >= 1
|
||||
# The edit branch is a current branch; no pre-graduation hop.
|
||||
assert d["pre_graduation_history"] == []
|
||||
assert any(b["name"] == branch for b in d["branches"]), \
|
||||
f"edit branch not in branches: {[b['name'] for b in d['branches']]}"
|
||||
|
||||
|
||||
def test_graduate_check_accepts_blank_id(app_with_fake_gitea):
|
||||
"""§13.2 (optional number): a blank id is VALID — it means "graduate
|
||||
without a number." `can_submit` stays true (owners are set), and an
|
||||
absent `id` param behaves the same as an explicit empty string."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Explicit empty id.
|
||||
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": ""}).json()
|
||||
assert d["id"]["ok"] is True
|
||||
assert d["id"]["error"] is None
|
||||
assert d["can_submit"] is True
|
||||
|
||||
# No id param at all — same default-accepted shape.
|
||||
d = client.get("/api/rfcs/ohm/graduate/check").json()
|
||||
assert d["id"]["ok"] is True
|
||||
assert d["can_submit"] is True
|
||||
|
||||
# A malformed (non-blank) id is still rejected.
|
||||
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-xx"}).json()
|
||||
assert d["id"]["ok"] is False
|
||||
assert d["can_submit"] is False
|
||||
|
||||
|
||||
def test_graduate_without_number_flips_to_active_null_id_by_slug(app_with_fake_gitea):
|
||||
"""§13.2/§13.3 (optional number): graduating with a blank id flips the
|
||||
entry to `active` with `id: null`. The slug is the canonical identifier;
|
||||
the catalog shows the entry as active with no number, and the audit row
|
||||
records rfc_id null."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, entry as entry_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="Open Human Model",
|
||||
pitch=PITCH, owners=["ben"], arbiters=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner", email="ben@test")
|
||||
|
||||
# Blank rfc_id → graduate without a number.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["succeeded"] is True
|
||||
assert d["rfc_id"] is None
|
||||
|
||||
# Meta entry: active, id null, body kept, graduation stamped.
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
graduated = entry_mod.parse(meta_text)
|
||||
assert graduated.state == "active"
|
||||
assert graduated.id is None
|
||||
assert graduated.graduated_by == "ben"
|
||||
assert graduated.graduated_at
|
||||
assert "Open Human Model is a framework" in graduated.body
|
||||
|
||||
# Cache flipped to active with a null rfc_id.
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "active"
|
||||
assert cached["rfc_id"] is None
|
||||
|
||||
# Catalog: present as active, identified by slug (no number).
|
||||
items = client.get("/api/rfcs").json()["items"]
|
||||
ohm = next(i for i in items if i["slug"] == "ohm")
|
||||
assert ohm["state"] == "active"
|
||||
assert ohm["id"] is None
|
||||
|
||||
# Audit: graduate_complete with rfc_id null.
|
||||
complete = db.conn().execute(
|
||||
"""
|
||||
SELECT details FROM actions
|
||||
WHERE rfc_slug = 'ohm' AND action_kind = 'graduate_complete'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
"""
|
||||
).fetchone()
|
||||
assert complete is not None
|
||||
assert _json.loads(complete["details"])["rfc_id"] is None
|
||||
|
||||
|
||||
def test_graduate_with_number_unchanged_when_id_absent_field(app_with_fake_gitea):
|
||||
"""Omitting the rfc_id field entirely is treated the same as blank —
|
||||
graduates without a number — so older clients that drop the field don't
|
||||
break, and the supplied-number path stays exactly as before."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import entry as entry_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
r = client.post("/api/rfcs/ohm/graduate?_sync=1", json={"owners": ["ben"]})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["rfc_id"] is None
|
||||
graduated = entry_mod.parse(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
)
|
||||
assert graduated.state == "active"
|
||||
assert graduated.id is None
|
||||
|
||||
|
||||
def test_claim_opens_meta_pr(app_with_fake_gitea):
|
||||
@@ -560,12 +649,10 @@ def test_claim_opens_meta_pr(app_with_fake_gitea):
|
||||
d = r.json()
|
||||
assert d["branch_name"] == "claim/ohm"
|
||||
|
||||
# The PR body's diff carries Alice in owners.
|
||||
text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"]
|
||||
ent = entry_mod.parse(text)
|
||||
assert "alice" in ent.owners
|
||||
|
||||
# cached_prs records pr_kind='meta_claim' via refresh_meta_pulls.
|
||||
row = db.conn().execute(
|
||||
"SELECT pr_kind FROM cached_prs WHERE pr_number = ?", (d["pr_number"],),
|
||||
).fetchone()
|
||||
|
||||
@@ -339,10 +339,10 @@ def test_hygiene_action_kinds_fire_no_notifications(app_with_fake_gitea):
|
||||
|
||||
|
||||
def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea):
|
||||
"""§19.2 candidate Slice 8 settles: when graduation rolls back
|
||||
after step 3 (open_pr), the `graduate-<slug>-<6hex>` branch is
|
||||
deleted alongside the PR close so failed-graduation branches
|
||||
don't accumulate on the meta repo across retries."""
|
||||
"""Meta-only (§13.3): when the flip's merge fails after the PR is
|
||||
open, the orchestrator closes the PR and deletes its
|
||||
`graduate-<slug>-<6hex>` branch so failed attempts don't accumulate
|
||||
on the meta repo across retries."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
from app.bot import Bot
|
||||
@@ -359,24 +359,23 @@ def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea):
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Force a step-4 (merge_pr) failure so step 3 (open_pr) has
|
||||
# already landed and the rollback exercises the branch cleanup.
|
||||
# Force a merge_pr failure so the flip PR (open_pr) has already
|
||||
# landed and the cleanup exercises the branch deletion.
|
||||
orig_merge = Bot.merge_graduation_pr
|
||||
async def boom(self, *args, **kwargs):
|
||||
raise GiteaError(502, "simulated merge failure for rollback test")
|
||||
raise GiteaError(502, "simulated merge failure for cleanup test")
|
||||
Bot.merge_graduation_pr = boom
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0099", "owners": ["ben"]},
|
||||
)
|
||||
finally:
|
||||
Bot.merge_graduation_pr = orig_merge
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["succeeded"] is False
|
||||
|
||||
# The dash-suffixed graduation branch was deleted on rollback.
|
||||
# The dash-suffixed graduation branch was deleted on cleanup.
|
||||
meta_branches = fake.branches[("wiggleverse", "meta")]
|
||||
graduation_branches = [n for n in meta_branches if n.startswith("graduate-ohm-")]
|
||||
assert graduation_branches == [], (
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""§22.4b — a project with initial_state='active' lands new entries active +
|
||||
unreviewed; the default 'super-draft' project is unchanged."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def _propose(client):
|
||||
return client.post("/api/rfcs/propose", json={
|
||||
"title": "Active Lander", "slug": "active-lander",
|
||||
"pitch": "Lands active.", "tags": [],
|
||||
})
|
||||
|
||||
|
||||
def test_super_draft_default_unchanged(app_with_fake_gitea):
|
||||
from app import entry as entry_mod
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
assert _propose(client).status_code == 200
|
||||
f = fake.files[("wiggleverse", "meta", "propose/active-lander", "rfcs/active-lander.md")]
|
||||
e = entry_mod.parse(f["content"])
|
||||
assert e.state == "super-draft"
|
||||
assert e.unreviewed is False
|
||||
|
||||
|
||||
def test_active_initial_state_lands_active_unreviewed(app_with_fake_gitea):
|
||||
from app import db, entry as entry_mod
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
db.conn().execute("UPDATE projects SET initial_state='active' WHERE id='default'")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
assert _propose(client).status_code == 200
|
||||
f = fake.files[("wiggleverse", "meta", "propose/active-lander", "rfcs/active-lander.md")]
|
||||
e = entry_mod.parse(f["content"])
|
||||
assert e.state == "active"
|
||||
assert e.unreviewed is True
|
||||
@@ -0,0 +1,65 @@
|
||||
"""§22.4c — owner/admin mark-reviewed clears the flag; catalog unreviewed filter."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def _seed_unreviewed_active(fake, slug="feat"):
|
||||
"""Put an active+unreviewed entry on the meta repo main + cache."""
|
||||
from app import cache, entry as entry_mod
|
||||
body = entry_mod.serialize(entry_mod.Entry(
|
||||
slug=slug, title="Feat", state="active", unreviewed=True,
|
||||
owners=["ben"], proposed_by="ben",
|
||||
))
|
||||
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {"content": body, "sha": "s1"}
|
||||
cache._upsert_cached_rfc(entry_mod.parse(body), body_sha="s1")
|
||||
|
||||
|
||||
def test_catalog_unreviewed_filter(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_unreviewed_active(fake, "feat")
|
||||
from app import cache, entry as entry_mod
|
||||
ok = entry_mod.serialize(entry_mod.Entry(slug="ok", title="OK", state="active", owners=["ben"]))
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ok.md")] = {"content": ok, "sha": "s2"}
|
||||
cache._upsert_cached_rfc(entry_mod.parse(ok), body_sha="s2")
|
||||
r = client.get("/api/rfcs", params={"unreviewed": "true"})
|
||||
slugs = {i["slug"] for i in r.json()["items"]}
|
||||
assert slugs == {"feat"}
|
||||
|
||||
|
||||
def test_mark_reviewed_clears_flag(app_with_fake_gitea):
|
||||
from app import db
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_unreviewed_active(fake, "feat")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
r = client.post("/api/projects/default/rfcs/feat/mark-reviewed")
|
||||
assert r.status_code == 200
|
||||
row = db.conn().execute(
|
||||
"SELECT unreviewed, reviewed_at, reviewed_by FROM cached_rfcs WHERE slug='feat'"
|
||||
).fetchone()
|
||||
assert row["unreviewed"] == 0
|
||||
assert row["reviewed_by"] == "ben"
|
||||
assert row["reviewed_at"] # provenance stamped
|
||||
# git-side: the entry file on main was rewritten with the cleared flag.
|
||||
from app import entry as entry_mod
|
||||
written = fake.files[("wiggleverse", "meta", "main", "rfcs/feat.md")]["content"]
|
||||
e = entry_mod.parse(written)
|
||||
assert e.unreviewed is False
|
||||
assert e.reviewed_by == "ben"
|
||||
|
||||
|
||||
def test_mark_reviewed_forbidden_for_non_superuser(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_unreviewed_active(fake, "feat")
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="carol", display_name="Carol", role="contributor")
|
||||
r = client.post("/api/projects/default/rfcs/feat/mark-reviewed")
|
||||
assert r.status_code == 403
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Migration 027 — additive §22 M3 schema (projects.type/initial_state, the
|
||||
deployment singleton, cached_rfcs review columns). No table rebuilds in Plan A."""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from app import db
|
||||
from app.config import Config
|
||||
|
||||
|
||||
def _fresh_config() -> Config:
|
||||
tmp = Path(tempfile.mkdtemp(prefix="mig027-")) / "t.db"
|
||||
return Config(
|
||||
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="x",
|
||||
registry_repo="registry",
|
||||
oauth_client_id="x", oauth_client_secret="x", app_url="x",
|
||||
secret_key="x", database_path=tmp, owner_gitea_login="x",
|
||||
webhook_secret="x",
|
||||
)
|
||||
|
||||
|
||||
def test_027_adds_project_type_and_initial_state():
|
||||
cfg = _fresh_config()
|
||||
db.run_migrations(cfg)
|
||||
conn = db.connect(cfg.database_path)
|
||||
cols = {r["name"]: r for r in conn.execute("PRAGMA table_info(projects)")}
|
||||
assert "type" in cols and cols["type"]["dflt_value"] == "'document'"
|
||||
assert "initial_state" in cols and cols["initial_state"]["dflt_value"] == "'super-draft'"
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_027_creates_deployment_singleton():
|
||||
cfg = _fresh_config()
|
||||
db.run_migrations(cfg)
|
||||
conn = db.connect(cfg.database_path)
|
||||
rows = list(conn.execute("SELECT id FROM deployment"))
|
||||
assert [r["id"] for r in rows] == [1]
|
||||
try:
|
||||
conn.execute("INSERT INTO deployment (id) VALUES (2)")
|
||||
raised = False
|
||||
except Exception:
|
||||
raised = True
|
||||
assert raised
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_027_adds_review_columns_to_cached_rfcs():
|
||||
cfg = _fresh_config()
|
||||
db.run_migrations(cfg)
|
||||
conn = db.connect(cfg.database_path)
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(cached_rfcs)")}
|
||||
assert {"unreviewed", "reviewed_at", "reviewed_by"} <= cols
|
||||
conn.close()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""§22.13 / migration 028 — the slug-keyed PK/UNIQUE rebuild that activates
|
||||
project #2. Proves two projects can hold the same slug, that (project_id, slug)
|
||||
is still unique within a project, that the rebuilt FK is composite + enforced,
|
||||
and that the no-foreign-keys migration runner left no dangling references."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, path):
|
||||
self.database_path = path
|
||||
|
||||
|
||||
def _fresh_db():
|
||||
d = tempfile.mkdtemp()
|
||||
path = Path(d) / "t.db"
|
||||
db.run_migrations(_Cfg(str(path)))
|
||||
return db.connect(str(path))
|
||||
|
||||
|
||||
def _seed_two_projects(conn):
|
||||
for pid in ("default", "ecomm"):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) "
|
||||
"VALUES (?, ?, 'document', ?, 'public', 'super-draft')",
|
||||
(pid, pid.title(), pid + "-content"),
|
||||
)
|
||||
|
||||
|
||||
def test_same_slug_coexists_across_projects():
|
||||
conn = _fresh_db()
|
||||
_seed_two_projects(conn)
|
||||
for pid in ("default", "ecomm"):
|
||||
conn.execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
|
||||
"VALUES ('intro', 'Intro', 'active', ?)",
|
||||
(pid,),
|
||||
)
|
||||
rows = conn.execute(
|
||||
"SELECT project_id FROM cached_rfcs WHERE slug = 'intro' ORDER BY project_id"
|
||||
).fetchall()
|
||||
assert [r["project_id"] for r in rows] == ["default", "ecomm"]
|
||||
|
||||
|
||||
def test_slug_still_unique_within_a_project():
|
||||
conn = _fresh_db()
|
||||
_seed_two_projects(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
|
||||
"VALUES ('intro', 'Intro', 'active', 'default')"
|
||||
)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
|
||||
"VALUES ('intro', 'Dup', 'active', 'default')"
|
||||
)
|
||||
|
||||
|
||||
def test_rfc_collaborators_composite_fk_enforced():
|
||||
conn = _fresh_db()
|
||||
_seed_two_projects(conn)
|
||||
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1, 'a', 'A', 'contributor')")
|
||||
conn.execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
|
||||
"VALUES ('intro', 'Intro', 'active', 'ecomm')"
|
||||
)
|
||||
# Matching (project_id, slug) — FK holds.
|
||||
conn.execute(
|
||||
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, project_id) "
|
||||
"VALUES ('intro', 1, 'contributor', 'ecomm')"
|
||||
)
|
||||
# Same slug but a project with no such entry — composite FK must reject.
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, project_id) "
|
||||
"VALUES ('intro', 1, 'contributor', 'default')"
|
||||
)
|
||||
|
||||
|
||||
def test_stars_unique_now_scoped_by_project():
|
||||
conn = _fresh_db()
|
||||
_seed_two_projects(conn)
|
||||
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1, 'a', 'A', 'contributor')")
|
||||
# Same (user, slug) under two projects coexist; a duplicate within one rejects.
|
||||
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'default')")
|
||||
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'ecomm')")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'default')")
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Slice M2 — project-scoped authorization + the §22.7 resolver.
|
||||
|
||||
M1 laid the schema spine (the `projects` / `project_members` tables and the
|
||||
`project_id` column on every slug-bearing table). M2 builds the resolver on top:
|
||||
the most-permissive union of the deployment role (§6.1), the project role
|
||||
(§22.6), and the per-RFC authority (§6.3/§12), with the §22.5 visibility gate
|
||||
subtractive on top (§22.7).
|
||||
|
||||
Two operator decisions are pinned here as executable expectations:
|
||||
|
||||
* implicit-on-public — a granted deployment `contributor` keeps its
|
||||
pre-multi-project write *baseline* on a `public` project (propose freely;
|
||||
an owned RFC's discuss/contribute still needs the per-RFC invite) with no
|
||||
project_members row. So the single public default project behaves exactly
|
||||
as it did before M2 (verified across the rest of the suite, and the
|
||||
`*_public_*` tests below).
|
||||
* preserve curation — the implicit-public baseline does NOT override per-RFC
|
||||
owner curation; only an *explicit* project_contributor/admin grant (or a
|
||||
deployment owner/admin) bypasses it.
|
||||
|
||||
Everything is exercised on the single `default` project by flipping its
|
||||
visibility and granting/revoking `project_members` roles — the M2 slice is
|
||||
verifiable without a second project (which arrives with M3's registry mirror).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _su(user_id: int, login: str, role: str, *, state: str = "granted"):
|
||||
"""A SessionUser handle for direct resolver calls (the endpoint tests use
|
||||
sign_in_as instead)."""
|
||||
from app import auth
|
||||
|
||||
return auth.SessionUser(
|
||||
user_id=user_id,
|
||||
gitea_id=user_id,
|
||||
gitea_login=login,
|
||||
display_name=login.capitalize(),
|
||||
email=f"{login}@test",
|
||||
avatar_url="",
|
||||
role=role,
|
||||
permission_state=state,
|
||||
)
|
||||
|
||||
|
||||
def _set_visibility(project_id: str, visibility: str) -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"UPDATE projects SET visibility = ? WHERE id = ?", (visibility, project_id)
|
||||
)
|
||||
|
||||
|
||||
def _add_member(project_id: str, user_id: int, role: str) -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO project_members (project_id, user_id, role) VALUES (?, ?, ?)",
|
||||
(project_id, user_id, role),
|
||||
)
|
||||
|
||||
|
||||
def _remove_member(project_id: str, user_id: int) -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"DELETE FROM project_members WHERE project_id = ? AND user_id = ?",
|
||||
(project_id, user_id),
|
||||
)
|
||||
|
||||
|
||||
def _seed_rfc(slug: str, *, state: str = "active", owners=None, project_id: str = "default") -> None:
|
||||
"""A minimal cached_rfcs row — enough for the authz gates (state, owners,
|
||||
project_id). project_id defaults to 'default' via migration 026 but we set
|
||||
it explicitly for clarity."""
|
||||
import json
|
||||
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO cached_rfcs
|
||||
(slug, title, state, owners_json, arbiters_json, tags_json, project_id)
|
||||
VALUES (?, ?, ?, ?, '[]', '[]', ?)
|
||||
""",
|
||||
(slug, slug.capitalize(), state, json.dumps(owners or []), project_id),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The §22.7 resolver — tier composition
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolver_public_project_tiers(app_with_fake_gitea):
|
||||
from app import auth
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="ben", role="owner")
|
||||
contributor = _su(1, "alice", "contributor")
|
||||
owner = _su(2, "ben", "owner")
|
||||
|
||||
# default is public — read open to everyone incl. anonymous.
|
||||
assert auth.can_read_project(None, "default") is True
|
||||
assert auth.can_read_project(contributor, "default") is True
|
||||
|
||||
# implicit-on-public: a granted deployment contributor carries the
|
||||
# write baseline without a project_members row.
|
||||
assert auth.can_contribute_in_project(contributor, "default") is True
|
||||
assert auth.can_discuss_in_project(contributor, "default") is True
|
||||
|
||||
# ... but it is NOT project_admin (curation/override authority).
|
||||
assert auth.is_project_superuser(contributor, "default") is False
|
||||
# a deployment owner/admin is a superuser in every project.
|
||||
assert auth.is_project_superuser(owner, "default") is True
|
||||
|
||||
# a pending contributor has no write standing (the §6 admission floor).
|
||||
pending = _su(1, "alice", "contributor", state="pending")
|
||||
assert auth.can_contribute_in_project(pending, "default") is False
|
||||
|
||||
|
||||
def test_resolver_gated_project_requires_membership(app_with_fake_gitea):
|
||||
from app import auth
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="ben", role="owner")
|
||||
_set_visibility("default", "gated")
|
||||
contributor = _su(1, "alice", "contributor")
|
||||
owner = _su(2, "ben", "owner")
|
||||
|
||||
# gated: no membership → invisible and no standing.
|
||||
assert auth.can_read_project(None, "default") is False
|
||||
assert auth.can_read_project(contributor, "default") is False
|
||||
assert auth.can_contribute_in_project(contributor, "default") is False
|
||||
|
||||
# deployment owner is a superuser regardless of membership.
|
||||
assert auth.can_read_project(owner, "default") is True
|
||||
assert auth.is_project_superuser(owner, "default") is True
|
||||
|
||||
# project_viewer → read + discuss, but not contribute.
|
||||
_add_member("default", 1, "project_viewer")
|
||||
assert auth.can_read_project(contributor, "default") is True
|
||||
assert auth.can_discuss_in_project(contributor, "default") is True
|
||||
assert auth.can_contribute_in_project(contributor, "default") is False
|
||||
|
||||
# project_contributor → contribute.
|
||||
_add_member("default", 1, "project_contributor")
|
||||
assert auth.can_contribute_in_project(contributor, "default") is True
|
||||
assert auth.is_project_superuser(contributor, "default") is False
|
||||
|
||||
# project_admin → superuser within the project.
|
||||
_add_member("default", 1, "project_admin")
|
||||
assert auth.is_project_superuser(contributor, "default") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Public default unchanged — the regression floor (curation preserved)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_public_per_rfc_curation_preserved(app_with_fake_gitea):
|
||||
"""On the public default project a granted contributor still cannot
|
||||
discuss an *owned* RFC without a per-RFC invite (the v0.16.0 contract);
|
||||
but an unclaimed (no-owners) entry stays open. This is the implicit-public
|
||||
baseline with curation preserved."""
|
||||
from app import auth
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="bob", role="contributor")
|
||||
bob = _su(2, "bob", "contributor")
|
||||
|
||||
_seed_rfc("owned", state="active", owners=["alice"])
|
||||
assert auth.can_discuss_rfc(bob, "owned") is False
|
||||
assert auth.can_contribute_to_rfc(bob, "owned") is False
|
||||
|
||||
_seed_rfc("draft", state="super-draft", owners=[])
|
||||
assert auth.can_discuss_rfc(bob, "draft") is True
|
||||
assert auth.can_contribute_to_rfc(bob, "draft") is True
|
||||
|
||||
|
||||
def test_public_read_open_write_gated_endpoints(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="bob", role="contributor")
|
||||
_seed_rfc("ohm", state="active", owners=["alice"])
|
||||
|
||||
# anonymous can read the entry on a public project.
|
||||
r = client.get("/api/rfcs/ohm")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# anonymous cannot open a discussion thread (401, the §6 write floor).
|
||||
r = client.post("/api/rfcs/ohm/discussion/threads", json={"message": "hi"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. The §22.5 visibility gate — 404 to non-members on read
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_gated_project_404s_non_members(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="bob", role="contributor")
|
||||
provision_user_row(user_id=3, login="ben", role="owner")
|
||||
_seed_rfc("sekret", state="active", owners=["alice"])
|
||||
_set_visibility("default", "gated")
|
||||
|
||||
# anonymous → 404 (indistinguishable from an unknown slug).
|
||||
assert client.get("/api/rfcs/sekret").status_code == 404
|
||||
|
||||
# signed-in non-member → 404 on the entry and its discussion.
|
||||
sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor")
|
||||
assert client.get("/api/rfcs/sekret").status_code == 404
|
||||
assert client.get("/api/rfcs/sekret/discussion/threads").status_code == 404
|
||||
# the gated entry never surfaces in the non-member's catalog.
|
||||
assert client.get("/api/rfcs").json()["items"] == []
|
||||
|
||||
# a project_viewer member can read again.
|
||||
_add_member("default", 2, "project_viewer")
|
||||
assert client.get("/api/rfcs/sekret").status_code == 200
|
||||
assert [i["slug"] for i in client.get("/api/rfcs").json()["items"]] == ["sekret"]
|
||||
|
||||
# a deployment owner can always read.
|
||||
sign_in_as(client, user_id=3, gitea_login="ben", display_name="Ben", role="owner")
|
||||
assert client.get("/api/rfcs/sekret").status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. The contribution gate on a gated project (401/403)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_gated_propose_requires_project_contributor(app_with_fake_gitea):
|
||||
"""Propose checks project-level contribute standing before any Gitea
|
||||
work, so the gate is observable as a 403 (gated, non-member) without
|
||||
seeding the success path."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="bob", role="contributor")
|
||||
_set_visibility("default", "gated")
|
||||
sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor")
|
||||
|
||||
body = {"slug": "newidea", "title": "New Idea", "pitch": "A pitch.", "tags": []}
|
||||
assert client.post("/api/rfcs/propose", json=body).status_code == 403
|
||||
|
||||
# grant project_contributor — the project gate now passes (the request
|
||||
# proceeds past the gate; we assert only that it is no longer 403).
|
||||
_add_member("default", 2, "project_contributor")
|
||||
assert client.post("/api/rfcs/propose", json=body).status_code != 403
|
||||
|
||||
|
||||
def test_gated_viewer_can_discuss_contributor_can_contribute(app_with_fake_gitea):
|
||||
from app import auth
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="bob", role="contributor")
|
||||
_seed_rfc("spec", state="super-draft", owners=[])
|
||||
_set_visibility("default", "gated")
|
||||
bob = _su(2, "bob", "contributor")
|
||||
|
||||
# non-member: discussion thread create → 404 (visibility, before the
|
||||
# write gate even applies).
|
||||
sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor")
|
||||
assert client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"}).status_code == 404
|
||||
|
||||
# project_viewer: can discuss (200) but cannot contribute (resolver).
|
||||
_add_member("default", 2, "project_viewer")
|
||||
r = client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert auth.can_contribute_to_rfc(bob, "spec") is False
|
||||
|
||||
# project_contributor: can contribute.
|
||||
_add_member("default", 2, "project_contributor")
|
||||
assert auth.can_contribute_to_rfc(bob, "spec") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Union of tiers + subtractive visibility gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_per_rfc_authority_unions_then_yields_to_visibility(app_with_fake_gitea):
|
||||
from app import auth
|
||||
from test_propose_vertical import grant_rfc_collaborator
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
provision_user_row(user_id=2, login="bob", role="contributor")
|
||||
_seed_rfc("owned", state="active", owners=["alice"])
|
||||
bob = _su(2, "bob", "contributor")
|
||||
|
||||
# public + per-RFC collaborator(contributor) → contribute (union term).
|
||||
grant_rfc_collaborator(user_id=2, rfc_slug="owned", role_in_rfc="contributor")
|
||||
assert auth.can_contribute_to_rfc(bob, "owned") is True
|
||||
|
||||
# flip to gated: the §22.5 gate is subtractive — the per-RFC grant no
|
||||
# longer suffices without project membership.
|
||||
_set_visibility("default", "gated")
|
||||
assert auth.can_contribute_to_rfc(bob, "owned") is False
|
||||
|
||||
# restore read via project membership → the per-RFC union applies again.
|
||||
_add_member("default", 2, "project_viewer")
|
||||
assert auth.can_contribute_to_rfc(bob, "owned") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Revocation takes effect on the next call
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_revoking_membership_revokes_access(app_with_fake_gitea):
|
||||
from app import auth
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
provision_user_row(user_id=2, login="bob", role="contributor")
|
||||
_set_visibility("default", "gated")
|
||||
bob = _su(2, "bob", "contributor")
|
||||
|
||||
_add_member("default", 2, "project_contributor")
|
||||
assert auth.can_contribute_in_project(bob, "default") is True
|
||||
|
||||
_remove_member("default", 2)
|
||||
assert auth.can_read_project(bob, "default") is False
|
||||
assert auth.can_contribute_in_project(bob, "default") is False
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Slice M1+M3 — the §22 multi-project spine.
|
||||
|
||||
Migration 026 introduces the `projects` and `project_members` tables, seeds
|
||||
the single `default` project (the N=1 case, §22.13), and threads a
|
||||
`project_id` column onto every slug-bearing table, backfilled to `default`.
|
||||
M3 retires the META_REPO startup backfill; content_repo now comes from the
|
||||
registry mirror (projects.yaml in REGISTRY_REPO). These tests prove the
|
||||
spine lands without disturbing the single-project app.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
# The 19 tables migration 026 threads project_id onto (docs/design/
|
||||
# multi-project-spec.md §5 amendment list).
|
||||
SLUG_TABLES = [
|
||||
"cached_rfcs", "cached_branches", "cached_prs", "branch_visibility",
|
||||
"branch_contribute_grants", "stars", "threads", "changes", "pr_seen",
|
||||
"branch_chat_seen", "watches", "notifications", "actions",
|
||||
"pr_resolution_branches", "funder_consents", "rfc_invitations",
|
||||
"rfc_collaborators", "proposed_use_cases", "contribution_requests",
|
||||
]
|
||||
|
||||
|
||||
def test_default_project_seeded_and_backfilled(app_with_fake_gitea):
|
||||
from app import db
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
rows = list(db.conn().execute(
|
||||
"SELECT id, name, visibility, content_repo FROM projects"
|
||||
))
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["id"] == "default"
|
||||
# public preserves the pre-multi-project open-by-default posture.
|
||||
assert row["visibility"] == "public"
|
||||
# M3: content_repo now comes from the registry mirror (projects.yaml),
|
||||
# not the retired META_REPO startup backfill.
|
||||
assert row["content_repo"] == "meta"
|
||||
|
||||
|
||||
def test_project_id_on_every_slug_table(app_with_fake_gitea):
|
||||
from app import db
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
for table in SLUG_TABLES:
|
||||
cols = {r["name"]: r for r in db.conn().execute(
|
||||
f"PRAGMA table_info({table})"
|
||||
)}
|
||||
assert "project_id" in cols, f"{table} missing project_id"
|
||||
col = cols["project_id"]
|
||||
# NOT NULL with the constant 'default' backfill default.
|
||||
assert col["notnull"] == 1, f"{table}.project_id should be NOT NULL"
|
||||
assert col["dflt_value"] == "'default'", f"{table}.project_id default"
|
||||
|
||||
|
||||
def test_existing_row_backfills_to_default(app_with_fake_gitea):
|
||||
"""A row inserted the old way (no project_id) lands in the default
|
||||
project — the trick that keeps every pre-multi-project INSERT working."""
|
||||
from app import db
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
db.conn().execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state) VALUES (?, ?, ?)",
|
||||
("human", "Human", "active"),
|
||||
)
|
||||
got = db.conn().execute(
|
||||
"SELECT project_id FROM cached_rfcs WHERE slug = 'human'"
|
||||
).fetchone()["project_id"]
|
||||
assert got == "default"
|
||||
|
||||
|
||||
def test_project_members_table_shape(app_with_fake_gitea):
|
||||
from app import db
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
cols = {r["name"] for r in db.conn().execute(
|
||||
"PRAGMA table_info(project_members)"
|
||||
)}
|
||||
assert cols == {"project_id", "user_id", "role", "granted_by", "granted_at"}
|
||||
# The role CHECK rejects an unknown role.
|
||||
db.conn().execute(
|
||||
"INSERT INTO users (id, display_name, role) VALUES (1, 'Ben', 'owner')"
|
||||
)
|
||||
db.conn().execute(
|
||||
"INSERT INTO project_members (project_id, user_id, role) "
|
||||
"VALUES ('default', 1, 'project_admin')"
|
||||
)
|
||||
import sqlite3
|
||||
try:
|
||||
db.conn().execute(
|
||||
"INSERT INTO project_members (project_id, user_id, role) "
|
||||
"VALUES ('default', 1, 'nonsense')"
|
||||
)
|
||||
assert False, "CHECK should reject an unknown project role"
|
||||
except sqlite3.IntegrityError:
|
||||
pass
|
||||
|
||||
|
||||
def test_registry_mirror_is_idempotent(app_with_fake_gitea):
|
||||
"""Re-running the registry mirror is safe — it upserts (overwrites) the
|
||||
projects row from projects.yaml each time without raising. M3 retirement
|
||||
of seed_default_project: the registry mirror is now the sole authority."""
|
||||
import asyncio
|
||||
from app import db, registry as registry_mod
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
before = db.conn().execute("SELECT COUNT(*) AS n FROM projects").fetchone()["n"]
|
||||
cfg = app.state.config
|
||||
gitea = app.state.gitea
|
||||
asyncio.run(registry_mod.refresh_registry(cfg, gitea))
|
||||
after = db.conn().execute("SELECT COUNT(*) AS n FROM projects").fetchone()["n"]
|
||||
assert after == before
|
||||
row = db.conn().execute(
|
||||
"SELECT content_repo FROM projects WHERE id='default'"
|
||||
).fetchone()
|
||||
assert row["content_repo"] == "meta"
|
||||
|
||||
|
||||
def test_registry_repo_config_wired(app_with_fake_gitea, monkeypatch):
|
||||
from app.config import load_config
|
||||
|
||||
monkeypatch.setenv("REGISTRY_REPO", "wiggleverse-registry")
|
||||
assert load_config().registry_repo == "wiggleverse-registry"
|
||||
# M3: REGISTRY_REPO is now required — absent raises RuntimeError.
|
||||
monkeypatch.delenv("REGISTRY_REPO", raising=False)
|
||||
import pytest
|
||||
with pytest.raises(RuntimeError, match="REGISTRY_REPO"):
|
||||
load_config()
|
||||
@@ -0,0 +1,64 @@
|
||||
"""§22.4 (Plan B write): proposing a new entry into a *specific* project lands
|
||||
it in that project's content repo and surfaces under that project's proposals,
|
||||
isolated from the default project."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def _register_ecomm(fake):
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) "
|
||||
"VALUES ('ecomm', 'Ecomm', 'document', 'ecomm-content', 'public', 'super-draft')"
|
||||
)
|
||||
fake._seed_repo("wiggleverse", "ecomm-content")
|
||||
|
||||
|
||||
def test_propose_into_second_project_lands_scoped(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_register_ecomm(fake)
|
||||
provision_user_row(user_id=3, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice",
|
||||
role="contributor", email="alice@test")
|
||||
r = client.post("/api/projects/ecomm/rfcs/propose", json={
|
||||
"title": "Cart", "slug": "cart", "pitch": "why a cart", "tags": [],
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# The idea PR shows under ecomm's proposals, not the default's.
|
||||
e = {i["slug"] for i in client.get("/api/projects/ecomm/proposals").json()["items"]}
|
||||
d = {i["slug"] for i in client.get("/api/projects/default/proposals").json()["items"]}
|
||||
assert "cart" in e
|
||||
assert "cart" not in d
|
||||
|
||||
# It landed in ecomm's content repo, not the default 'meta' repo.
|
||||
assert ("wiggleverse", "ecomm-content") in {
|
||||
(o, rp) for (o, rp) in fake.branches if rp == "ecomm-content"
|
||||
}
|
||||
assert any(
|
||||
br.startswith("propose/cart")
|
||||
for br in fake.branches.get(("wiggleverse", "ecomm-content"), {})
|
||||
)
|
||||
|
||||
|
||||
def test_propose_into_gated_project_404s_for_non_member(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
from app import db
|
||||
with TestClient(app) as client:
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) "
|
||||
"VALUES ('secret', 'Secret', 'document', 'secret-content', 'gated', 'super-draft')"
|
||||
)
|
||||
provision_user_row(user_id=4, login="bob", role="contributor")
|
||||
sign_in_as(client, user_id=4, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
r = client.post("/api/projects/secret/rfcs/propose", json={
|
||||
"title": "X", "slug": "x", "pitch": "p", "tags": [],
|
||||
})
|
||||
assert r.status_code == 404
|
||||
@@ -0,0 +1,67 @@
|
||||
"""§22.4 (Plan B) — per-project RFC serving. A second project's corpus renders
|
||||
under its own slug namespace via /api/projects/{pid}/rfcs[/{slug}], isolated
|
||||
from the default project and gated by §22.5 visibility."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def _add_project(pid, name, vis="public"):
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) "
|
||||
"VALUES (?, ?, 'document', ?, ?, 'super-draft')",
|
||||
(pid, name, pid + "-content", vis),
|
||||
)
|
||||
|
||||
|
||||
def _add_rfc(slug, title, pid, state="active"):
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state, project_id) VALUES (?, ?, ?, ?)",
|
||||
(slug, title, state, pid),
|
||||
)
|
||||
|
||||
|
||||
def test_catalog_scoped_to_one_project(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("ecomm", "Ecomm")
|
||||
_add_rfc("intro", "Default Intro", "default")
|
||||
_add_rfc("intro", "Ecomm Intro", "ecomm")
|
||||
_add_rfc("only-ecomm", "Ecomm Only", "ecomm")
|
||||
|
||||
d = client.get("/api/projects/default/rfcs").json()["items"]
|
||||
e = client.get("/api/projects/ecomm/rfcs").json()["items"]
|
||||
d_slugs = {i["slug"] for i in d}
|
||||
e_slugs = {i["slug"] for i in e}
|
||||
assert "intro" in d_slugs and "only-ecomm" not in d_slugs
|
||||
assert {"intro", "only-ecomm"} <= e_slugs
|
||||
|
||||
|
||||
def test_entry_is_isolated_by_project(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("ecomm", "Ecomm")
|
||||
_add_rfc("intro", "Default Intro", "default")
|
||||
_add_rfc("intro", "Ecomm Intro", "ecomm")
|
||||
_add_rfc("only-ecomm", "Ecomm Only", "ecomm")
|
||||
|
||||
assert client.get("/api/projects/default/rfcs/intro").json()["title"] == "Default Intro"
|
||||
assert client.get("/api/projects/ecomm/rfcs/intro").json()["title"] == "Ecomm Intro"
|
||||
# a slug that exists only in ecomm 404s under default
|
||||
assert client.get("/api/projects/ecomm/rfcs/only-ecomm").status_code == 200
|
||||
assert client.get("/api/projects/default/rfcs/only-ecomm").status_code == 404
|
||||
|
||||
|
||||
def test_gated_project_catalog_404s_for_anon(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("secret", "Secret", vis="gated")
|
||||
_add_rfc("hush", "Hush", "secret")
|
||||
assert client.get("/api/projects/secret/rfcs").status_code == 404
|
||||
assert client.get("/api/projects/secret/rfcs/hush").status_code == 404
|
||||
@@ -55,6 +55,24 @@ class FakeGitea:
|
||||
self._pr_counter = 0
|
||||
self._commit_counter = 0
|
||||
self._seed_repo("wiggleverse", "meta")
|
||||
# §22 M3: the deployment's project registry. Startup refresh_registry
|
||||
# reads projects.yaml here; the single 'default' project's content_repo
|
||||
# points back at the seeded meta repo so the corpus mirror is unchanged.
|
||||
self._seed_repo("wiggleverse", "registry")
|
||||
self.files[("wiggleverse", "registry", "main", "projects.yaml")] = {
|
||||
"content": (
|
||||
"deployment:\n"
|
||||
" name: Test Deployment\n"
|
||||
" tagline: A test deployment\n"
|
||||
"projects:\n"
|
||||
" - id: default\n"
|
||||
" name: Test Deployment\n"
|
||||
" type: document\n"
|
||||
" content_repo: meta\n"
|
||||
" visibility: public\n"
|
||||
),
|
||||
"sha": "regsha0001",
|
||||
}
|
||||
|
||||
def _seed_repo(self, owner, repo):
|
||||
self.branches[(owner, repo)] = {"main": {"sha": "initial", "ts": "2026-05-23T00:00:00Z"}}
|
||||
@@ -431,7 +449,7 @@ def tmp_env(monkeypatch):
|
||||
"GITEA_BOT_USER": "rfc-bot",
|
||||
"GITEA_BOT_TOKEN": "bot-token",
|
||||
"GITEA_ORG": "wiggleverse",
|
||||
"META_REPO": "meta",
|
||||
"REGISTRY_REPO": "registry",
|
||||
"OAUTH_CLIENT_ID": "cid",
|
||||
"OAUTH_CLIENT_SECRET": "csec",
|
||||
"APP_URL": "http://localhost:8000",
|
||||
@@ -444,6 +462,18 @@ def tmp_env(monkeypatch):
|
||||
# the dev-bypass path monkeypatch `RFC_APP_INSECURE_WEBHOOKS=1`.
|
||||
"GITEA_WEBHOOK_SECRET": "test-webhook-secret-for-signature-verification",
|
||||
"ENABLED_MODELS": "claude",
|
||||
# v0.27.0 (audit 0026 M4): the session cookie now defaults to
|
||||
# Secure. The TestClient talks plain http://testserver, so a
|
||||
# Secure cookie is never sent back and every authenticated flow
|
||||
# would fail. Tests opt out explicitly, exactly as a dev box on
|
||||
# plain http does.
|
||||
"SESSION_COOKIE_SECURE": "false",
|
||||
# v0.27.0 (audit 0026 M5): the bounce webhook fails closed (503)
|
||||
# when its secret is unset. Tests exercise the legacy behavioral
|
||||
# path via the documented dev opt-in, mirroring the
|
||||
# RFC_APP_INSECURE_WEBHOOKS bypass above. Tests that assert the
|
||||
# fail-closed default delenv this key themselves.
|
||||
"RFC_APP_INSECURE_BOUNCE_WEBHOOK": "1",
|
||||
}
|
||||
for k, v in env.items():
|
||||
monkeypatch.setenv(k, v)
|
||||
@@ -565,6 +595,97 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
|
||||
assert ("merge_proposal", "ben") in kinds
|
||||
|
||||
|
||||
def test_merged_idea_pr_with_deleted_branch_clears_proposal(app_with_fake_gitea):
|
||||
"""Regression: a merged idea PR whose branch was deleted must not
|
||||
linger as a 'pending idea' ghost.
|
||||
|
||||
Found via the ROADMAP #35 operator authoring lane: merging an idea
|
||||
PR from the CLI with `--delete-branch` makes Gitea report the PR's
|
||||
`head.ref` as the synthetic `refs/pull/<N>/head` sentinel instead of
|
||||
`propose/<slug>`. `refresh_meta_pulls` derives the slug from the
|
||||
branch name, so the sentinel parsed to slug=None, the row was skipped,
|
||||
and `cached_prs.state` stayed frozen at 'open' — leaving the entry
|
||||
showing as BOTH a super-draft (cached_rfcs reconciled off the push)
|
||||
AND a pending idea (cached_prs never updated). The fix recovers the
|
||||
original branch name from the already-stored cached_prs row.
|
||||
|
||||
The web UX never tripped this because it leaves the branch in place
|
||||
(the repo's default_delete_branch_after_merge is false).
|
||||
|
||||
The bug only manifests on an out-of-band merge (the PR merged +
|
||||
branch deleted directly in Gitea, with the in-app merge endpoint never
|
||||
reconciling the row while the branch still existed) -- which is exactly
|
||||
what the #35 CLI lane does. An in-app merge reconciles cached_prs to
|
||||
'merged' before the branch is gone, so it never trips this; the test
|
||||
therefore drives the Gitea state directly to reproduce the CLI path.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, cache, gitea as gitea_mod
|
||||
from app.config import load_config
|
||||
|
||||
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", email="alice@test")
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "Informed Consent",
|
||||
"slug": "informed-consent",
|
||||
"pitch": "A first-class definition of consent in OHM.",
|
||||
"tags": [],
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_number = r.json()["pr_number"]
|
||||
|
||||
# The proposal is cached as an open idea PR.
|
||||
items = client.get("/api/proposals").json()["items"]
|
||||
assert any(i["pr_number"] == pr_number for i in items)
|
||||
|
||||
# Out-of-band CLI merge (ROADMAP #35 lane): the PR is merged AND
|
||||
# its branch deleted directly in Gitea, WITHOUT the in-app merge
|
||||
# endpoint ever running. So cached_prs still says state='open' and
|
||||
# Gitea now reports the merged PR's head.ref as the sentinel. This
|
||||
# is the exact state `rfc-authoring.sh pr-merge --delete-branch`
|
||||
# leaves behind.
|
||||
for pr in fake.pulls[("wiggleverse", "meta")]:
|
||||
if pr["number"] == pr_number:
|
||||
# land the file on main (the push side already reconciles
|
||||
# cached_rfcs into a super-draft via the webhook/sweep)
|
||||
for (o, rp, br, p), data in list(fake.files.items()):
|
||||
if (o, rp, br) == ("wiggleverse", "meta", "propose/informed-consent"):
|
||||
fake.files[("wiggleverse", "meta", "main", p)] = dict(data)
|
||||
pr["state"] = "closed"
|
||||
pr["merged"] = True
|
||||
pr["merged_at"] = "2026-05-29T12:13:00Z"
|
||||
pr["closed_at"] = "2026-05-29T12:13:00Z"
|
||||
pr["merge_commit_sha"] = fake._next_sha()
|
||||
pr["head"]["ref"] = f"refs/pull/{pr_number}/head"
|
||||
fake.branches[("wiggleverse", "meta")].pop("propose/informed-consent", None)
|
||||
|
||||
# The reconcile sweep runs (a later webhook, or the 5-min safety net).
|
||||
import asyncio
|
||||
cfg = load_config()
|
||||
gclient = gitea_mod.Gitea(cfg)
|
||||
asyncio.run(cache.refresh_meta_repo(cfg, gclient))
|
||||
asyncio.run(cache.refresh_meta_pulls(cfg, gclient))
|
||||
|
||||
# The bug: this used to still list informed-consent (frozen 'open'
|
||||
# row, slug unparseable from the sentinel). The fix recovers the
|
||||
# stored branch name, so the row reconciles to merged and the ghost
|
||||
# is gone.
|
||||
assert client.get("/api/proposals").json()["items"] == []
|
||||
|
||||
# And the cached_prs row is correctly merged, not a frozen 'open'.
|
||||
row = db.conn().execute(
|
||||
"SELECT state FROM cached_prs WHERE pr_number = ?", (pr_number,)
|
||||
).fetchone()
|
||||
assert row["state"] == "merged", f"expected merged, got {row['state']}"
|
||||
|
||||
# The super-draft itself is unaffected — still in the catalog.
|
||||
items = client.get("/api/rfcs").json()["items"]
|
||||
assert any(i["slug"] == "informed-consent" and i["state"] == "super-draft" for i in items)
|
||||
|
||||
|
||||
def test_slug_uniqueness_enforced(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
app, _fake = app_with_fake_gitea
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""§22.2 registry parse + apply: validation, type-immutability, upsert."""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db, registry
|
||||
from app.config import Config
|
||||
|
||||
|
||||
def _db():
|
||||
cfg = Config(
|
||||
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="x",
|
||||
registry_repo="registry", oauth_client_id="x",
|
||||
oauth_client_secret="x", app_url="x", secret_key="x",
|
||||
database_path=Path(tempfile.mkdtemp(prefix="reg-")) / "t.db",
|
||||
owner_gitea_login="x", webhook_secret="x",
|
||||
)
|
||||
db.run_migrations(cfg)
|
||||
if db._CONN is not None:
|
||||
db._CONN.close()
|
||||
db._CONN = None
|
||||
db.init(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
VALID = """
|
||||
deployment:
|
||||
name: Open Human Model
|
||||
tagline: A model of human flourishing
|
||||
projects:
|
||||
- id: default
|
||||
name: Open Human Model
|
||||
type: document
|
||||
content_repo: meta
|
||||
visibility: public
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_valid_registry():
|
||||
doc = registry.parse_registry(VALID)
|
||||
assert doc.deployment_name == "Open Human Model"
|
||||
assert doc.deployment_tagline == "A model of human flourishing"
|
||||
assert len(doc.projects) == 1
|
||||
p = doc.projects[0]
|
||||
assert (p.id, p.type, p.content_repo, p.visibility) == ("default", "document", "meta", "public")
|
||||
assert p.initial_state == "super-draft"
|
||||
|
||||
|
||||
def test_parse_initial_state_defaults_per_type():
|
||||
doc = registry.parse_registry(
|
||||
"projects:\n - {id: a, name: A, type: bdd, content_repo: a}\n"
|
||||
)
|
||||
assert doc.projects[0].initial_state == "active" # bdd default
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad,msg", [
|
||||
("projects: []\n", "at least one"),
|
||||
("projects:\n - just-a-string\n", "must be a mapping"),
|
||||
("projects:\n - {id: 'Bad Slug', name: A, type: document, content_repo: a}\n", "valid slug"),
|
||||
("projects:\n - {id: a, name: A, type: nope, content_repo: a}\n", "invalid type"),
|
||||
("projects:\n - {id: a, name: A, type: document}\n", "content_repo"),
|
||||
("projects:\n - {id: a, name: A, type: document, content_repo: a, visibility: x}\n", "visibility"),
|
||||
("projects:\n - {id: a, name: A, type: document, content_repo: a}\n - {id: a, name: B, type: document, content_repo: b}\n", "duplicate"),
|
||||
])
|
||||
def test_parse_rejects_invalid(bad, msg):
|
||||
with pytest.raises(registry.RegistryError) as e:
|
||||
registry.parse_registry(bad)
|
||||
assert msg in str(e.value)
|
||||
|
||||
|
||||
def test_apply_upserts_projects_and_deployment():
|
||||
_db()
|
||||
doc = registry.parse_registry(VALID)
|
||||
registry.apply_registry(doc, registry_sha="regsha1")
|
||||
prow = db.conn().execute(
|
||||
"SELECT name, type, content_repo, visibility, initial_state, registry_sha FROM projects WHERE id='default'"
|
||||
).fetchone()
|
||||
assert prow["name"] == "Open Human Model"
|
||||
assert prow["content_repo"] == "meta"
|
||||
assert prow["registry_sha"] == "regsha1"
|
||||
drow = db.conn().execute("SELECT name, tagline FROM deployment WHERE id=1").fetchone()
|
||||
assert drow["name"] == "Open Human Model"
|
||||
assert drow["tagline"] == "A model of human flourishing"
|
||||
|
||||
|
||||
def test_apply_rejects_type_change_on_existing_project():
|
||||
_db()
|
||||
registry.apply_registry(registry.parse_registry(VALID), "s1")
|
||||
changed = VALID.replace("type: document", "type: specification")
|
||||
registry.apply_registry(registry.parse_registry(changed), "s2") # logged + skipped, no raise
|
||||
t = db.conn().execute("SELECT type FROM projects WHERE id='default'").fetchone()["type"]
|
||||
assert t == "document" # immutable — unchanged
|
||||
# The deployment row IS still advanced even though the project upsert was skipped.
|
||||
drow = db.conn().execute("SELECT registry_sha FROM deployment WHERE id=1").fetchone()
|
||||
assert drow["registry_sha"] == "s2"
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Startup mirrors the registry; the registry webhook re-mirrors it."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: F401
|
||||
|
||||
|
||||
def test_startup_mirrors_registry_into_projects_and_deployment(app_with_fake_gitea):
|
||||
from app import db
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
prow = db.conn().execute(
|
||||
"SELECT content_repo, type, initial_state FROM projects WHERE id='default'"
|
||||
).fetchone()
|
||||
assert prow["content_repo"] == "meta" # from the registry, not META_REPO
|
||||
assert prow["type"] == "document"
|
||||
drow = db.conn().execute("SELECT name FROM deployment WHERE id=1").fetchone()
|
||||
assert drow["name"] # deployment name mirrored from the registry
|
||||
|
||||
|
||||
def test_registry_webhook_remirrors(app_with_fake_gitea):
|
||||
import hashlib
|
||||
import hmac
|
||||
import json as _json
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
from app import db
|
||||
new_yaml = (
|
||||
"deployment:\n name: OHM\n tagline: Edited tagline\n"
|
||||
"projects:\n - id: default\n name: OHM\n type: document\n"
|
||||
" content_repo: meta\n visibility: public\n"
|
||||
)
|
||||
fake.files[("wiggleverse", "registry", "main", "projects.yaml")] = {
|
||||
"content": new_yaml, "sha": "regsha2",
|
||||
}
|
||||
body = _json.dumps({"repository": {"full_name": "wiggleverse/registry"}}).encode()
|
||||
secret = "test-webhook-secret-for-signature-verification"
|
||||
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
r = client.post(
|
||||
"/api/webhooks/gitea",
|
||||
content=body,
|
||||
headers={"X-Gitea-Event": "push", "X-Gitea-Signature": sig,
|
||||
"Content-Type": "application/json"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
tagline = db.conn().execute("SELECT tagline FROM deployment WHERE id=1").fetchone()["tagline"]
|
||||
assert tagline == "Edited tagline"
|
||||
@@ -0,0 +1,64 @@
|
||||
"""§22.13 step 1 — the bootstrap-id re-stamp: 'default' → the configured
|
||||
DEFAULT_PROJECT_ID across every project-scoped table, with the composite FKs
|
||||
kept intact and the stale 'default' projects row dropped. Idempotent."""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import app.db as db
|
||||
from app import projects
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, path, default_id):
|
||||
self.database_path = path
|
||||
self.default_project_id = default_id
|
||||
|
||||
|
||||
def _setup(monkeypatch, default_id="ohm"):
|
||||
path = str(Path(tempfile.mkdtemp()) / "t.db")
|
||||
cfg = _Cfg(path, default_id)
|
||||
db.run_migrations(cfg)
|
||||
monkeypatch.setattr(db, "_CONN", db.connect(path))
|
||||
conn = db.conn()
|
||||
# M1 bootstrap row + a registry-mirrored 'ohm' row coexist pre-restamp.
|
||||
conn.execute("INSERT OR IGNORE INTO projects (id,name,type,content_repo,visibility,initial_state) "
|
||||
"VALUES ('default','Bootstrap','document','ohm-content','public','super-draft')")
|
||||
conn.execute("INSERT OR IGNORE INTO projects (id,name,type,content_repo,visibility,initial_state) "
|
||||
"VALUES ('ohm','Open Human Model','document','ohm-content','public','super-draft')")
|
||||
conn.execute("INSERT INTO users (id,gitea_login,display_name,role) VALUES (1,'a','A','contributor')")
|
||||
# default-stamped data with a composite-FK child
|
||||
conn.execute("INSERT INTO cached_rfcs (slug,title,state,project_id) VALUES ('human','Human','active','default')")
|
||||
conn.execute("INSERT INTO rfc_collaborators (rfc_slug,user_id,role_in_rfc,project_id) "
|
||||
"VALUES ('human',1,'contributor','default')")
|
||||
conn.execute("INSERT INTO stars (user_id,rfc_slug,project_id) VALUES (1,'human','default')")
|
||||
return cfg, conn
|
||||
|
||||
|
||||
def test_restamp_moves_data_and_drops_bootstrap_row(monkeypatch):
|
||||
cfg, conn = _setup(monkeypatch, default_id="ohm")
|
||||
projects.restamp_default_project(cfg)
|
||||
assert conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE project_id='default'").fetchone()["c"] == 0
|
||||
assert conn.execute("SELECT project_id FROM cached_rfcs WHERE slug='human'").fetchone()["project_id"] == "ohm"
|
||||
assert conn.execute("SELECT project_id FROM rfc_collaborators WHERE rfc_slug='human'").fetchone()["project_id"] == "ohm"
|
||||
assert conn.execute("SELECT project_id FROM stars WHERE rfc_slug='human'").fetchone()["project_id"] == "ohm"
|
||||
# stale bootstrap projects row removed; 'ohm' remains
|
||||
assert conn.execute("SELECT 1 FROM projects WHERE id='default'").fetchone() is None
|
||||
assert conn.execute("SELECT 1 FROM projects WHERE id='ohm'").fetchone() is not None
|
||||
# FK integrity intact after the rename
|
||||
assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
|
||||
|
||||
|
||||
def test_restamp_is_idempotent(monkeypatch):
|
||||
cfg, conn = _setup(monkeypatch, default_id="ohm")
|
||||
projects.restamp_default_project(cfg)
|
||||
projects.restamp_default_project(cfg) # second call: no rows left → no-op
|
||||
assert conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE project_id='ohm'").fetchone()["c"] == 1
|
||||
|
||||
|
||||
def test_restamp_noop_when_default_id_unchanged(monkeypatch):
|
||||
cfg, conn = _setup(monkeypatch, default_id="") # resolves to 'default'
|
||||
projects.restamp_default_project(cfg)
|
||||
# nothing renamed; bootstrap data + row still present
|
||||
assert conn.execute("SELECT project_id FROM cached_rfcs WHERE slug='human'").fetchone()["project_id"] == "default"
|
||||
@@ -0,0 +1,257 @@
|
||||
"""End-to-end integration tests for the §13.7 retire (soft-delete) flow.
|
||||
|
||||
Retire is an in-place frontmatter flip on the meta entry (state →
|
||||
`retired`) committed via an auto-merged PR — the same machinery as
|
||||
graduation, reused. The distinguishing rules under test:
|
||||
|
||||
* Authority (§3.1): RFC owners (frontmatter) and site `owner`-role
|
||||
holders may retire; app admins may NOT. Un-retire is site-owners-only.
|
||||
* Visibility (§13.7): a retired entry drops out of the catalog
|
||||
(`GET /api/rfcs`), and `GET /api/rfcs/<slug>` 404s for everyone except
|
||||
a site owner (so the un-retire affordance has a surface).
|
||||
* Reversibility: a site owner can un-retire, restoring the prior state
|
||||
(and keeping the integer id intact); an RFC owner cannot.
|
||||
|
||||
These walk against the in-process FakeGitea from test_propose_vertical.py,
|
||||
reusing the super-draft seed + sync graduation seam from the graduation
|
||||
suite.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
grant_rfc_collaborator,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_super_draft_vertical import seed_super_draft # noqa: F401
|
||||
from test_graduation_vertical import PITCH, seed_owned_super_draft # noqa: F401
|
||||
|
||||
|
||||
def _catalog_slugs(client) -> set[str]:
|
||||
return {i["slug"] for i in client.get("/api/rfcs").json()["items"]}
|
||||
|
||||
|
||||
def test_rfc_owner_can_retire_and_entry_leaves_every_surface(app_with_fake_gitea):
|
||||
"""An RFC owner (frontmatter, role contributor) retires their own
|
||||
super-draft. The entry flips to `retired`, drops out of the catalog,
|
||||
and `GET /api/rfcs/<slug>` 404s for them (they are not a site owner)."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, entry as entry_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"], arbiters=["carol"])
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
|
||||
assert "ohm" in _catalog_slugs(client)
|
||||
|
||||
r = client.post("/api/rfcs/ohm/retire")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["state"] == "retired"
|
||||
|
||||
# Meta entry on main: state retired, body + fields kept.
|
||||
meta = entry_mod.parse(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
)
|
||||
assert meta.state == "retired"
|
||||
assert "carol" in meta.owners
|
||||
|
||||
# Cache flipped; gone from the catalog.
|
||||
cached = db.conn().execute(
|
||||
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "retired"
|
||||
assert "ohm" not in _catalog_slugs(client)
|
||||
|
||||
# The RFC owner is NOT a site owner → 404 on the entry read.
|
||||
assert client.get("/api/rfcs/ohm").status_code == 404
|
||||
|
||||
# Audit row records the prior state for un-retire.
|
||||
row = db.conn().execute(
|
||||
"SELECT details FROM actions WHERE rfc_slug='ohm' AND action_kind='retire' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert _json.loads(row["details"])["prior_state"] == "super-draft"
|
||||
|
||||
|
||||
def test_site_owner_sees_retired_entry_but_admin_and_others_404(app_with_fake_gitea):
|
||||
"""`GET /api/rfcs/<slug>` for a retired entry: site owner gets 200
|
||||
(so the un-retire UI has a surface); an admin, a non-owner contributor,
|
||||
and an anonymous viewer all get 404."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
provision_user_row(user_id=3, login="dave", role="admin")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
assert client.post("/api/rfcs/ohm/retire").status_code == 200
|
||||
|
||||
# Site owner: 200.
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/rfcs/ohm")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["state"] == "retired"
|
||||
|
||||
# Admin (not site owner): 404.
|
||||
sign_in_as(client, user_id=3, gitea_login="dave",
|
||||
display_name="Dave", role="admin")
|
||||
assert client.get("/api/rfcs/ohm").status_code == 404
|
||||
|
||||
# Non-owner contributor: 404.
|
||||
provision_user_row(user_id=4, login="erin", role="contributor")
|
||||
sign_in_as(client, user_id=4, gitea_login="erin",
|
||||
display_name="Erin", role="contributor")
|
||||
assert client.get("/api/rfcs/ohm").status_code == 404
|
||||
|
||||
|
||||
def test_admin_cannot_retire(app_with_fake_gitea):
|
||||
"""§3.1: retire authority excludes app admins. An admin who is not an
|
||||
RFC owner gets 403 — the one lifecycle action where admin authority
|
||||
does not apply."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=3, login="dave", role="admin")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
sign_in_as(client, user_id=3, gitea_login="dave",
|
||||
display_name="Dave", role="admin")
|
||||
r = client.post("/api/rfcs/ohm/retire")
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
|
||||
def test_non_owner_contributor_cannot_retire(app_with_fake_gitea):
|
||||
"""A signed-in contributor who is not an RFC owner gets 403."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=4, login="erin", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
sign_in_as(client, user_id=4, gitea_login="erin",
|
||||
display_name="Erin", role="contributor")
|
||||
assert client.post("/api/rfcs/ohm/retire").status_code == 403
|
||||
|
||||
|
||||
def test_site_owner_can_retire_active_and_unretire_restores_active_with_id(app_with_fake_gitea):
|
||||
"""Round-trip on an active RFC: graduate (with a number) → retire →
|
||||
un-retire. The integer id survives, and un-retire restores `active`."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, entry as entry_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"], arbiters=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner", email="ben@test")
|
||||
|
||||
# Graduate with a number.
|
||||
assert client.post("/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0042", "owners": ["ben"]}).status_code == 200
|
||||
|
||||
# Retire (site owner).
|
||||
assert client.post("/api/rfcs/ohm/retire").json()["state"] == "retired"
|
||||
assert "ohm" not in _catalog_slugs(client)
|
||||
|
||||
# Un-retire (site owner) restores active, id intact.
|
||||
r = client.post("/api/rfcs/ohm/unretire")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["state"] == "active"
|
||||
|
||||
meta = entry_mod.parse(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
)
|
||||
assert meta.state == "active"
|
||||
assert meta.id == "RFC-0042"
|
||||
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "active"
|
||||
assert cached["rfc_id"] == "RFC-0042"
|
||||
assert "ohm" in _catalog_slugs(client)
|
||||
|
||||
|
||||
def test_rfc_owner_cannot_unretire(app_with_fake_gitea):
|
||||
"""Un-retire is site-owner-only: an RFC owner who could retire cannot
|
||||
bring it back (the soft-delete is recoverable only by the operator)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
assert client.post("/api/rfcs/ohm/retire").status_code == 200
|
||||
# Same RFC owner tries to un-retire → 403.
|
||||
assert client.post("/api/rfcs/ohm/unretire").status_code == 403
|
||||
|
||||
|
||||
def test_admin_retired_list_is_site_owner_only(app_with_fake_gitea):
|
||||
"""GET /api/admin/retired-rfcs lists retired entries for a site owner;
|
||||
an admin (who lacks un-retire authority) gets 403."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
provision_user_row(user_id=3, login="dave", role="admin")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
assert client.post("/api/rfcs/ohm/retire").status_code == 200
|
||||
|
||||
# Admin: 403.
|
||||
sign_in_as(client, user_id=3, gitea_login="dave",
|
||||
display_name="Dave", role="admin")
|
||||
assert client.get("/api/admin/retired-rfcs").status_code == 403
|
||||
|
||||
# Site owner: 200, sees ohm with its restore target.
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/admin/retired-rfcs")
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
ohm = next(i for i in items if i["slug"] == "ohm")
|
||||
assert ohm["restores_to"] == "super-draft"
|
||||
|
||||
|
||||
def test_retired_entry_refuses_discussion_reads(app_with_fake_gitea):
|
||||
"""A retired entry refuses content reads of every shape (§13.7) — the
|
||||
discussion/branch surfaces 404/409 rather than serve a soft-deleted RFC."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
assert client.post("/api/rfcs/ohm/retire").status_code == 200
|
||||
|
||||
# The /main branch surface no longer serves it.
|
||||
assert client.get("/api/rfcs/ohm/main").status_code in (404, 409)
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Roadmap #28 Part 1 — auto-link RFC references in PR text + comments.
|
||||
|
||||
Two layers:
|
||||
|
||||
* Unit tests over the pure scanner (`rfc_links.segment_text` /
|
||||
`_keys_for` / `LinkIndex`) — the matching rules and their
|
||||
false-positive guards, no DB.
|
||||
* End-to-end tests that the PR description, PR review comments, and
|
||||
PR-less discussion comments all surface `*_segments` enriched against
|
||||
the live accepted-RFC corpus, with self-references suppressed.
|
||||
|
||||
Reuses the FakeGitea + session helpers from test_propose_vertical.py and
|
||||
the active-RFC seed from test_rfc_view_vertical.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app import rfc_links
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
grant_rfc_collaborator,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
|
||||
from test_pr_flow_vertical import _cut_branch_and_accept_change
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit — the pure scanner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _idx(*terms):
|
||||
"""Build a LinkIndex from raw (key, slug, title) tuples (keys lower)."""
|
||||
return rfc_links.LinkIndex(list(terms))
|
||||
|
||||
|
||||
def test_empty_text_is_single_empty_segment():
|
||||
assert rfc_links.segment_text("", []) == [{"type": "text", "text": ""}]
|
||||
assert rfc_links.segment_text(None, []) == [{"type": "text", "text": ""}]
|
||||
|
||||
|
||||
def test_no_terms_returns_plain_text():
|
||||
out = rfc_links.segment_text("hello world", [])
|
||||
assert out == [{"type": "text", "text": "hello world"}]
|
||||
|
||||
|
||||
def test_multiword_title_links_and_preserves_casing():
|
||||
idx = _idx(("open human model", "open-human-model", "Open Human Model"))
|
||||
out = idx.segment("See the Open Human Model for details.")
|
||||
assert out == [
|
||||
{"type": "text", "text": "See the "},
|
||||
{"type": "rfc", "slug": "open-human-model", "label": "Open Human Model",
|
||||
"title": "Open Human Model"},
|
||||
{"type": "text", "text": " for details."},
|
||||
]
|
||||
|
||||
|
||||
def test_match_is_case_insensitive():
|
||||
idx = _idx(("open human model", "open-human-model", "Open Human Model"))
|
||||
out = idx.segment("see the OPEN HUMAN MODEL")
|
||||
assert out[-1] == {"type": "rfc", "slug": "open-human-model",
|
||||
"label": "OPEN HUMAN MODEL", "title": "Open Human Model"}
|
||||
|
||||
|
||||
def test_word_boundary_prevents_substring_match():
|
||||
# "harm" must not match inside "charming" / "harmless".
|
||||
idx = _idx(("rfc-0001", "open-human-model", "Open Human Model"))
|
||||
out = idx.segment("a charming rfc-00012 not real")
|
||||
# rfc-0001 is a prefix of rfc-00012 but the trailing '2' is a word char,
|
||||
# so no match — the whole string stays plain text.
|
||||
assert out == [{"type": "text", "text": "a charming rfc-00012 not real"}]
|
||||
|
||||
|
||||
def test_rfc_id_token_links():
|
||||
idx = _idx(("rfc-0001", "open-human-model", "Open Human Model"))
|
||||
out = idx.segment("as established in RFC-0001.")
|
||||
assert out[1] == {"type": "rfc", "slug": "open-human-model",
|
||||
"label": "RFC-0001", "title": "Open Human Model"}
|
||||
|
||||
|
||||
def test_longest_match_wins():
|
||||
# A bare "Open" term and the full title both present; the full title
|
||||
# (longer) must win at the position.
|
||||
idx = _idx(
|
||||
("open", "open", "Open"),
|
||||
("open human model", "open-human-model", "Open Human Model"),
|
||||
)
|
||||
out = idx.segment("the Open Human Model")
|
||||
assert out[-1]["slug"] == "open-human-model"
|
||||
assert out[-1]["label"] == "Open Human Model"
|
||||
|
||||
|
||||
def test_pending_term_emits_contribute_segment():
|
||||
# Part 3: a super-draft match is an `rfc-pending` segment carrying the
|
||||
# owner display name, not a plain link.
|
||||
idx = rfc_links.LinkIndex([
|
||||
rfc_links.Term(key="open human model", kind="pending",
|
||||
slug="open-human-model", title="Open Human Model", owner="Alice"),
|
||||
])
|
||||
out = idx.segment("see Open Human Model please")
|
||||
assert out[1] == {
|
||||
"type": "rfc-pending", "slug": "open-human-model",
|
||||
"label": "Open Human Model", "title": "Open Human Model", "owner": "Alice",
|
||||
}
|
||||
|
||||
|
||||
def test_candidate_term_emits_create_segment():
|
||||
# Part 2: a candidate term carries its canonical spelling for the
|
||||
# propose pre-fill; no slug (no RFC exists yet).
|
||||
idx = rfc_links.LinkIndex([
|
||||
rfc_links.Term(key="memory model", kind="candidate", term="Memory Model"),
|
||||
])
|
||||
out = idx.segment("the memory model is unspecified")
|
||||
assert out[1] == {"type": "rfc-candidate", "label": "memory model", "term": "Memory Model"}
|
||||
|
||||
|
||||
def test_kind_precedence_active_beats_pending_beats_candidate():
|
||||
# All three buckets contribute the same key; the highest-precedence
|
||||
# kind (active) must win at the position.
|
||||
key = "open human model"
|
||||
idx = rfc_links.LinkIndex([
|
||||
rfc_links.Term(key=key, kind="candidate", term="Open Human Model"),
|
||||
rfc_links.Term(key=key, kind="pending", slug="ohm-draft", title="Open Human Model", owner="A"),
|
||||
rfc_links.Term(key=key, kind="active", slug="open-human-model", title="Open Human Model"),
|
||||
])
|
||||
out = idx.segment("the Open Human Model")
|
||||
assert out[-1]["type"] == "rfc"
|
||||
assert out[-1]["slug"] == "open-human-model"
|
||||
|
||||
|
||||
def test_keys_for_gating():
|
||||
keys = lambda **kw: set(rfc_links._keys_for(**kw))
|
||||
# rfc_id always contributes.
|
||||
assert "rfc-0001" in keys(slug="x", title="X", rfc_id="RFC-0001")
|
||||
# multi-word title contributes; single common word does NOT.
|
||||
assert "open human model" in keys(slug="ohm", title="Open Human Model", rfc_id=None)
|
||||
assert keys(slug="human", title="Human", rfc_id=None) == set()
|
||||
# hyphenated slug contributes; single-token slug does NOT.
|
||||
assert "open-human-model" in keys(slug="open-human-model", title="X", rfc_id=None)
|
||||
assert "ohm" not in keys(slug="ohm", title="OHM", rfc_id=None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end — enrichment surfaces on the read paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _open_pr_on(client, fake, *, host_slug: str, description: str):
|
||||
"""Seed branch + accepted change on host_slug and open a PR. Returns
|
||||
the pr_number."""
|
||||
# `original` must exist verbatim in SEED_BODY or the accept is "stale".
|
||||
branch, _ = _cut_branch_and_accept_change(
|
||||
client, fake, slug=host_slug,
|
||||
original="It defines consent, trait, and agency in compatible terms.",
|
||||
proposed="It defines consent, trait, harm, and agency in compatible terms.",
|
||||
)
|
||||
r = client.post(
|
||||
f"/api/rfcs/{host_slug}/branches/{branch}/open-pr",
|
||||
json={"title": "A change", "description": description},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["pr_number"]
|
||||
|
||||
|
||||
def _rfc_segments(segments):
|
||||
return [s for s in segments if s["type"] == "rfc"]
|
||||
|
||||
|
||||
def test_pr_description_autolinks_other_rfc(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")
|
||||
# Two accepted RFCs: a host for the PR + a referenceable target.
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
pr_number = _open_pr_on(
|
||||
client, fake, host_slug="ohm",
|
||||
description="This builds on the Open Human Model definition.",
|
||||
)
|
||||
r = client.get(f"/api/rfcs/ohm/prs/{pr_number}")
|
||||
assert r.status_code == 200, r.text
|
||||
pr = r.json()
|
||||
links = _rfc_segments(pr["description_segments"])
|
||||
assert len(links) == 1
|
||||
assert links[0]["slug"] == "open-human-model"
|
||||
assert links[0]["label"] == "Open Human Model"
|
||||
# The plain text is still present for non-segment callers (the bot
|
||||
# appends a §6.5 On-behalf-of trailer, so this is a containment check).
|
||||
assert "This builds on the Open Human Model definition." in pr["description"]
|
||||
|
||||
|
||||
def test_pr_review_comment_autolinked(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")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
pr_number = _open_pr_on(client, fake, host_slug="ohm", description="plain.")
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/prs/{pr_number}/review",
|
||||
json={"text": "See Open Human Model and RFC-0001.", "anchor_payload": {}},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
|
||||
all_msgs = [m for msgs in pr["messages_by_thread"].values() for m in msgs]
|
||||
review_msgs = [m for m in all_msgs if "Open Human Model" in (m["text"] or "")]
|
||||
assert review_msgs, "review comment not found in payload"
|
||||
links = _rfc_segments(review_msgs[0]["text_segments"])
|
||||
# Both "Open Human Model" (title) and "RFC-0001" (id) point to the
|
||||
# one referenceable RFC.
|
||||
assert {s["slug"] for s in links} == {"open-human-model"}
|
||||
assert {s["label"] for s in links} == {"Open Human Model", "RFC-0001"}
|
||||
|
||||
|
||||
def test_discussion_comment_autolinked(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")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
|
||||
# alice is the seeded owner of ohm (owners=["alice"]); grant the
|
||||
# per-RFC collaborator row explicitly so the #12 discuss gate passes.
|
||||
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
|
||||
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": "Compare with the Open Human Model."},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
thread_id = r.json()["thread_id"]
|
||||
|
||||
r = client.get(f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages")
|
||||
assert r.status_code == 200, r.text
|
||||
msgs = r.json()["messages"]
|
||||
assert msgs and "text_segments" in msgs[0]
|
||||
links = _rfc_segments(msgs[0]["text_segments"])
|
||||
assert len(links) == 1
|
||||
assert links[0]["slug"] == "open-human-model"
|
||||
|
||||
|
||||
def test_self_reference_not_linked(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")
|
||||
# The host RFC has a multi-word title, so absent exclude_slug it
|
||||
# WOULD self-link. exclude_slug must suppress it.
|
||||
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
pr_number = _open_pr_on(
|
||||
client, fake, host_slug="open-human-model",
|
||||
description="Refines the Open Human Model definition.",
|
||||
)
|
||||
pr = client.get(f"/api/rfcs/open-human-model/prs/{pr_number}").json()
|
||||
assert _rfc_segments(pr["description_segments"]) == []
|
||||
@@ -55,13 +55,19 @@ def _outbound_otc_envelopes(to_address: str | None = None) -> list[dict]:
|
||||
|
||||
|
||||
def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | None = None):
|
||||
"""Replace `httpx.post` inside `app.turnstile` with a stub that
|
||||
"""Replace `turnstile._siteverify_post` with an async stub that
|
||||
returns the requested success shape. The stub does not touch the
|
||||
real CloudFlare endpoint and never sees a real secret.
|
||||
|
||||
I4 (security-audit-0026): the siteverify call is now awaited on an
|
||||
`httpx.AsyncClient`, isolated behind the `_siteverify_post` seam.
|
||||
Patching that narrow function (rather than the shared
|
||||
`httpx.AsyncClient`, which gitea/docs also construct) keeps app boot
|
||||
intact.
|
||||
"""
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, *, data=None, timeout=None, **kwargs):
|
||||
async def fake_post(url, data):
|
||||
captured["url"] = url
|
||||
captured["data"] = data
|
||||
body = {"success": bool(success)}
|
||||
@@ -70,7 +76,7 @@ def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | No
|
||||
return SimpleNamespace(json=lambda: body)
|
||||
|
||||
from app import turnstile as turnstile_mod
|
||||
monkeypatch.setattr(turnstile_mod.httpx, "post", fake_post)
|
||||
monkeypatch.setattr(turnstile_mod, "_siteverify_post", fake_post)
|
||||
return captured
|
||||
|
||||
|
||||
@@ -170,14 +176,15 @@ def test_otc_request_admits_when_secret_unset_and_not_required(app_with_fake_git
|
||||
|
||||
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
|
||||
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
|
||||
# The httpx.post inside turnstile must not be called in this path —
|
||||
# patch it to a sentinel that explodes if it ever runs.
|
||||
# The siteverify call inside turnstile must not be made in this path —
|
||||
# patch the seam to a sentinel that explodes if it ever runs
|
||||
# (I4: the seam is now `_siteverify_post`, not module-level httpx.post).
|
||||
from app import turnstile as turnstile_mod
|
||||
|
||||
def must_not_be_called(*a, **kw):
|
||||
async def must_not_be_called(*a, **kw):
|
||||
raise AssertionError("siteverify should not run when no secret is configured")
|
||||
|
||||
monkeypatch.setattr(turnstile_mod.httpx, "post", must_not_be_called)
|
||||
monkeypatch.setattr(turnstile_mod, "_siteverify_post", must_not_be_called)
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
@@ -218,3 +225,27 @@ def test_otc_request_refuses_when_required_but_secret_unset(app_with_fake_gitea,
|
||||
)
|
||||
assert r.status_code == 500, r.text
|
||||
assert _outbound_otc_envelopes("alice@example.com") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# I4 (security-audit-0026): verify_token is a coroutine — calling it returns
|
||||
# an awaitable, not a VerifyOutcome. Locks the async contract so a revert to
|
||||
# the synchronous event-loop-blocking shape fails here, not just in the
|
||||
# integration paths.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_verify_token_is_async_and_soft_skips_without_secret(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
from app import turnstile as turnstile_mod
|
||||
|
||||
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
|
||||
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
|
||||
|
||||
coro = turnstile_mod.verify_token("any-token")
|
||||
assert asyncio.iscoroutine(coro), "verify_token must be a coroutine (I4)"
|
||||
outcome = asyncio.run(coro)
|
||||
# No secret + not required → the gate stays open without any network call.
|
||||
assert outcome.ok is True
|
||||
assert outcome.reason == "skipped"
|
||||
|
||||
@@ -73,6 +73,7 @@ def test_config_loads_with_empty_secret_when_bypass_is_set(monkeypatch, tmp_path
|
||||
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
|
||||
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
|
||||
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
|
||||
monkeypatch.setenv("REGISTRY_REPO", "registry")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
|
||||
@@ -92,6 +93,7 @@ def test_config_loads_with_secret_set(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
|
||||
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
|
||||
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
|
||||
monkeypatch.setenv("REGISTRY_REPO", "registry")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
|
||||
|
||||
@@ -20,15 +20,24 @@ The v1 build is complete (8 slices shipped, 125 passing integration tests). New
|
||||
|
||||
The RFC app runs on its own dedicated GCP VM in a separate project from the Gitea VM. The two coexist under `wiggleverse.org` but are otherwise unrelated infrastructure.
|
||||
|
||||
> **⚠️ Infrastructure was realigned (GCP name-alignment, ~2026-05).** The
|
||||
> GCP project, VM, install path, system user, and systemd unit were all
|
||||
> renamed, the static IP changed, and SSH is now **IAP-only** (direct
|
||||
> port-22 connections time out). The tables below are the current truth;
|
||||
> if you find an older clone of this doc naming `wiggleverse-rfc` /
|
||||
> `rfc-app` / `/opt/rfc-app` / `34.132.29.41`, it predates the alignment.
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| GCP project | `wiggleverse-rfc` |
|
||||
| VM name | `rfc-app` |
|
||||
| GCP project | `wiggleverse-ohm` |
|
||||
| VM name | `ohm-rfc-app` |
|
||||
| VM type | e2-small |
|
||||
| Zone | us-central1-a |
|
||||
| OS | Debian 12 (bookworm) |
|
||||
| Static IP | 34.132.29.41 |
|
||||
| Linux user (OS Login) | `benstull` |
|
||||
| External IP | `136.116.40.66` |
|
||||
| SSH | **IAP-only** — `gcloud compute ssh … --tunnel-through-iap` (port 22 is firewalled off the public internet) |
|
||||
| OS Login SSH user | `ben_wiggleverse_org` (auto-derived; you don't type it) |
|
||||
| App system user | `ohm-rfc-app` |
|
||||
|
||||
For reference, the separate Gitea VM is `wiggleverse` project / `gitea` VM / 34.55.46.221.
|
||||
|
||||
@@ -36,7 +45,7 @@ For reference, the separate Gitea VM is `wiggleverse` project / `gitea` VM / 34.
|
||||
|
||||
| Record | Type | Value | Proxy |
|
||||
|--------|------|-------|-------|
|
||||
| `ohm.wiggleverse.org` | A | 34.132.29.41 | DNS-only (gray cloud) |
|
||||
| `ohm.wiggleverse.org` | A | `136.116.40.66` | DNS-only (gray cloud) |
|
||||
| `_dmarc.wiggleverse.org` | TXT | `v=DMARC1; p=none; rua=mailto:ben@wiggleverse.org` | n/a |
|
||||
|
||||
> 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.
|
||||
@@ -48,24 +57,25 @@ SPF (`v=spf1 include:_spf.google.com ~all`) and DKIM (`google._domainkey`) for `
|
||||
| Component | Details |
|
||||
|-----------|---------|
|
||||
| Backend | Python 3.11, FastAPI, uvicorn (single process) |
|
||||
| Database | SQLite in WAL mode at `/opt/rfc-app/backend/data/rfc-app.db` |
|
||||
| Database | SQLite in WAL mode at `/opt/ohm-rfc-app/backend/data/rfc-app.db` |
|
||||
| Frontend | React 19, Vite 8, Tiptap 3, React Router 7 |
|
||||
| Web server | nginx — serves `frontend/dist/` as static SPA, proxies `/api/` and `/auth/` to uvicorn on `127.0.0.1:8000` |
|
||||
| Process manager | systemd unit `rfc-app.service`, runs as `rfc-app` system user |
|
||||
| Process manager | systemd unit `ohm-rfc-app.service`, runs as `ohm-rfc-app` system user |
|
||||
| TLS | Let's Encrypt via certbot |
|
||||
| Git backend | Gitea at `git.wiggleverse.org`, bot service account `rfc-bot` |
|
||||
| Content Gitea (the bot's writes) | Gitea at `git.wiggleverse.org`, org `wiggleverse`, meta repo `ohm-content` (`wiggleverse/ohm-content`), bot service account `rfc-bot` |
|
||||
| Code-deploy source (the VM's git origin) | **`https://git.benstull.org/benstull/rfc-app.git`** — a *different* Gitea from the content one. See the two-remote note under "Deploying a New Version." |
|
||||
| Email | Google Workspace SMTP relay (`smtp-relay.gmail.com:587`), AUTH'd as `ben@wiggleverse.org`, From `notifications@wiggleverse.org` |
|
||||
|
||||
### Key Paths on the VM
|
||||
|
||||
| Path | Contents |
|
||||
|------|---------|
|
||||
| `/opt/rfc-app/` | App root (owned by `rfc-app` user) |
|
||||
| `/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) |
|
||||
| `/opt/ohm-rfc-app/` | App root (owned by `ohm-rfc-app` user) |
|
||||
| `/opt/ohm-rfc-app/backend/.env` | All secrets and config (mode 0600) |
|
||||
| `/opt/ohm-rfc-app/backend/data/rfc-app.db` | SQLite database |
|
||||
| `/opt/ohm-rfc-app/frontend/dist/` | Built React SPA (served by nginx) |
|
||||
| `/etc/nginx/sites-available/ohm.wiggleverse.org` | nginx vhost config |
|
||||
| `/etc/systemd/system/rfc-app.service` | systemd unit |
|
||||
| `/etc/systemd/system/ohm-rfc-app.service` | systemd unit |
|
||||
|
||||
---
|
||||
|
||||
@@ -91,7 +101,7 @@ 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://ohm.wiggleverse.org/api/webhooks/gitea`.
|
||||
`wiggleverse/ohm-content` (the `META_REPO` value is `ohm-content`) — 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
|
||||
|
||||
@@ -167,23 +177,52 @@ HYGIENE_TICK_SECONDS=3600
|
||||
|
||||
## Deploying a New Version
|
||||
|
||||
SSH into the VM:
|
||||
> **⚠️ Two-remote step — do this FIRST.** The framework's release flow
|
||||
> (branches, PRs, version tags) happens on **`git.wiggleverse.org`**
|
||||
> (`ben.stull/rfc-app`). But the VM's git origin is a *separate* Gitea,
|
||||
> **`git.benstull.org/benstull/rfc-app`**, which does **not** auto-mirror
|
||||
> from the release Gitea. So after a release is merged + tagged on
|
||||
> `git.wiggleverse.org`, you must push `main` and the new tag to the
|
||||
> `benstull` remote before the VM can fetch them:
|
||||
> ```bash
|
||||
> # from your local rfc-app clone, on main at the merged release tip:
|
||||
> git push benstull main vX.Y.Z # benstull = git@git.benstull.org:benstull/rfc-app.git
|
||||
> ```
|
||||
> If `git ls-remote benstull vX.Y.Z` comes back empty, the VM cannot see
|
||||
> the release yet — push it first. (Wiring the two Gitea instances to
|
||||
> mirror would remove this step; until then it's manual.)
|
||||
|
||||
SSH into the VM (IAP-only — the `--tunnel-through-iap` flag is required):
|
||||
```bash
|
||||
gcloud compute ssh rfc-app --zone=us-central1-a --project=wiggleverse-rfc
|
||||
gcloud compute ssh ohm-rfc-app --zone=us-central1-a --project=wiggleverse-ohm --tunnel-through-iap
|
||||
```
|
||||
|
||||
Pull the latest code, reinstall deps, restart:
|
||||
Fetch + check out the release tag (the VM tracks a **detached** tag, not
|
||||
a branch), then restart. Backend deps only need reinstalling when
|
||||
`requirements.txt` changed:
|
||||
```bash
|
||||
sudo -u rfc-app git -C /opt/rfc-app pull
|
||||
sudo -u rfc-app /opt/rfc-app/backend/.venv/bin/pip install \
|
||||
-r /opt/rfc-app/backend/requirements.txt
|
||||
sudo systemctl restart rfc-app
|
||||
sudo -u ohm-rfc-app git -C /opt/ohm-rfc-app fetch origin --tags
|
||||
sudo -u ohm-rfc-app git -C /opt/ohm-rfc-app checkout vX.Y.Z
|
||||
# only if backend deps changed:
|
||||
sudo -u ohm-rfc-app /opt/ohm-rfc-app/backend/.venv/bin/pip install \
|
||||
-r /opt/ohm-rfc-app/backend/requirements.txt
|
||||
sudo systemctl restart ohm-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 ci
|
||||
sudo -u rfc-app npm run build
|
||||
cd /opt/ohm-rfc-app/frontend && sudo -u ohm-rfc-app npm ci
|
||||
sudo -u ohm-rfc-app npm run build
|
||||
```
|
||||
|
||||
Smoke test (asset hashes change on every rebuild, so a match proves the
|
||||
new bundle is live):
|
||||
```bash
|
||||
# public:
|
||||
curl -s -o /dev/null -w '%{http_code} %{remote_ip}\n' https://ohm.wiggleverse.org/
|
||||
curl -s https://ohm.wiggleverse.org/ | grep -oE 'assets/index-[A-Za-z0-9_-]+\.(js|css)'
|
||||
# backend startup line, on the VM:
|
||||
sudo journalctl -u ohm-rfc-app -n 20 --no-pager | grep 'RFC app started'
|
||||
```
|
||||
|
||||
`npm ci` installs strictly from the committed `package-lock.json` and
|
||||
@@ -192,7 +231,7 @@ to rewrite the lockfile in place (notably stripping `libc` fields on
|
||||
optional rollup native packages), which then conflicts with `git
|
||||
checkout <tag>` on the next deploy.
|
||||
|
||||
The output lands in `/opt/rfc-app/frontend/dist/` owned by `rfc-app` — nginx serves it directly, no copy step needed.
|
||||
The output lands in `/opt/ohm-rfc-app/frontend/dist/` owned by `ohm-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.)
|
||||
|
||||
@@ -202,6 +241,15 @@ Schema migrations run automatically on restart (append-only, safe to re-run).
|
||||
|
||||
## First-Time Deployment (new server)
|
||||
|
||||
> **Note on names:** the command blocks in this section predate the GCP
|
||||
> name-alignment and still spell the pre-alignment project/VM/path/user
|
||||
> (`wiggleverse-rfc` / `rfc-app` / `/opt/rfc-app` / `rfc-app` /
|
||||
> `34.132.29.41`). They're kept as the structural reference. When standing
|
||||
> up a box today, substitute the current values from the Host/Paths tables
|
||||
> at the top: project `wiggleverse-ohm`, VM `ohm-rfc-app`, install path
|
||||
> `/opt/ohm-rfc-app`, system user `ohm-rfc-app`, service `ohm-rfc-app`, IP
|
||||
> `136.116.40.66`, and SSH via `--tunnel-through-iap`.
|
||||
|
||||
### 1. Add DNS record
|
||||
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.
|
||||
|
||||
@@ -307,7 +355,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 `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.
|
||||
> 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 (`ohm-rfc-app` in the `wiggleverse-ohm` 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
|
||||
@@ -323,11 +371,12 @@ Paste the following into a new Claude session to continue development:
|
||||
> - 10 append-only schema migrations in `backend/migrations/`
|
||||
>
|
||||
> **Deployment:**
|
||||
> - SSH: `gcloud compute ssh rfc-app --zone=us-central1-a --project=wiggleverse-rfc`
|
||||
> - Code at `/opt/rfc-app/` on the VM, owned by `rfc-app` system user
|
||||
> - `.env` at `/opt/rfc-app/backend/.env` (mode 0600)
|
||||
> - Frontend built **on the VM** (Node 20 is installed there) with `npm run build` directly into `/opt/rfc-app/frontend/dist/`
|
||||
> - Restart to deploy: `sudo systemctl restart rfc-app`
|
||||
> - SSH (IAP-only): `gcloud compute ssh ohm-rfc-app --zone=us-central1-a --project=wiggleverse-ohm --tunnel-through-iap`
|
||||
> - Code at `/opt/ohm-rfc-app/` on the VM, owned by `ohm-rfc-app` system user; tracks a detached release **tag**
|
||||
> - The VM's git origin is `git.benstull.org/benstull/rfc-app` — a *different* Gitea from the release one (`git.wiggleverse.org`); push `main` + the new tag to the `benstull` remote before deploying
|
||||
> - `.env` at `/opt/ohm-rfc-app/backend/.env` (mode 0600)
|
||||
> - Frontend built **on the VM** (Node 20 is installed there) with `npm run build` directly into `/opt/ohm-rfc-app/frontend/dist/`
|
||||
> - Restart to deploy: `sudo systemctl restart ohm-rfc-app`
|
||||
> - Migrations run automatically on startup
|
||||
>
|
||||
> **Source is at `~/git/rfc-app/`.**
|
||||
|
||||
+45
-10
@@ -1,9 +1,26 @@
|
||||
# Runbook
|
||||
|
||||
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.
|
||||
Single-host deployment of the RFC app at `ohm.wiggleverse.org`. The shape
|
||||
matches §4.2: one process, one SQLite file, no separate worker.
|
||||
|
||||
> **⚠️ Current deployment names (post GCP name-alignment, ~2026-05).**
|
||||
> The command blocks below were written with the original names and use
|
||||
> `/opt/rfc-app`, system user `rfc-app`, and service `rfc-app`. The live
|
||||
> OHM box uses the realigned names — substitute throughout:
|
||||
>
|
||||
> | Was | Now |
|
||||
> | --- | --- |
|
||||
> | GCP project `wiggleverse-rfc` | `wiggleverse-ohm` |
|
||||
> | VM `rfc-app` | `ohm-rfc-app` |
|
||||
> | install path `/opt/rfc-app` | `/opt/ohm-rfc-app` |
|
||||
> | system user `rfc-app` | `ohm-rfc-app` |
|
||||
> | service `rfc-app.service` | `ohm-rfc-app.service` |
|
||||
> | SSH | IAP-only: `gcloud compute ssh ohm-rfc-app --zone=us-central1-a --project=wiggleverse-ohm --tunnel-through-iap` |
|
||||
> | meta repo `wiggleverse/meta` | `wiggleverse/ohm-content` |
|
||||
>
|
||||
> The VM's git origin is **`git.benstull.org/benstull/rfc-app`** — a
|
||||
> *different* Gitea from the release one (`git.wiggleverse.org`). See §2.5.
|
||||
> The full current infra table lives in `DEPLOY-NEW-SESSION-PROMPT.md`.
|
||||
|
||||
Bring-up order: host prep → Gitea side (bot, OAuth, meta repo) → app side
|
||||
(code, venv, build, .env) → web server side (nginx, certbot) → systemd →
|
||||
@@ -345,16 +362,34 @@ pinned = 1 WHERE rfc_slug = ? AND branch_name = ?`).
|
||||
|
||||
### 2.5 Updating after a push
|
||||
|
||||
The live OHM box uses the realigned names (see the callout at the top)
|
||||
and deploys by checking out a **release tag** (detached HEAD), not by
|
||||
pulling a branch. Its git origin is `git.benstull.org/benstull/rfc-app`,
|
||||
which does **not** auto-mirror from the release Gitea
|
||||
(`git.wiggleverse.org`) — so first push `main` + the new tag there:
|
||||
|
||||
```sh
|
||||
sudo -u rfc-app git -C /opt/rfc-app pull
|
||||
sudo -u rfc-app /opt/rfc-app/backend/.venv/bin/pip install \
|
||||
-r /opt/rfc-app/backend/requirements.txt
|
||||
# Rebuild the frontend locally and rsync dist/ as in 1.3.2.
|
||||
sudo systemctl restart rfc-app
|
||||
# from your local rfc-app clone, on main at the merged release tip:
|
||||
git push benstull main vX.Y.Z # benstull = git@git.benstull.org:benstull/rfc-app.git
|
||||
```
|
||||
|
||||
Then on the VM (SSH is IAP-only):
|
||||
|
||||
```sh
|
||||
gcloud compute ssh ohm-rfc-app --zone=us-central1-a --project=wiggleverse-ohm --tunnel-through-iap
|
||||
sudo -u ohm-rfc-app git -C /opt/ohm-rfc-app fetch origin --tags
|
||||
sudo -u ohm-rfc-app git -C /opt/ohm-rfc-app checkout vX.Y.Z
|
||||
# only when backend deps changed:
|
||||
sudo -u ohm-rfc-app /opt/ohm-rfc-app/backend/.venv/bin/pip install \
|
||||
-r /opt/ohm-rfc-app/backend/requirements.txt
|
||||
# frontend changes: build on the VM (Node 20+ is there) — output is served directly by nginx:
|
||||
cd /opt/ohm-rfc-app/frontend && sudo -u ohm-rfc-app npm ci && sudo -u ohm-rfc-app npm run build
|
||||
sudo systemctl restart ohm-rfc-app
|
||||
```
|
||||
|
||||
The §5 schema migrations run on startup and are append-only. A restart
|
||||
is the entire deploy.
|
||||
is the entire backend deploy; a frontend-only change is live as soon as
|
||||
the new `dist/` is built (nginx serves it directly).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,15 +8,94 @@
|
||||
# /etc/nginx/sites-enabled/
|
||||
# sudo nginx -t && sudo systemctl reload nginx
|
||||
#
|
||||
# Then add the Let's Encrypt cert:
|
||||
# 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.
|
||||
# TLS: this vhost terminates with the *.wiggleverse.org wildcard cert installed
|
||||
# on the VM (see the ssl_* directives in the 443 block below). This REPLACES the
|
||||
# old per-host certbot cert — the live file certbot previously rewrote on the VM
|
||||
# is superseded once you install this one. Put the cert files in place first:
|
||||
# sudo install -m 600 -o root -g root privkey.pem /etc/ssl/private/wiggleverse-wildcard.key
|
||||
# sudo install -m 644 fullchain.pem /etc/ssl/certs/wiggleverse-wildcard.crt
|
||||
# then `sudo nginx -t && sudo systemctl reload nginx`. Once cut over, retire the
|
||||
# old cert: `sudo certbot delete --cert-name ohm.wiggleverse.org`.
|
||||
#
|
||||
# Cloudflare: with an origin wildcard in place, set SSL/TLS mode to Full (strict)
|
||||
# and flip ohm to the orange cloud (proxied). If this is a Cloudflare Origin CA
|
||||
# cert it ONLY validates behind the proxy — so cut the cert over and proxy in the
|
||||
# same change; do not leave ohm grey-cloud with an Origin CA cert.
|
||||
|
||||
# HTTP → HTTPS redirect. Cloudflare also redirects at the edge once proxied, but
|
||||
# keep the origin honest for direct hits.
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ohm.wiggleverse.org;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name ohm.wiggleverse.org;
|
||||
|
||||
# TLS — *.wiggleverse.org wildcard installed on the VM (see top comment).
|
||||
# These files must exist before `nginx -t` / reload, or nginx fails to start.
|
||||
ssl_certificate /etc/ssl/certs/wiggleverse-wildcard.crt;
|
||||
ssl_certificate_key /etc/ssl/private/wiggleverse-wildcard.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSLwiggle:10m;
|
||||
ssl_session_timeout 1d;
|
||||
|
||||
# NOTE: once proxied behind Cloudflare, $remote_addr is a Cloudflare edge IP.
|
||||
# To restore the real client IP (for logging / Turnstile / rate limits), add a
|
||||
# real_ip block (set_real_ip_from <CF ranges>; real_ip_header CF-Connecting-IP;).
|
||||
# Left out here to avoid baking stale CF ranges into the repo — see follow-up.
|
||||
|
||||
# v0.25.0 security hardening (audit 0026 M2/L8)
|
||||
#
|
||||
# NOTE: these response headers live in the HTTPS (443) server block. They use
|
||||
# `add_header ... always` so they also apply to nginx-generated error
|
||||
# responses (4xx/5xx), not just 200s.
|
||||
#
|
||||
# `server_tokens off` (L8) — suppress the nginx version in the
|
||||
# Server header and on error pages so we don't advertise the
|
||||
# build to scanners.
|
||||
server_tokens off;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Content-Security-Policy (M2). Tuned to what the SPA actually loads:
|
||||
# - default-src 'self': everything not called out below is same-origin.
|
||||
# - script-src 'self' + challenges.cloudflare.com: the only external
|
||||
# <script> tag the app injects is the CloudFlare Turnstile widget
|
||||
# (frontend/src/components/TurnstileWidget.jsx). Amplitude and
|
||||
# mermaid are BUNDLED (dynamic `import()` from node_modules, served
|
||||
# from 'self'), so they need no extra script origin — *.amplitude.com
|
||||
# is listed defensively in case a future SDK build script-injects.
|
||||
# script-src DELIBERATELY OMITS 'unsafe-inline' — no inline <script>
|
||||
# is used, so we keep XSS-via-inline-script blocked.
|
||||
# - style-src 'unsafe-inline' IS REQUIRED by the current build: the
|
||||
# JSX uses inline `style={...}` attributes throughout and mermaid
|
||||
# injects <style> blocks at render time. Removing it would break
|
||||
# layout; tightening this is a future build-side change (nonce/hash).
|
||||
# - img-src 'self' data: https: — markdown/RFC bodies may embed remote
|
||||
# images and data: URIs; svg/mermaid output uses data: too.
|
||||
# - font-src 'self' data: — bundled fonts plus data: webfonts.
|
||||
# - connect-src 'self' + *.amplitude.com + challenges.cloudflare.com:
|
||||
# the app's API/auth/SSE are same-origin (nginx proxy); Amplitude
|
||||
# Analytics + Session Replay (shipped at sampleRate 1) POST to
|
||||
# *.amplitude.com; Turnstile verifies via challenges.cloudflare.com.
|
||||
# - worker-src 'self' blob: — Amplitude Session Replay spins up a
|
||||
# Web Worker from a blob: URL for capture/compression; without
|
||||
# blob: here session replay breaks for every consenting user.
|
||||
# - frame-src challenges.cloudflare.com — the Turnstile challenge
|
||||
# renders in an iframe from that origin.
|
||||
# - frame-ancestors 'none' — clickjacking defense, pairs with
|
||||
# X-Frame-Options DENY for older agents.
|
||||
# - base-uri 'self'; object-src 'none' — lock down <base>/<object>.
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://challenges.cloudflare.com https://*.amplitude.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://*.amplitude.com https://challenges.cloudflare.com; worker-src 'self' blob:; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always;
|
||||
|
||||
# Static SPA assets live in the Vite build output. The systemd unit
|
||||
# runs as user `rfc-app`; make sure nginx (usually `www-data`) can
|
||||
@@ -49,6 +128,28 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# §22.10: the old corpus-root URLs (/rfc/<slug>, /rfc/<slug>/pr/<n>,
|
||||
# /proposals/<n>) are 308-redirected to the project-scoped /p/<default>/…
|
||||
# routes by the backend, so route them to FastAPI rather than serving the
|
||||
# SPA index.html. Must precede the `location /` SPA fallback.
|
||||
location /rfc/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /proposals/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# SPA fallback — any non-asset path falls back to index.html so
|
||||
# React Router can take over.
|
||||
location / {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Preview container entrypoint (flotilla SPEC §15).
|
||||
#
|
||||
# 1. Render nginx against Cloud Run's $PORT.
|
||||
# 2. Start the single-process uvicorn (which runs DB migrations on startup via
|
||||
# the app lifespan — §4.2: one process, one SQLite file, never scale workers).
|
||||
# 3. On a brand-new DB, apply the SYNTHETIC seed fixture so the preview shows
|
||||
# realistic content with no real user PII (§15).
|
||||
# 4. Hand the foreground to nginx.
|
||||
#
|
||||
# This is preview-only plumbing — production runs uvicorn under systemd and nginx
|
||||
# under the OS, never this script.
|
||||
set -euo pipefail
|
||||
|
||||
: "${PORT:=8080}"
|
||||
: "${DATABASE_PATH:=/opt/rfc-app/backend/data/rfc-app.db}"
|
||||
export PORT DATABASE_PATH
|
||||
|
||||
mkdir -p "$(dirname "$DATABASE_PATH")"
|
||||
|
||||
# Render the nginx config with the injected port.
|
||||
envsubst '${PORT}' \
|
||||
< /opt/rfc-app/deploy/preview/nginx.conf.template \
|
||||
> /etc/nginx/nginx.conf
|
||||
|
||||
fresh=0
|
||||
[ -f "$DATABASE_PATH" ] || fresh=1
|
||||
|
||||
# Start uvicorn (backgrounded); the app's lifespan runs migrations on boot.
|
||||
cd /opt/rfc-app/backend
|
||||
uvicorn app.main:app --host 127.0.0.1 --port 8000 &
|
||||
UVICORN_PID=$!
|
||||
|
||||
# Seed synthetic data once the schema exists (only on a fresh DB).
|
||||
if [ "$fresh" = "1" ]; then
|
||||
for _ in $(seq 1 30); do
|
||||
if [ -f "$DATABASE_PATH" ] \
|
||||
&& sqlite3 "$DATABASE_PATH" "SELECT 1 FROM schema_migrations LIMIT 1;" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if sqlite3 "$DATABASE_PATH" < /opt/rfc-app/deploy/preview/seed.sql; then
|
||||
echo "[preview-entrypoint] applied synthetic seed" >&2
|
||||
else
|
||||
# Best-effort: a seed that drifts from the schema must not block the
|
||||
# preview from booting (it still serves /api/health + an empty app).
|
||||
echo "[preview-entrypoint] WARNING: synthetic seed failed (schema drift?) — continuing" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# nginx in the foreground becomes the container's main process. (uvicorn is a
|
||||
# reparented child; for an ephemeral preview the hard stop on instance teardown
|
||||
# is fine — a process supervisor is a follow-up if graceful drain matters.)
|
||||
exec nginx -g 'daemon off;'
|
||||
@@ -0,0 +1,64 @@
|
||||
# nginx config TEMPLATE for the preview container (flotilla SPEC §15).
|
||||
# `${PORT}` is substituted by deploy/preview/entrypoint.sh (envsubst) with the
|
||||
# port Cloud Run injects. Mirrors the prod vhost
|
||||
# (deploy/nginx/ohm.wiggleverse.org.conf) minus TLS (Cloud Run terminates TLS)
|
||||
# and minus the prod security headers tuned for the public host.
|
||||
|
||||
worker_processes 1;
|
||||
pid /run/nginx.pid;
|
||||
error_log /dev/stderr warn;
|
||||
|
||||
events { worker_connections 1024; }
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
access_log /dev/stdout;
|
||||
sendfile on;
|
||||
server_tokens off;
|
||||
|
||||
server {
|
||||
listen ${PORT};
|
||||
listen [::]:${PORT};
|
||||
server_name _;
|
||||
|
||||
root /opt/rfc-app/frontend/dist;
|
||||
index index.html;
|
||||
|
||||
# API + auth proxy to the single-process uvicorn. SSE chat streams need
|
||||
# buffering off so chunks reach the browser immediately.
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 1h;
|
||||
}
|
||||
|
||||
location /auth/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# SPA fallback so React Router can take over.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|woff2?|ttf|otf|eot|png|jpg|jpeg|gif|svg|ico)$ {
|
||||
try_files $uri =404;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
client_max_body_size 4M;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
# Preview environment shape (flotilla SPEC §15) — TEST values only.
|
||||
#
|
||||
# These are the env vars a per-PR preview boots with. The operator loads them
|
||||
# into flotilla's PREVIEW overlay layer (NOT the base overlay, NOT secrets):
|
||||
#
|
||||
# while IFS='=' read -r k v; do
|
||||
# [ -n "$k" ] && case "$k" in \#*) ;; *) \
|
||||
# ohm-rfc-app-flotilla overlay set ohm-rfc-app "$k=$v" --preview ;; esac
|
||||
# done < deploy/preview/preview.env.example
|
||||
#
|
||||
# CRITICAL (§15 / §3 invariant 1): a preview resolves ZERO real secret bytes.
|
||||
# Every value here is synthetic or a documented public test value. None is a
|
||||
# real credential. Do NOT bind real `secret_refs` for previews.
|
||||
|
||||
# --- Turnstile: Cloudflare's documented ALWAYS-PASS test keys (public) -------
|
||||
# Site key is also baked into the image at build time (Dockerfile ARG); the
|
||||
# secret key here makes server-side verification always succeed.
|
||||
CLOUDFLARE_TURNSTILE_SECRET=1x0000000000000000000000000000000AA
|
||||
VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
||||
|
||||
# --- Email: a catch-all SMTP sink (run Mailpit/Inbucket as a sidecar/service)-
|
||||
# No mail leaves the preview; everything lands in the sink's web UI.
|
||||
SMTP_HOST=mailpit
|
||||
SMTP_PORT=1025
|
||||
SMTP_STARTTLS=0
|
||||
SMTP_PASSWORD=preview-sink-no-auth
|
||||
EMAIL_FROM=preview@preview.invalid
|
||||
EMAIL_FROM_NAME=RFC App (preview)
|
||||
|
||||
# --- Analytics: no-op'd -------------------------------------------------------
|
||||
VITE_AMPLITUDE_API_KEY=
|
||||
|
||||
# --- App identity / required env (synthetic) ---------------------------------
|
||||
# These satisfy backend/app/config.py's required vars with non-secret test
|
||||
# values. SECRET_KEY is a throwaway — sessions in an ephemeral preview need a
|
||||
# key, not a SECRET one. GITEA_* point at whatever read surface the preview
|
||||
# uses; for a content-light framework-PR preview the seeded synthetic DB
|
||||
# carries the visible state and Gitea need not be reachable.
|
||||
SECRET_KEY=preview-throwaway-not-a-secret-0000000000
|
||||
GITEA_URL=https://git.wiggleverse.org
|
||||
GITEA_BOT_USER=preview-bot
|
||||
GITEA_BOT_TOKEN=preview-not-a-real-token
|
||||
GITEA_ORG=preview
|
||||
GITEA_WEBHOOK_SECRET=preview-webhook-not-a-secret
|
||||
OAUTH_CLIENT_ID=preview-oauth-client
|
||||
OAUTH_CLIENT_SECRET=preview-oauth-not-a-secret
|
||||
# APP_URL: set to the Cloud Run service URL once `preview up` reports it, if the
|
||||
# app's OAuth redirect / absolute-URL building needs it for your review flow.
|
||||
APP_URL=http://localhost:8080
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Synthetic seed for per-PR preview environments (flotilla SPEC §15).
|
||||
--
|
||||
-- SYNTHETIC DATA ONLY. This file is version-controlled and reviewable, carries
|
||||
-- NO real user PII, and is applied by deploy/preview/entrypoint.sh onto a
|
||||
-- brand-new preview DB AFTER the app's own migrations have created the schema.
|
||||
-- It must never be applied to a production database.
|
||||
--
|
||||
-- Applied best-effort: if a future migration changes a table shape this seed
|
||||
-- references, the entrypoint logs a warning and the preview still boots (it
|
||||
-- just shows less content). Keep the inserts conservative and schema-stable;
|
||||
-- grow richer fixtures (RFCs, branches, threads, PRs) here as the preview's
|
||||
-- review value warrants — they are reproducible because they live in git.
|
||||
|
||||
-- A small synthetic user set covering each role (§6.1 owner/admin/contributor).
|
||||
-- Negative gitea_ids keep these clear of any real Gitea account id space.
|
||||
INSERT OR IGNORE INTO users (gitea_id, gitea_login, email, display_name, role) VALUES
|
||||
(-1, 'preview-owner', 'owner@preview.invalid', 'Preview Owner', 'owner'),
|
||||
(-2, 'preview-admin', 'admin@preview.invalid', 'Preview Admin', 'admin'),
|
||||
(-3, 'preview-alice', 'alice@preview.invalid', 'Alice Preview', 'contributor'),
|
||||
(-4, 'preview-bob', 'bob@preview.invalid', 'Bob Preview', 'contributor');
|
||||
@@ -42,5 +42,32 @@ ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/opt/rfc-app/backend/data
|
||||
|
||||
# v0.25.0 security hardening (audit 0026 L4) — defense-in-depth.
|
||||
# The service binds 127.0.0.1:8000 and runs plain CPython
|
||||
# (FastAPI/uvicorn + sqlite + bcrypt + httpx), so it needs no
|
||||
# capabilities and no exotic syscalls.
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictNamespaces=true
|
||||
LockPersonality=true
|
||||
# MemoryDenyWriteExecute=true blocks W^X memory — safe for stock
|
||||
# CPython (no JIT) and the pure-Python/C-extension stack here, but
|
||||
# would break a JIT or a C-ext that mmaps W+X. Watch the first
|
||||
# restart's journal for a crash; if uvicorn fails to come up,
|
||||
# comment this one line out and reload.
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallErrorNumber=EPERM
|
||||
SystemCallArchitectures=native
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
# Draft spec — §22 Multi-project deployments + amendments + slicing plan
|
||||
|
||||
> Status: **draft for review.** Binding voice, but not yet merged into
|
||||
> `SPEC.md`. When accepted: §22 below is appended after §21; the amendment
|
||||
> notes in Part B are applied in place; the slicing plan in Part C seeds a
|
||||
> new `docs/DEV.md` build section. Rationale and the decisions behind this
|
||||
> live in [`multi-project.md`](./multi-project.md). Target release: the next
|
||||
> minor (a pre-1.0 minor carrying breaking changes with upgrade steps, §20.2).
|
||||
|
||||
---
|
||||
|
||||
# Part A — New canonical section
|
||||
|
||||
## 22. Projects: multiple corpora per deployment
|
||||
|
||||
A **deployment** hosts one or more **projects**. A project is a single
|
||||
corpus: one content repository (§1) holding entries under `rfcs/`, with
|
||||
its own per-project slug namespace (the slug is the identity — §22.4), a
|
||||
declared **type** (§22.4a), catalog, philosophy, branding,
|
||||
member roster, and model universe. The deployment is the substrate the
|
||||
projects share — one Gitea org, one bot, one account system, one inbox, one
|
||||
running process — and the surface a visitor first lands on.
|
||||
|
||||
Everything §§1–21 describe about *a corpus* is now *a project*. Everything
|
||||
they describe about *a deployment* that is not corpus-specific — accounts,
|
||||
the §6 admission gate, the §15 inbox, the §1 bot — stays at the deployment
|
||||
level and is shared across projects. The numbered sections that assume a
|
||||
single corpus are amended in Part B; §22 is the binding model they defer to.
|
||||
|
||||
> **Multi-project change (target: next minor — supersedes the original
|
||||
> single-corpus model).** §1 originally said "for a deployment, this single
|
||||
> repository is its content repository." A deployment now has a **registry**
|
||||
> (§22.2) naming N content repositories, one per project. The single-corpus
|
||||
> deployment is the **N=1 case** and continues to run after migration via a
|
||||
> generated default project (§22.13); no deployment is forced to adopt more
|
||||
> than one project. Where earlier sections say "the meta repo" or "the
|
||||
> corpus," read "the project's content repo" and "the project's corpus."
|
||||
|
||||
### 22.1 The deployment ⇄ project relation
|
||||
|
||||
One deployment, N projects (N ≥ 1). A project belongs to exactly one
|
||||
deployment and never moves between deployments. Projects within a deployment
|
||||
are isolated by default (§22.5): an RFC, branch, thread, star, or watch
|
||||
belongs to exactly one project, and no app surface joins across projects
|
||||
except the per-account ones the deployment owns (the §15 inbox, the §6
|
||||
account roster, sign-in).
|
||||
|
||||
### 22.2 The registry — git is still truth
|
||||
|
||||
Which projects exist, and their configuration, is declared in git, mirrored
|
||||
into a `projects` cache table the same way content is mirrored into
|
||||
`cached_rfcs` (§4). The registry is a file the bot reads — a `projects.yaml`
|
||||
at the root of a dedicated **registry repo** under the deployment's Gitea
|
||||
org. The framework learns the registry repo's location from a required env
|
||||
var (`REGISTRY_REPO`, the multi-project successor to `META_REPO`); the repo's
|
||||
*name* is the deployment's choice, not the framework's, per the
|
||||
separation-of-concerns rule, and the framework fails loudly at startup if the
|
||||
var is unset. Adding, reconfiguring, or archiving a project is a PR against
|
||||
that file; the §4 webhook + reconciler keep the `projects` table in sync,
|
||||
recording the merged `registry_sha` on each row for provenance.
|
||||
|
||||
The registry is a **deployment-side repo the framework reads**, in exactly
|
||||
the sense `META_REPO` is today — not operator-tooling config. Where a
|
||||
deployment is assembled by an external operator tool, that tool supplies the
|
||||
`REGISTRY_REPO` value in the deployment's `.env` (as it supplies `META_REPO`
|
||||
now) and is otherwise unaffected: project definitions live in git, edited by
|
||||
PR, and the framework knows nothing about the tool that wrote the env var.
|
||||
|
||||
```yaml
|
||||
# projects.yaml (registry repo root)
|
||||
deployment:
|
||||
name: Wiggleverse # deployment display name (replaces VITE_APP_NAME)
|
||||
tagline: ... # deployment landing deck (§22.10)
|
||||
projects:
|
||||
- id: ohm # url-stable slug, unique within the deployment
|
||||
name: Open Human Model
|
||||
type: document # document | specification | bdd — immutable (§22.4a)
|
||||
content_repo: ohm-content # repo under the deployment's Gitea org (§22.3)
|
||||
visibility: gated # gated | public | unlisted (§22.5)
|
||||
initial_state: super-draft # super-draft | active — landing state of a new
|
||||
# entry; defaults from type (§22.4b)
|
||||
enabled_models: [claude, gemini] # optional; falls back to deployment ENABLED_MODELS
|
||||
theme: { accent: "#5b5bd6" } # optional per-project token overrides (§22.9)
|
||||
```
|
||||
|
||||
Project **definition and configuration** live in the registry (git). Project
|
||||
**membership** lives in the app db (§22.6) — it churns at user speed and is
|
||||
app state, not document state, exactly as `rfc_collaborators` is (§5).
|
||||
`projects` rows are never written from user actions; they flow from the
|
||||
registry mirror only.
|
||||
|
||||
### 22.3 Content repositories — one per project
|
||||
|
||||
Each project names one content repo under the deployment's single Gitea org
|
||||
(naming convention `<project-id>-content`). The §1 bot service account
|
||||
operates org-wide across every content repo and the registry repo; nothing
|
||||
about the bot, the §6 app-owned authorization, or the "app is the only
|
||||
contribution surface" stance changes. There are no per-project Gitea orgs and
|
||||
no per-project bot accounts.
|
||||
|
||||
### 22.4 The slug namespace is per-project; the slug is the identity
|
||||
|
||||
An entry's slug (§2) is unique **within its project**, not across the
|
||||
deployment: `ohm` and `specs` may each have an `intro`. The slug **is** the
|
||||
identity — a fully qualified reference is `(project_id, slug)`, and nothing
|
||||
more. There is no type prefix and **no per-project numeric ID**: within a
|
||||
project the slug alone is unambiguous, and the project context (its `/p/<id>/`
|
||||
URL prefix and chrome) carries everything the old `RFC-NNNN` label used to.
|
||||
|
||||
This retires the per-project numbering of earlier drafts. The §13 graduation
|
||||
flip still happens — it moves an entry from proposal to graduated state and
|
||||
into the content repo — but it no longer allocates a number; the
|
||||
`max(integer IDs)+1` allocator (§2.3, the old `api_graduation.py` path) is
|
||||
removed. The displayed *noun* around a slug ("RFC", "Spec", "Feature") is a
|
||||
presentation concern driven by the project's type (§22.4a), not part of the
|
||||
identity.
|
||||
|
||||
**Legacy numbers.** Entries graduated *before* this change keep their existing
|
||||
`id` (`RFC-NNNN`) in frontmatter as a **frozen, non-identity legacy label** —
|
||||
preserved and shown in the UI so external "RFC-0001"-style citations still
|
||||
resolve, but never used for routing or lookup (the slug is). New entries are
|
||||
never assigned one, and graduation does not write `id`. The field is read-only
|
||||
provenance from here on.
|
||||
|
||||
### 22.4a Project type
|
||||
|
||||
Every project declares a `type` in the registry (§22.2), chosen at creation
|
||||
and **immutable**: one of `document`, `specification`, or `bdd`. Type does not
|
||||
change the engine — every type uses the same content repo (§22.3), the same
|
||||
propose→branch→PR→discuss→graduate lifecycle (§§9–13), the same threads,
|
||||
flags, and chat. Type selects exactly three things:
|
||||
|
||||
1. the **entry frontmatter schema** the project validates entries against (§2);
|
||||
2. the **terminology** the chrome uses for an entry (the §8.1 noun, catalog
|
||||
labels);
|
||||
3. the set of **type-specific surfaces** layered on top of the shared §7
|
||||
catalog.
|
||||
|
||||
Type-specific behavior is implemented as a per-type module the framework
|
||||
selects on `project.type`; the engine itself treats every entry as markdown +
|
||||
frontmatter regardless of type. `type` is an **open set** in shape — a future
|
||||
type is a new module plus a new allowed enum value, no schema rebuild — and
|
||||
the three below are what is defined now. The type names and their behavior are
|
||||
framework concepts (like role names), not deployment content: a deployment
|
||||
picks which type each project is, but does not define or rename types.
|
||||
|
||||
- **`document`** — long-form normative prose (OHM: a model of principles and
|
||||
definitions). Frontmatter is the §2 baseline (title, status,
|
||||
owners/arbiters, tags). No type-specific surfaces. The §22.13 generated
|
||||
default project is a `document` project, so the N=1 case is unchanged.
|
||||
- **`specification`** — a versioned technical specification (this framework's
|
||||
own `SPEC.md` is the archetype: numbered normative sections, upgrade steps).
|
||||
Frontmatter adds spec metadata (`version`, lifecycle `status` of
|
||||
draft/active/superseded, `supersedes`). **Type-specific surface — release
|
||||
planning:** group entries/changes into versioned releases, carry a
|
||||
changelog + §20-style upgrade-steps per release, and surface "what is in the
|
||||
next release."
|
||||
- **`bdd`** — behavior-driven feature specs: each entry states a feature as
|
||||
Given/When/Then scenarios with acceptance criteria. Frontmatter adds feature
|
||||
metadata and an optional link to the `specification` entries a feature
|
||||
verifies. **Type-specific surface:** a scenario/acceptance view, and — where
|
||||
a deployment runs a BDD project alongside a specification project — a
|
||||
coverage view mapping features to the spec sections they exercise.
|
||||
|
||||
> **Draft note.** The shared-engine boundary is locked; the per-type
|
||||
> *schemas and surfaces* above (notably the specification release-planning
|
||||
> data model and whether BDD scenarios are free-form markdown or a parsed
|
||||
> Given/When/Then structure) are first proposals, to be pinned in the
|
||||
> type-surface slice (Part C, M5).
|
||||
|
||||
### 22.4b Initial state of a new entry
|
||||
|
||||
A project sets the **landing state** a new entry takes when its creating
|
||||
idea-PR merges (§2.4) — the `initial_state` registry field, one of the §2.4
|
||||
super-draft entry-states:
|
||||
|
||||
- **`super-draft`** (default for `document` and `specification`) — a new entry
|
||||
lands as a super-draft and must be explicitly graduated (§13) to reach
|
||||
`active`. This is today's flow: propose → super-draft → review → graduate.
|
||||
- **`active`** (default for `bdd`) — a new entry lands `active` on the idea-PR
|
||||
merge, with the **`unreviewed` flag set** (§22.4c). The §13 graduate gate is
|
||||
not surfaced — the entry is already active — but because nothing reviewed
|
||||
it, the flag marks it as not-yet-vetted until an owner clears it. A behavior
|
||||
spec is captured fact, not a proposal under deliberation, so it skips the
|
||||
review-then-promote ceremony; branches, PRs, and discussion work afterward
|
||||
exactly as on any `active` entry.
|
||||
|
||||
The default comes from the project's **type** (§22.4a) — each type module
|
||||
supplies it — but `initial_state` is an independent registry knob: a
|
||||
deployment may run a `bdd` project with `super-draft` if it wants a review
|
||||
gate, or (less commonly) a `document` project that auto-actives. It changes
|
||||
only the *landing state and whether graduation is required*; the underlying
|
||||
propose→branch→PR→discuss engine is unchanged (shared-engine rule, §22.4a).
|
||||
The §22.13 default project keeps `super-draft`, preserving the N=1 flow.
|
||||
|
||||
### 22.4c The `unreviewed` flag
|
||||
|
||||
An `active` entry carries an **`unreviewed`** boolean, orthogonal to its
|
||||
`state`. It records whether a human gate has vetted the entry:
|
||||
|
||||
- An entry that reaches `active` by the normal **graduate** path (§13) is
|
||||
**never** flagged — the graduate action, taken by an owner/admin, *is* the
|
||||
review.
|
||||
- An entry that **skips straight to `active`** via `initial_state: active`
|
||||
(§22.4b) lands with `unreviewed = true`, because nothing reviewed it.
|
||||
|
||||
A **project owner** — `project_admin`, or a deployment `owner`/`admin` (§22.7)
|
||||
— clears the flag with a **mark-reviewed** action (§17), the same authority
|
||||
that graduates an entry. Clearing stamps `reviewed_at`/`reviewed_by` for
|
||||
provenance, paralleling `graduated_at`/`graduated_by`. The flag is a property
|
||||
of the entry (frontmatter, §2 amendment), so it is git-truth and survives a
|
||||
cache rebuild, exactly like `state`.
|
||||
|
||||
The §7 catalog gains an **unreviewed filter** so an owner can find the entries
|
||||
awaiting review; it is the natural worklist for the mark-reviewed action.
|
||||
`unreviewed` only applies to `active` entries — a `super-draft` is pre-review
|
||||
by definition, and `withdrawn` is out of scope.
|
||||
|
||||
### 22.5 Project visibility
|
||||
|
||||
Each project carries a `visibility`, defaulting to **gated**:
|
||||
|
||||
- **`gated`** (default) — the project is invisible to non-members. It does
|
||||
not appear in the directory (§22.10), its RFCs return 404 to non-members,
|
||||
and reading or writing anything in it requires membership (§22.6). This is
|
||||
the baseline because a deployment may host specs and vision docs it is not
|
||||
ready to publish.
|
||||
- **`public`** — any visitor may read the project's RFCs under the §6.1
|
||||
anonymous-read contract; the project appears in the directory; contributing
|
||||
still requires a `project_contributor` grant. This is the mode that
|
||||
preserves the pre-multi-project open-by-default behavior, and the mode a
|
||||
generated default project (§22.13) is seeded into.
|
||||
- **`unlisted`** — readable by anyone with a direct link, but not shown in
|
||||
the directory and not enumerated by `GET /api/deployment`.
|
||||
|
||||
Visibility is the project's; it does not relax the §11 per-branch
|
||||
`read_public` controls *within* a project, which continue to apply on top.
|
||||
|
||||
### 22.6 Project membership and roles
|
||||
|
||||
Membership is a `project_members(project_id, user_id, role, granted_by,
|
||||
granted_at)` table, one role per (user, project). The role is a **new middle
|
||||
tier** between the §6.1 deployment roles and the §6.3 per-RFC authority:
|
||||
|
||||
1. **`project_viewer`** — read the project's RFCs and participate in
|
||||
discussion (chat, flags) on anything readable. No propose/branch/PR. The
|
||||
discuss-only counterpart of the §12 per-RFC `discussant`, at project scope.
|
||||
2. **`project_contributor`** — everything a viewer can do, plus the §6.1
|
||||
contributor capabilities *within this project*: propose RFCs into it,
|
||||
create branches, open PRs, claim unclaimed super-drafts.
|
||||
3. **`project_admin`** — everything a contributor can do, plus the §6.1
|
||||
admin capabilities *within this project*: manage its membership, act on
|
||||
any RFC in it (merge on behalf of arbiters, graduate, set branch
|
||||
visibility, withdraw/reopen), and edit per-RFC delegated authority.
|
||||
`project_admin` is the §6.3 delegated-authority idea lifted from per-RFC
|
||||
to per-project: an admin scoped to one corpus, not the deployment.
|
||||
|
||||
Membership and role are still gated by the deployment-level
|
||||
`users.permission_state='granted'` (§6): a pending account has no write
|
||||
capability in any project regardless of its `project_members` rows.
|
||||
|
||||
### 22.7 How the three tiers compose
|
||||
|
||||
Authorization for an action on an RFC resolves by taking the **most
|
||||
permissive** of:
|
||||
|
||||
- the actor's **deployment role** (§6.1) — `owner`/`admin` are superusers in
|
||||
every project; a plain authenticated `contributor` has, by itself, only
|
||||
anonymous-equivalent access to a project until §22.6 grants it a role
|
||||
(subject to §22.5 visibility);
|
||||
- the actor's **project role** in that RFC's project (§22.6);
|
||||
- the actor's **per-RFC authority** in that RFC (§6.3 `owners`/`arbiters`,
|
||||
§12 `rfc_collaborators`).
|
||||
|
||||
Concretely: deployment `owner`/`admin` ⊇ `project_admin` ⊇ RFC
|
||||
`owners`/`arbiters`; deployment `contributor` + `project_contributor` ⊇ RFC
|
||||
`rfc_collaborators(contributor)`; `project_viewer` ⊇ `discussant`. The §6.2
|
||||
write-mute and the §22.5 visibility gate are subtractive on top of whatever
|
||||
the union grants.
|
||||
|
||||
`users.role` (§5) now means *deployment* level only. No schema change demotes
|
||||
an existing owner/admin; their powers simply read as "superuser in every
|
||||
project" rather than "superuser in the corpus."
|
||||
|
||||
### 22.8 Discovery and joining a gated project
|
||||
|
||||
Because a gated project is invisible to non-members, joining is by one of:
|
||||
|
||||
- **Invite** — a `project_admin` (or deployment admin/owner) adds a user
|
||||
directly, writing a `project_members` row and fanning a §15 notification.
|
||||
This reuses the §12 per-RFC invitation machinery, re-scoped to the project.
|
||||
- **Request to join** — a surface analogous to §28's contribution-requests:
|
||||
a user who knows a project exists (e.g. by direct link to an `unlisted`
|
||||
project, or by out-of-band referral) can request membership; a
|
||||
`project_admin` accepts or declines from the inbox. The request names the
|
||||
desired role (defaulting to `project_viewer`).
|
||||
|
||||
A `public` project needs neither: read is open, and the existing §6 / §12
|
||||
contribute-grant paths cover write access.
|
||||
|
||||
### 22.9 Branding is resolved at runtime
|
||||
|
||||
`VITE_APP_NAME` is **deprecated** (§20 amendment): a single build-time name
|
||||
cannot serve N projects. Deployment and project identity are served at
|
||||
runtime:
|
||||
|
||||
- `GET /api/deployment` — the deployment `name`, `tagline`, and the list of
|
||||
projects the caller can see (gated projects filtered by the caller's
|
||||
membership; `unlisted` omitted).
|
||||
- `GET /api/projects/:id` — that project's `name`, `tagline`, philosophy
|
||||
pointer, and optional `theme` token overrides applied over the §-default
|
||||
`tokens.css`.
|
||||
|
||||
The frontend reads these instead of `import.meta.env.VITE_APP_NAME`. Two
|
||||
chrome layers result: **deployment chrome** (the directory/landing, the
|
||||
project switcher, the shared inbox) and **project chrome** (the §7 catalog,
|
||||
the §8 RFC view, the §14 philosophy — all per project).
|
||||
|
||||
### 22.10 Routing and the deployment landing
|
||||
|
||||
Every corpus-scoped route gains a project segment: `/p/<project>/…` carries
|
||||
the §7 catalog, the §8 entry view at `/p/<project>/e/<slug>`, the §9/§10
|
||||
`/p/<project>/proposals/<n>`, and the §14 `/p/<project>/philosophy`. The entry
|
||||
segment is the **generic `/e/`** for every type — the displayed noun ("RFC",
|
||||
"Spec", "Feature") is a type-driven label (§22.4a), not part of the path, so
|
||||
routing stays a single type-agnostic path and avoids colliding with the
|
||||
reserved sibling segments (`proposals`, `philosophy`, …). The root `/` is the
|
||||
**deployment landing**: a directory of the projects the visitor can see (per
|
||||
§22.5), plus sign-in. An anonymous or non-member visitor sees only `public`
|
||||
projects there. The §8.1 breadcrumb gains a leading project segment:
|
||||
`OHM / Human › main` (slug, with the type-driven noun as its label).
|
||||
|
||||
### 22.11 Notifications span projects, one inbox
|
||||
|
||||
Accounts are deployment-wide, so the §15 inbox is one inbox across all the
|
||||
caller's projects. `notifications` and `watches` carry `project_id` (§5
|
||||
amendment) so the inbox filters by project and a user can mute an entire
|
||||
project. Quiet hours, digest cadence, and email preferences stay per-account
|
||||
at the deployment level (§5, §15).
|
||||
|
||||
### 22.12 Per-project model universe
|
||||
|
||||
A project's `enabled_models` (registry, §22.2) defines its operator universe,
|
||||
overriding the deployment `ENABLED_MODELS` (§18) when present and falling
|
||||
back to it when absent. The §6.6 per-RFC `models:` list and the §6.7 funder
|
||||
universe resolve *within* the project's universe — the resolution order
|
||||
becomes funder universe ∩ §6.6 list ∩ project universe, with the project
|
||||
universe substituting for the deployment universe at the outermost step.
|
||||
|
||||
### 22.13 Migration — the default project (the N=1 case)
|
||||
|
||||
A deployment upgrading from a pre-multi-project version is migrated to a
|
||||
single **default project** so it keeps running unchanged:
|
||||
|
||||
1. The migration generates a `projects` row from current config:
|
||||
`META_REPO → content_repo`, `VITE_APP_NAME → name`, `visibility = public`
|
||||
(preserving the deployment's current open-by-default posture),
|
||||
`type = document` (every pre-multi-project corpus is a document corpus),
|
||||
and the `id` a **config-derived slug**: `DEFAULT_PROJECT_ID` if set, else a
|
||||
slug of the deployment name, falling back to the literal `default`. (M1's
|
||||
migration 026 seeds the bootstrap id `default`; the §C-M3 step re-stamps it
|
||||
to the config-derived slug before any `/p/<id>/` route is public, so the id
|
||||
is meaningful — e.g. `/p/ohm/…` — and never renamed after URLs go live. The
|
||||
id stays framework-generic: the framework supplies no deployment name.)
|
||||
2. Every existing RFC-scoped row (§5 amendment list) is stamped with that
|
||||
`project_id`.
|
||||
3. Old corpus-root URLs (`/rfc/<slug>`, `/proposals/<n>`) 308-redirect to
|
||||
their `/p/<default-id>/…` equivalents — the entry view to
|
||||
`/p/<default-id>/e/<slug>` (§22.10) — so existing links survive.
|
||||
4. The operator creates the registry repo (§22.2) declaring the default
|
||||
project; until they add a second project, the deployment is functionally
|
||||
identical to before, with one extra path segment.
|
||||
|
||||
This is the §20.4 upgrade-steps content for the release.
|
||||
|
||||
---
|
||||
|
||||
# Part B — Amendments to existing sections
|
||||
|
||||
Applied in place, in the established amendment-note style (cf. §1's
|
||||
"Topology change (v0.31.0)").
|
||||
|
||||
- **§1 Repository topology.** Add the §22 amendment note (above). "This
|
||||
single repository is its content repository" → "each *project* names one
|
||||
content repository; the deployment's registry (§22.2) lists them." The bot
|
||||
and app-owned-authorization paragraphs are unchanged and now read
|
||||
org-wide.
|
||||
- **§2 Meta schema / §2.3 IDs.** Slugs are unique within a project; entry
|
||||
filenames are unchanged (per content repo). §2.3's `RFC-NNNN` `max+1`
|
||||
allocation is **removed** — the slug is the identity (§22.4); there is no
|
||||
per-project number. Entries graduated before this change keep their `id`
|
||||
as a frozen, read-only legacy display label (§22.4), never used for lookup.
|
||||
The entry frontmatter schema becomes type-dependent
|
||||
(§22.4a): `document` keeps today's fields, `specification` and `bdd` add
|
||||
their type metadata. New `active`-entry fields: `unreviewed` (bool) and the
|
||||
`reviewed_at`/`reviewed_by` provenance pair (§22.4c), paralleling
|
||||
`graduated_at`/`graduated_by`.
|
||||
- **§2.4 State machine.** The `(no entry) ─[idea-PR merged]→` transition now
|
||||
targets the project's `initial_state` (§22.4b): `super-draft` as today, or
|
||||
straight to `active` when the project (e.g. a `bdd` project) lands entries
|
||||
there — in which case the entry is stamped `unreviewed` (§22.4c). A new
|
||||
`active ─[mark-reviewed, owner/admin]→ active` self-transition clears the
|
||||
flag. The rest of the machine is unchanged.
|
||||
- **§5 Data model.** Add `project_id` to: `branch_visibility`,
|
||||
`branch_contribute_grants`, `stars`, `threads`, `changes`, `watches`,
|
||||
`notifications`, `rfc_invitations`, `rfc_collaborators`,
|
||||
`contribution_requests`, `funder_consents`, the `*_seen` cursors, `actions`,
|
||||
and the §4 cache tables `cached_rfcs` (PK → `(project_id, slug)`;
|
||||
also mirrors the `unreviewed` frontmatter flag, §22.4c, so the §7 catalog
|
||||
filter can query it without reading every entry file),
|
||||
`cached_branches`, `cached_prs`, `pr_resolution_branches`,
|
||||
`proposed_use_cases`. Add the new tables `projects` (carrying the immutable
|
||||
`type`, §22.4a) and `project_members` (§22.2, §22.6). `users.role` is
|
||||
annotated as deployment-scope (§22.7).
|
||||
- **§6.1 Roles.** Add the §22.7 composition note: deployment roles are now
|
||||
one of three tiers; a plain `contributor` has no implicit access to a
|
||||
project until §22.6 grants a project role (subject to §22.5).
|
||||
- **§6.3 Per-RFC delegated authority.** Note that `project_admin` (§22.6) is
|
||||
the same delegation idea at project scope, sitting above per-RFC authority.
|
||||
- **§7 Left pane.** The catalog is per-project, under `/p/<project>/`. The
|
||||
deployment directory (§22.10) is a new surface above it. The catalog gains
|
||||
an **unreviewed filter** (§22.4c) — the owner's worklist of `active` entries
|
||||
that landed unreviewed.
|
||||
- **§8.1 Breadcrumb.** Gains a leading project segment (§22.10). The entry is
|
||||
named by its slug, not a number (§22.4); the noun shown around it ("RFC",
|
||||
"Spec", "Feature") is the project type's label (§22.4a).
|
||||
- **§13.3 Graduation flip.** Operates on the project's content repo. It flips
|
||||
status and moves the entry, but **allocates no number** — the slug is the
|
||||
identity throughout (§22.4); the old per-project `RFC-NNNN` allocation is
|
||||
gone. Graduation is also **conditional on the project's `initial_state`
|
||||
(§22.4b)**: a project that lands entries `active` has no super-draft phase,
|
||||
so the graduate action is a no-op there and is not surfaced. For those
|
||||
entries the **mark-reviewed** action (§22.4c) takes graduation's place as
|
||||
the owner/admin vetting step — it clears `unreviewed` instead of flipping
|
||||
state.
|
||||
- **§14.1 Pre-login landing.** Splits into deployment landing (the directory,
|
||||
§22.10) and per-project philosophy/deck (§14 under `/p/<project>/`).
|
||||
Deployment name comes from `GET /api/deployment`, not `VITE_APP_NAME`.
|
||||
- **§17 Backend surface.** RFC routes gain the `/p/<project>` /
|
||||
`project_id` scoping; add `GET /api/deployment`, `GET /api/projects/:id`
|
||||
(returns the project's `type`, §22.4a), and the `project_members`
|
||||
management + request-to-join endpoints (§22.6, §22.8). Add a
|
||||
**mark-reviewed** endpoint clearing an entry's `unreviewed` flag (§22.4c,
|
||||
owner/admin), and an `unreviewed` filter param on the catalog list. Type-
|
||||
specific surfaces (§22.4a) add their own routes, mounted only for projects of
|
||||
the matching type — e.g. the `specification` release-planning endpoints and
|
||||
the `bdd` scenario/coverage endpoints.
|
||||
- **§18 Stack.** `ENABLED_MODELS` is the deployment fallback; per-project
|
||||
`enabled_models` overrides it (§22.12).
|
||||
- **§20 Versioning / surface.** `VITE_APP_NAME` deprecated in favor of the
|
||||
registry + `GET /api/deployment` (§22.9). New required backend env var
|
||||
`REGISTRY_REPO` (the §20.3 env contract); `META_REPO` becomes legacy,
|
||||
consulted only by the §22.13 migration to seed the default project's
|
||||
`content_repo`, then unused. The registry file shape and the
|
||||
`projects`/`project_members` schema join the §20.3 versioned surface. The
|
||||
release is a pre-1.0 minor with a §22.13 upgrade-steps block. Note for the
|
||||
changelog: a deployment assembled by an external operator tool upgrades
|
||||
through the same pinned-version path as any other — the only deploy-surface
|
||||
change is swapping the `META_REPO` overlay value for `REGISTRY_REPO`; the
|
||||
framework's versioned contracts (`/api/health`, `VERSION`, the pin file)
|
||||
are unchanged.
|
||||
|
||||
---
|
||||
|
||||
# Part C — Slicing plan
|
||||
|
||||
Seven slices carry §22 and its amendments end-to-end. The ordering mirrors
|
||||
DEV.md's original principle — foundations and the cache/permission spine
|
||||
first, the surfaces that consume them after, hardening last. Each slice is
|
||||
shippable: a deployment can stop at any slice boundary and still run (the
|
||||
default project keeps the N=1 case whole throughout). The project `type`
|
||||
(§22.4a) rides M3 (config) and M5 (its surfaces); M1–M4 are type-agnostic
|
||||
because the engine is.
|
||||
|
||||
**M1 — The project spine (schema + default-project migration).** *(landed)*
|
||||
The `projects` and `project_members` tables; `project_id` threaded
|
||||
additively onto every slug-bearing §5 table (migration 026); the §22.13
|
||||
default project generated and every existing row backfilled to it; the
|
||||
startup backfill that fills the default project's `content_repo` from
|
||||
`META_REPO`; `REGISTRY_REPO` wired into config (consumed in M3). No UI, no
|
||||
routing change, no registry mirror yet — the app runs exactly as before,
|
||||
single project, with the spine underneath. Additive only: no table rebuilds
|
||||
(the slug-keyed uniqueness/PK rework is deferred to the slice that activates
|
||||
project #2, enumerated in migration 026's header). This is the foundation
|
||||
everything after builds on.
|
||||
|
||||
**M2 — Project-scoped authorization + the §22.7 resolver.** *(landed)*
|
||||
The three-tier composition: `project_members` roles, the most-permissive
|
||||
union with deployment role and per-RFC authority, the §22.5 visibility gate as
|
||||
a 404 on read and 401/403 on write. Every §17 write endpoint surveyed in
|
||||
§6.1's audit re-checked under the project axis. Still single visible project;
|
||||
verifiable by granting/revoking roles on the default project and flipping its
|
||||
visibility (`backend/tests/test_multi_project_authz_vertical.py`). Pure
|
||||
app-layer — the resolver primitives live in `app/auth.py`
|
||||
(`project_visibility`, `project_member_role`, `is_project_superuser`,
|
||||
`can_read_project`, `can_contribute_in_project`, `require_project_readable`,
|
||||
`visible_project_ids`), composed into the existing per-RFC capability helpers
|
||||
and threaded into every RFC-resolution gate (`_require_rfc*`,
|
||||
`_require_super_draft`, `_require_rfc_readable`, the branch/PR/graduation deep
|
||||
gates). No migration (M1 shipped the tables) and no behavior change on the
|
||||
public default project.
|
||||
|
||||
Two operator decisions pin how the implicit grant behaves on a `public`
|
||||
project: **implicit-on-public** — a granted deployment `contributor` keeps its
|
||||
pre-multi-project write baseline with no `project_members` row, so the N=1 case
|
||||
stays whole (no backfill); and **preserve curation** — that implicit baseline
|
||||
does *not* override per-RFC owner curation (only an explicit
|
||||
project_contributor/admin or a deployment owner/admin does), so the v0.16.0
|
||||
per-RFC invite contract is unchanged on public. Explicit `project_members`
|
||||
rows and `gated`/`unlisted` visibility are where the new tier bites. This is a
|
||||
deliberate liberalization of §22.5's literal "contributing still requires a
|
||||
grant" for the public case; gated/unlisted honor the grant model exactly.
|
||||
|
||||
**M3 — Registry mirror + routing + runtime branding.** The §4 registry
|
||||
mirror (webhook + reconciler over the `REGISTRY_REPO`, populating `projects`
|
||||
rows beyond the default); the **re-stamp of the default project's bootstrap
|
||||
`id`** (`default` → the config-derived slug, §22.13 step 1) which must land
|
||||
here, before any `/p/<id>/` URL is public; the `/p/<project>/` route prefix
|
||||
with the generic `/e/<slug>` entry segment (§22.10) and the 308 redirects off
|
||||
the old corpus-root URLs (`/rfc/<slug>` → `/p/<default-id>/e/<slug>`); `GET
|
||||
/api/deployment` and `GET /api/projects/:id`; the frontend cut from `VITE_APP_NAME` to runtime config,
|
||||
per-project `theme` token overlay. The deployment directory at `/` and the
|
||||
project switcher in deployment chrome. This slice also adds the additive
|
||||
`type` and `initial_state` columns to `projects` (a small migration — M1
|
||||
shipped `projects` without them), mirrors both from the registry, returns
|
||||
them on `GET /api/projects/:id`, and drives the entry-noun terminology off
|
||||
`type` (§22.4a). It also teaches the shared creation path to honor
|
||||
`initial_state` (§22.4b) — land a new entry `active` instead of `super-draft`,
|
||||
stamping `unreviewed` and skipping the graduate gate when the project says so
|
||||
— plus the `unreviewed` frontmatter fields (§22.4c) mirrored into
|
||||
`cached_rfcs`, the owner/admin mark-reviewed action, and the §7 catalog
|
||||
unreviewed filter that queries that cached column. But no type
|
||||
*surfaces* yet; beyond their label, landing state, and review flag, all three
|
||||
types still look the same here. After M3 a deployment with two registry projects is
|
||||
fully navigable — which makes this the slice that must also land the deferred
|
||||
slug-keyed uniqueness/PK rebuilds (migration 026 header) before a second
|
||||
project can collide with the first.
|
||||
|
||||
**M4 — Per-project corpus surfaces (the second-project acceptance pass).** The
|
||||
§7 catalog, §8 entry view, §9/§10 proposal/PR flows, §13 graduation, and §14
|
||||
philosophy all confirmed working under project scope with the per-project,
|
||||
slug-only namespace (§22.4). This is mostly *inherited* from M1–M3 — the work
|
||||
is an end-to-end pass that proves a second `document` project's full lifecycle
|
||||
(propose → super-draft → graduate, identified by slug *in that project*), not
|
||||
new build. Naming it explicitly as the acceptance slice keeps scope that
|
||||
belongs in M3 from leaking in.
|
||||
|
||||
**M5 — Type modules + type-specific surfaces.** The per-type layer of §22.4a:
|
||||
type-specific entry-frontmatter validation (`specification`/`bdd` metadata);
|
||||
the `specification` **release-planning** surface; the `bdd` scenario/coverage
|
||||
surface. Type-scoped routes mounted only for matching projects (§17). The
|
||||
shared engine is untouched — this slice only adds the layers on top, so a
|
||||
`document` project is unaffected and the M4 acceptance still holds. (The
|
||||
per-type schema/surface details are the §22.4a draft note's open work.)
|
||||
|
||||
**M6 — Membership lifecycle.** §22.8 invite (re-scoped §12 machinery) and
|
||||
request-to-join (re-scoped §28); the inbox surfacing of join requests; the
|
||||
§22.11 cross-project inbox with `project_id` filtering and project-level
|
||||
mute. The admin surface for managing a project's roster.
|
||||
|
||||
**M7 — Hardening + operator path.** Per-project `enabled_models` resolution
|
||||
(§22.12) including funder/§6.6 intersection; the registry's place in
|
||||
`docs/DEPLOYMENTS.md` and the flotilla operator tooling; end-to-end tests
|
||||
spanning two projects with disjoint membership; the §20.4 changelog +
|
||||
upgrade-steps block; the SPEC merge (Part A appended, Part B applied).
|
||||
|
||||
## Open items folded into the slices
|
||||
|
||||
- **Registry repo vs. file-in-existing-repo** — *resolved:* a dedicated
|
||||
registry repo the framework reads via the `REGISTRY_REPO` env var (§22.2).
|
||||
Confirmed against the flotilla operator-tooling spec: the registry is
|
||||
deployment-side git content (like the corpus), not operator config, so the
|
||||
operator tool's only change is swapping the `META_REPO` overlay value for
|
||||
`REGISTRY_REPO`. No flotilla architectural change; no new framework⇄tool
|
||||
contract. With OHM becoming one project among several, the registry sits
|
||||
*above* any single project's content repo, so a file inside one project's
|
||||
repo is wrong — a dedicated repo is the right home.
|
||||
- **Request-to-join vs. invite-only** — drafted with both (§22.8); M6
|
||||
(membership lifecycle) may ship invite-only first and add request-to-join
|
||||
second if scope demands.
|
||||
- **Per-type schemas and surfaces** — the §22.4a draft note's open work:
|
||||
the `specification` release-planning data model and whether `bdd` scenarios
|
||||
are free-form markdown or a parsed Given/When/Then structure. Pinned in M5.
|
||||
@@ -0,0 +1,365 @@
|
||||
# Design sketch — multi-project deployments
|
||||
|
||||
> Status: **draft / sketch.** Not binding. This precedes the SPEC edits it
|
||||
> describes. Decisions captured here were made interactively; open questions
|
||||
> are flagged inline. When this stabilizes it folds into `SPEC.md` (§1, §2,
|
||||
> §5, §6, §7, §8, §13, §14, §17, §20) and ships as a pre-1.0 minor with
|
||||
> upgrade steps.
|
||||
|
||||
## The reframe
|
||||
|
||||
Today the framework hardcodes **deployment : corpus = 1 : 1**. One deployment
|
||||
is one Gitea content repo (`META_REPO`), one global slug namespace, one
|
||||
`VITE_APP_NAME` baked into the build, one flat catalog. SPEC §1 says it
|
||||
plainly: "this single repository is its content repository."
|
||||
|
||||
This change makes it **deployment : project = 1 : N**, where *today's entire
|
||||
deployment becomes the N=1 case*. A **project** is what a corpus is now: a
|
||||
content repo, its own slug namespace, a declared **type** (§ "Project types"
|
||||
below), its own catalog, philosophy, branding, member roster, and
|
||||
enabled-models universe. The **deployment** (the subdomain — e.g. Wiggleverse)
|
||||
becomes a thin shell hosting a directory of projects plus a shared
|
||||
account/notification layer.
|
||||
|
||||
OHM — a *document* project — becomes one project among several: specs
|
||||
(*specification* projects), behavior suites (*BDD* projects), vision docs, …
|
||||
all under one deployment.
|
||||
|
||||
The value of this framing: the migration stays mechanical. Everything
|
||||
deployment-scoped today splits cleanly into:
|
||||
|
||||
- **stays deployment-scoped** — accounts, the beta/permission gate, the inbox,
|
||||
the bot service account, the Gitea org;
|
||||
- **becomes project-scoped** — the corpus, branding, roles, catalog, philosophy,
|
||||
enabled models.
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
1. **Project registry lives in git.** A registry (a `projects.yaml` / registry
|
||||
repo) declares which projects exist and their config; adding a project is a
|
||||
PR. Mirrored into a `projects` cache table. Keeps the git-is-truth invariant.
|
||||
2. **Projects are membership-gated by default** (private). A non-member does
|
||||
not see a private project exists. `visibility` is still a per-project field
|
||||
with `public` and `unlisted` escape hatches (see §3) — gated is the default,
|
||||
not the only mode.
|
||||
3. **Entries are identified by slug, scoped to the project.** A fully
|
||||
qualified reference is `(project_id, slug)`. There is *no* type prefix and
|
||||
*no* per-project numeric ID: inside a project the slug alone is
|
||||
unambiguous, and the project (its URL prefix, its chrome) supplies all the
|
||||
surrounding context. This **supersedes** the earlier per-project `RFC-NNNN`
|
||||
allocation idea — graduation (§13) still flips an entry's status, but no
|
||||
longer mints a number.
|
||||
4. **Each project declares a `type`** — `document`, `specification`, or `bdd`.
|
||||
Type is chosen when the project is created (in the registry), is immutable,
|
||||
and selects the project's entry frontmatter schema, its terminology/labels,
|
||||
and any type-specific surfaces (e.g. release planning for specifications).
|
||||
All types ride the *same* propose→branch→PR→graduate engine, threads,
|
||||
flags, and chat; type layers on top — it does not fork the lifecycle.
|
||||
|
||||
## Project types
|
||||
|
||||
A project's `type` is a registry field, fixed at creation. It does **not**
|
||||
change the engine — every type uses the same content repo, the same
|
||||
propose→branch→PR→discuss→graduate lifecycle, the same threads/flags/chat. It
|
||||
selects three things: the **entry frontmatter schema** the project validates
|
||||
against, the **terminology** the chrome uses for an entry, and the set of
|
||||
**type-specific surfaces** the project exposes on top of the shared catalog.
|
||||
The engine treats every entry as markdown + frontmatter regardless of type;
|
||||
type-specific behavior is a layer, implemented as a per-type module the
|
||||
framework selects on `project.type`.
|
||||
|
||||
> These three definitions — especially the specification release-planning
|
||||
> surface and the BDD scenario model — are first drafts. The schema/surface
|
||||
> details below are proposals to refine, not yet locked.
|
||||
|
||||
- **`document`** — long-form normative prose (OHM is the archetype: a model
|
||||
of principles and definitions). Frontmatter is today's entry schema
|
||||
(title, status, owners/arbiters, tags). **No** type-specific surfaces; this
|
||||
is the baseline, and the N=1 default project (§7) is a `document` project.
|
||||
- **`specification`** — a versioned technical specification (this app's own
|
||||
`SPEC.md`, with numbered normative sections and upgrade steps, is the
|
||||
archetype). Frontmatter adds spec metadata (e.g. `version`, lifecycle
|
||||
`status` of draft/active/superseded, `supersedes`). Type-specific surface:
|
||||
**release planning** — group entries/changes into versioned releases, track
|
||||
the changelog + upgrade-steps for each, and show "what's in the next
|
||||
release." (This mirrors how rfc-app itself runs VERSION + CHANGELOG +
|
||||
§20 upgrade steps.)
|
||||
- **`bdd`** — behavior-driven feature specs: each entry describes a feature
|
||||
as scenarios in Given/When/Then form with acceptance criteria. Frontmatter
|
||||
adds feature metadata and an optional link to the `specification` entries a
|
||||
feature verifies. Type-specific surface: a scenario/acceptance view, and
|
||||
(where a deployment pairs a BDD project with a specification project) a
|
||||
coverage view linking features back to the spec sections they exercise.
|
||||
|
||||
Types are an **open set** in shape — a new type is a new module plus a new
|
||||
allowed `type` value; it needs no schema migration beyond the enum. Document,
|
||||
specification, and BDD are the three defined now.
|
||||
|
||||
**Landing state (`initial_state`).** A project also sets what state a new
|
||||
entry lands in when its idea-PR merges — `super-draft` (the normal
|
||||
propose→review→graduate flow) or `active` (graduated on submission). The
|
||||
default comes from the type: `document` and `specification` default to
|
||||
`super-draft`; **`bdd` defaults to `active`** — a behavior spec is captured
|
||||
fact, not a proposal under deliberation, so it skips the review-then-promote
|
||||
gate. It's an independent registry knob, so a deployment can override the
|
||||
default per project. This changes only the landing state and whether
|
||||
graduation is required; the propose→branch→PR engine is unchanged.
|
||||
|
||||
**The `unreviewed` flag.** Skipping straight to `active` means nothing vetted
|
||||
the entry, so it lands with an **`unreviewed`** flag set. An entry that reaches
|
||||
`active` the normal way — super-draft → graduate — is never flagged, because
|
||||
graduation *is* the review. A project owner clears the flag with a
|
||||
**mark-reviewed** action (same authority as graduate), and the catalog has an
|
||||
**unreviewed filter** so owners can find the entries still awaiting review. The
|
||||
flag is an entry property (frontmatter, git-truth like `state`), orthogonal to
|
||||
the `active` state and only meaningful on `active` entries.
|
||||
|
||||
The separation-of-concerns rule (CLAUDE.md) is satisfied: the *type names*
|
||||
and their behavior are framework concepts (like role names), not
|
||||
deployment-specific content. A deployment chooses *which* type each of its
|
||||
projects is; it does not define new types or rename them.
|
||||
|
||||
## 1. Git topology — one content repo per project
|
||||
|
||||
One Gitea org for the deployment, **N content repos** (`ohm-content`,
|
||||
`specs-content`, `vision-content`, …). The bot already operates org-wide; it
|
||||
gains more repos and one registry repo. Slug uniqueness becomes naturally
|
||||
per-repo = per-project (and the slug is the whole identity — see Decision 3).
|
||||
|
||||
Rejected alternatives: subdirectories in one repo (`projects/<id>/rfcs/…`)
|
||||
churns every path / branch-name / graduation code path and can't carry
|
||||
git-layer access control per project; prefixed slugs pollute the namespace.
|
||||
|
||||
### The registry repo
|
||||
|
||||
A small repo (or a top-level file in a deployment repo) the bot reads and
|
||||
mirrors. Sketch shape:
|
||||
|
||||
```yaml
|
||||
# projects.yaml
|
||||
deployment:
|
||||
name: Wiggleverse
|
||||
tagline: ...
|
||||
projects:
|
||||
- id: ohm # url slug, stable, unique in deployment
|
||||
name: Open Human Model
|
||||
type: document # document | specification | bdd (immutable)
|
||||
content_repo: ohm-content # repo under the deployment's Gitea org
|
||||
visibility: gated # gated | public | unlisted
|
||||
initial_state: super-draft # super-draft | active — landing state of a
|
||||
# new entry; defaults from type
|
||||
philosophy_repo_path: PHILOSOPHY.md
|
||||
enabled_models: [claude, gemini] # optional; falls back to deployment ENABLED_MODELS
|
||||
theme: { accent: "#5b5bd6" } # optional per-project token overrides
|
||||
```
|
||||
|
||||
Project **definition/config** is in git; project **membership** is in the DB
|
||||
(it churns like `rfc_collaborators` and is app-state, not document state). The
|
||||
registry gets the same webhook + reconciler treatment as content repos.
|
||||
|
||||
> Open: does the registry get its own repo, or is it a file in an existing
|
||||
> deployment/meta repo? Ties into the `ohm-rfc-app-flotilla` operator tooling,
|
||||
> which already owns deploy orchestration and could own registry edits.
|
||||
|
||||
## 2. Data model
|
||||
|
||||
Introduce a **`projects`** cache table (mirrored from the registry, like
|
||||
`cached_rfcs` is mirrored from content). Then thread `project_id` through
|
||||
every RFC-scoped table.
|
||||
|
||||
Hard constraint: once slugs are unique only *within* a project, any table
|
||||
keyed on `rfc_slug` alone is ambiguous, so `project_id` must ride along on all
|
||||
of them:
|
||||
|
||||
- `cached_rfcs` — PK becomes `(project_id, slug)`; `rfc_id` unique per project
|
||||
- `cached_branches`, `cached_prs`, `pr_resolution_branches`, `proposed_use_cases`
|
||||
- `threads`, `changes`, `branch_visibility`, `branch_contribute_grants`
|
||||
- `stars`, `watches`, `pr_seen`, `branch_chat_seen`
|
||||
- `rfc_invitations`, `rfc_collaborators`, `contribution_requests`, `funder_consents`
|
||||
- `notifications`, `actions`
|
||||
|
||||
~15-table migration, all backfillable to a single default project (see §7).
|
||||
With slug-only identity (Decision 3) there is no per-project number to
|
||||
allocate: `api_graduation.py`'s `RFC-NNNN` allocator is **retired**, and
|
||||
graduation reduces to the status flip + content-repo move, keyed on
|
||||
`(project_id, slug)`.
|
||||
|
||||
New tables:
|
||||
|
||||
```
|
||||
projects(
|
||||
id TEXT PRIMARY KEY, -- 'ohm'
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL -- document | specification | bdd (immutable)
|
||||
CHECK (type IN ('document','specification','bdd')),
|
||||
initial_state TEXT NOT NULL -- super-draft | active (landing state, §types)
|
||||
DEFAULT 'super-draft' CHECK (initial_state IN ('super-draft','active')),
|
||||
content_repo TEXT NOT NULL,
|
||||
visibility TEXT CHECK (visibility IN ('gated','public','unlisted')),
|
||||
config_json TEXT, -- theme, tagline, enabled_models, …
|
||||
registry_sha TEXT, -- provenance of the mirrored row
|
||||
updated_at TEXT
|
||||
)
|
||||
|
||||
project_members(
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
role TEXT CHECK (role IN ('project_admin','project_contributor','project_viewer')),
|
||||
granted_by INTEGER REFERENCES users(id),
|
||||
granted_at TEXT,
|
||||
PRIMARY KEY (project_id, user_id)
|
||||
)
|
||||
```
|
||||
|
||||
## 3. Roles — three tiers
|
||||
|
||||
A **middle tier** slots between today's deployment roles and per-RFC authority.
|
||||
|
||||
| Tier | Who | Powers |
|
||||
|---|---|---|
|
||||
| **Deployment** (unchanged, narrowed) | `owner` / `admin` | Create/archive projects, manage all accounts, act in any project. The beta/`permission_state` gate stays here — it gates *having an account*, not project access. A plain authenticated user has an account but no implicit project powers. |
|
||||
| **Project** (NEW — `project_members`) | `project_admin` / `project_contributor` / `project_viewer` | `project_admin` = today's app-admin, scoped to one project (manage its membership, settings, graduate, act on any RFC in it). `project_contributor` = propose/branch/PR/chat. `project_viewer` = read + discuss only. |
|
||||
| **RFC** (unchanged) | frontmatter `owners`/`arbiters`; `rfc_collaborators` (`contributor`/`discussant`) | Same as today, now scoped within their project. |
|
||||
|
||||
This is the existing owner → admin → contributor delegation pattern with a
|
||||
project axis added. `users.role` reverts to meaning *deployment*-level only.
|
||||
|
||||
**Visibility interaction:**
|
||||
|
||||
- **gated** (default) — invisible to non-members; must be a member to see it
|
||||
exists. Read and write both require membership.
|
||||
- **public** — any authenticated user can read; contributing requires a
|
||||
`project_contributor` grant.
|
||||
- **unlisted** — readable by direct link, not shown in the directory.
|
||||
|
||||
> Philosophy tension to resolve in SPEC: today the app is open-by-default
|
||||
> (anonymous read, §11.1; admission gates only writing). Gated-by-default
|
||||
> reverses that for the common case. The `public`/`unlisted` modes preserve the
|
||||
> old behavior for projects that want it, and the deployment can choose its own
|
||||
> default posture — but §11 and §14 need rewriting to make "gated" the baseline
|
||||
> and anonymous read a per-project opt-in.
|
||||
|
||||
**Discovery for gated projects:** since a stranger sees an empty directory,
|
||||
there must be a join path — invite-only (a `project_admin` adds you), or a
|
||||
request-to-join surface analogous to the existing §28 contribution-request
|
||||
flow. (Open — pick one.)
|
||||
|
||||
## 4. Branding & frontend (forced change)
|
||||
|
||||
The one *forced* change. `VITE_APP_NAME` is baked at build time; you cannot
|
||||
bake N project names into one bundle. Branding moves to **runtime config
|
||||
served by the backend**:
|
||||
|
||||
- `GET /api/deployment` → deployment name/tagline + the list of projects the
|
||||
caller can see (gated ones filtered by membership).
|
||||
- `GET /api/projects/:id` → that project's name, tagline, philosophy, theme
|
||||
tokens.
|
||||
- Frontend reads these instead of `import.meta.env.VITE_APP_NAME`
|
||||
(`App.jsx:208`, `Landing.jsx:16`, `BetaPending.jsx:17`).
|
||||
|
||||
`VITE_APP_NAME` is deprecated → deployment name comes from the registry. This
|
||||
is a documented config change with upgrade steps per CLAUDE.md.
|
||||
|
||||
Two chrome layers result:
|
||||
|
||||
- **Deployment chrome** — the Wiggleverse header, the project directory /
|
||||
landing, a project switcher.
|
||||
- **Project chrome** — the current header / catalog / philosophy, now per
|
||||
project; theme tokens (`tokens.css`) overridable per project at runtime.
|
||||
|
||||
## 5. Routing & UX
|
||||
|
||||
- Entry routes gain a project prefix with a **generic `/e/` segment**:
|
||||
`/p/<project>/e/<slug>`, `/p/<project>/proposals/<n>`,
|
||||
`/p/<project>/philosophy`, etc. The segment is the same for every type; the
|
||||
noun shown around the slug is a type-driven label, not part of the path.
|
||||
- Root `/` becomes the **deployment landing = project directory** (the
|
||||
Wiggleverse home). For an anonymous or non-member visitor under gated
|
||||
default, that's only public/unlisted-by-link projects.
|
||||
- Breadcrumb gains a segment: `Wiggleverse / OHM / Human › main` (the entry
|
||||
is named by its slug; the entry-noun the chrome uses around it is
|
||||
type-driven — "RFC", "Spec", "Feature").
|
||||
- The left-pane catalog (§7) becomes per-project; a project switcher lives in
|
||||
deployment chrome.
|
||||
|
||||
## 6. Cross-cutting — notifications, inbox, watches
|
||||
|
||||
Accounts are deployment-wide, so there is **one inbox spanning projects**
|
||||
(§15). `notifications` and `watches` carry `project_id` so the inbox is
|
||||
filterable and a user can mute an entire project. Quiet hours / digest prefs
|
||||
stay per-account (deployment level).
|
||||
|
||||
## 7. Backward compatibility & migration
|
||||
|
||||
The N=1 path keeps existing single-project deployments working:
|
||||
|
||||
1. Migration creates one **default project** from current config (`META_REPO`
|
||||
→ `content_repo`, `VITE_APP_NAME` → `name`, visibility seeded to match the
|
||||
deployment's current open posture, likely `public`). Its `id` is a
|
||||
config-derived slug (`DEFAULT_PROJECT_ID`, else slug of the deployment
|
||||
name, else `default`); M1's `default` bootstrap id is re-stamped to it in
|
||||
M3 before any `/p/` URL is public.
|
||||
2. Every existing row's `project_id` is stamped to that default project.
|
||||
3. An optional default-project redirect keeps old `/rfc/<slug>` URLs alive
|
||||
(308 → `/p/<default-id>/e/<slug>`).
|
||||
|
||||
This is the SPEC §20 upgrade-steps block.
|
||||
|
||||
## 8. SPEC & versioning impact
|
||||
|
||||
Touches §1, §2, §5, §6, §7, §8, §13, §14, §17, §20. Pre-1.0 minor with
|
||||
breaking changes spelled out (schema migration, `VITE_APP_NAME` deprecation,
|
||||
URL change, gated-default philosophy shift). Single-process SQLite stays fine —
|
||||
`project_id` is just a column; no DB-per-project, no Postgres forced.
|
||||
|
||||
## Operator-tooling integration (flotilla)
|
||||
|
||||
Checked against the `ohm-rfc-app-flotilla` spec (the OHM deployment's operator
|
||||
control panel). It assembles a deployment from `{rfc-app@pin} + {non-secret
|
||||
overlay} + {secret pulls} + {corpus}`, **does not host the corpus** ("the
|
||||
corpus lives in a deployment-side repo"), and **depends on the framework only
|
||||
through versioned contracts** (`/api/health`, `VERSION`, CHANGELOG
|
||||
upgrade-steps, the `.rfc-app-version` pin). The framework knows nothing about
|
||||
flotilla.
|
||||
|
||||
This confirms the registry decision and fixes how it integrates:
|
||||
|
||||
- The registry is **deployment-side git content the framework reads** — same
|
||||
category as the corpus — *not* a flotilla-owned config blob. Putting
|
||||
project definitions in operator tooling would re-bake deployment specifics
|
||||
into the assembly layer and add a new framework⇄tool contract.
|
||||
- The framework reads the registry via a `REGISTRY_REPO` env var, the
|
||||
multi-project successor to `META_REPO`. Flotilla's overlay simply swaps one
|
||||
ref; its four versioned contracts are untouched.
|
||||
- Multi-project therefore ships to OHM as an **ordinary pinned-version
|
||||
upgrade**: bump the pin, run the §22.13 default-project migration, change
|
||||
`META_REPO`→`REGISTRY_REPO` in the overlay, deploy, verify via
|
||||
`/api/health`. No flotilla architectural change.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Gated discovery: invite-only vs. request-to-join surface? (drafted with
|
||||
both; M5 can ship invite-only first.)
|
||||
- Does the deployment landing itself need branding config, or is it derived
|
||||
entirely from the registry `deployment:` block?
|
||||
- Per-project `ENABLED_MODELS` resolution vs. deployment universe (§18, §6.6,
|
||||
§6.7 funder) — confirm fallback order.
|
||||
- Slicing plan for the build (mirrors DEV.md's original slice approach).
|
||||
- **Type surfaces — depth of each.** What concretely is in the
|
||||
`specification` *release-planning* surface (its own tables? a release =
|
||||
a tag + a changelog entry + a set of graduated entries?), and does the
|
||||
`bdd` scenario model stay free-form markdown or get a structured
|
||||
Given/When/Then schema the app parses? Drafted shallow; pin before the
|
||||
type-surface slice.
|
||||
- **Entry-noun in URLs/labels** — *resolved 2026-06-02:* generic route
|
||||
segment `/p/<project>/e/<slug>` for every type; the displayed noun
|
||||
("RFC"/"Spec"/"Feature") is a type-driven label, not part of the path
|
||||
(§22.10, §22.4a).
|
||||
- **Existing graduated numbers** — *resolved 2026-06-02:* pre-change graduated
|
||||
entries keep their `RFC-NNNN` `id` in frontmatter as a frozen, read-only
|
||||
legacy display label (preserves citations); never used for lookup, never
|
||||
assigned to new entries (§22.4).
|
||||
- **Default project `id`** — *resolved 2026-06-02:* a config-derived slug
|
||||
(`DEFAULT_PROJECT_ID`, else slug of the deployment name, else `default`);
|
||||
M1's `default` bootstrap id is re-stamped in M3 before any `/p/` URL is
|
||||
public, so it's meaningful (e.g. `/p/ohm/`) and never renamed live (§22.13).
|
||||
@@ -0,0 +1,823 @@
|
||||
# M3-0 — Test & Local-Env Foundation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Stand up the handbook §10.3 **Tier-1 local-Docker test foundation** for rfc-app — a `docker compose` stack (backend + nginx-served SPA + seeded real Gitea + Mailpit), a Vitest frontend-unit setup, and an environment-agnostic Playwright e2e harness with one passing smoke spec — so every later M3 sub-plan can be verified at unit/integration/functional/e2e levels on localhost (and against PPE later by changing `BASE_URL`).
|
||||
|
||||
**Architecture:** A four-service compose stack. The backend (FastAPI, single uvicorn process, SQLite, migrations on startup) and an nginx container serving the built SPA + proxying `/api` and `/auth` — mirroring prod. A **real, disposable Gitea** container, seeded fresh each run by a one-shot seed service (admin + bot token + OAuth app + org + content repo + webhook), chosen so the SAME e2e suite behaves identically in Tier 1 and Tier 2/PPE (§10.3). **Mailpit** as the mail sink; e2e logs in via the email **OTC** flow (`/auth/otc/request` → read code from Mailpit's API → `/auth/otc/verify`), which needs no Gitea OAuth consent scripting. Playwright is parameterized by `BASE_URL` + `MAILSINK_URL` so the unchanged suite later targets PPE.
|
||||
|
||||
**Tech Stack:** Docker Compose, Gitea (pinned image), Mailpit, nginx, Python 3.11/uvicorn, Vite/React 19, Vitest + @testing-library/react, Playwright (@playwright/test).
|
||||
|
||||
**Conventions (Wiggleverse):** SSH git transport; no inline comments trailing CLI commands; commit messages end with the `Co-Authored-By` trailer. Branch off `main` — do **not** work on `main`. Suggested branch: `feat/m3-0-test-foundation`.
|
||||
|
||||
**Pre-req:** This plan creates a new directory `testing/` at repo root for harness assets and `frontend/src/**/*.test.jsx` for unit tests. It does not touch backend app code except adding a Dockerfile.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
Files created/modified, by responsibility:
|
||||
|
||||
- `testing/docker-compose.yml` — the four-service Tier-1 stack (backend, web/nginx, gitea, mailpit) + the one-shot `gitea-seed` service.
|
||||
- `testing/backend.Dockerfile` — builds the backend image (Python 3.11 + requirements + app).
|
||||
- `testing/web.Dockerfile` — builds the SPA (node build stage) and serves it via nginx (runtime stage).
|
||||
- `testing/web.nginx.conf` — nginx config for the web container (SPA fallback + `/api` `/auth` proxy to backend). Adapted from `deploy/nginx/ohm.wiggleverse.org.conf`, TLS stripped.
|
||||
- `testing/seed-gitea.sh` — idempotent seed script: admin user, bot user + token, OAuth app, org, content repo (seeded with `rfcs/`), webhook.
|
||||
- `testing/.env.tier1` — the env values the compose stack injects into the backend.
|
||||
- `testing/README.md` — how to run Tier 1 locally and how to point the suite at PPE.
|
||||
- `frontend/vitest.config.js` — Vitest config (jsdom env).
|
||||
- `frontend/src/test/setup.js` — testing-library/jsdom setup.
|
||||
- `frontend/src/lib/brand.js` + `frontend/src/lib/brand.test.js` — a tiny first unit-tested module (proves Vitest wiring; reused by M3c).
|
||||
- `frontend/package.json` — add devDeps + `test`, `test:run` scripts (modify).
|
||||
- `e2e/playwright.config.js` — Playwright config; `baseURL` from `BASE_URL`, mail sink from `MAILSINK_URL`.
|
||||
- `e2e/lib/mailpit.js` — helper to read the latest OTC email from Mailpit's API.
|
||||
- `e2e/smoke.spec.js` — the one smoke spec (OTC login → landing renders).
|
||||
- `e2e/package.json` — Playwright dep + `e2e` script (kept separate from the app frontend deps).
|
||||
- `Makefile` (repo root) — `tier1-up`, `tier1-down`, `e2e`, `fe-unit` convenience targets (modify or create).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Frontend unit testing (Vitest) — independent quick win
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/package.json`
|
||||
- Create: `frontend/vitest.config.js`
|
||||
- Create: `frontend/src/test/setup.js`
|
||||
- Create: `frontend/src/lib/brand.js`
|
||||
- Test: `frontend/src/lib/brand.test.js`
|
||||
|
||||
- [ ] **Step 1: Add Vitest dev dependencies and scripts**
|
||||
|
||||
Modify `frontend/package.json` — add to `devDependencies`:
|
||||
|
||||
```json
|
||||
"vitest": "^3.0.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/jest-dom": "^6.6.0"
|
||||
```
|
||||
|
||||
Add to `scripts`:
|
||||
|
||||
```json
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Install**
|
||||
|
||||
Run: `cd frontend && npm install`
|
||||
Expected: lockfile updates, `node_modules/.bin/vitest` exists.
|
||||
|
||||
- [ ] **Step 3: Create the Vitest config**
|
||||
|
||||
Create `frontend/vitest.config.js`:
|
||||
|
||||
```js
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.js'],
|
||||
include: ['src/**/*.test.{js,jsx}'],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create the test setup file**
|
||||
|
||||
Create `frontend/src/test/setup.js`:
|
||||
|
||||
```js
|
||||
import '@testing-library/jest-dom'
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Write the failing unit test**
|
||||
|
||||
Create `frontend/src/lib/brand.test.js`:
|
||||
|
||||
```js
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { brandTitle } from './brand.js'
|
||||
|
||||
describe('brandTitle', () => {
|
||||
it('returns the deployment name when set', () => {
|
||||
expect(brandTitle('Wiggleverse')).toBe('Wiggleverse')
|
||||
})
|
||||
|
||||
it('falls back to a neutral placeholder when name is empty', () => {
|
||||
expect(brandTitle('')).toBe('RFC')
|
||||
expect(brandTitle(undefined)).toBe('RFC')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run it to verify it fails**
|
||||
|
||||
Run: `cd frontend && npm run test:run -- src/lib/brand.test.js`
|
||||
Expected: FAIL — `Failed to resolve import "./brand.js"` (module does not exist yet).
|
||||
|
||||
- [ ] **Step 7: Implement the minimal module**
|
||||
|
||||
Create `frontend/src/lib/brand.js`:
|
||||
|
||||
```js
|
||||
export function brandTitle(name) {
|
||||
const trimmed = (name || '').trim()
|
||||
return trimmed || 'RFC'
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Run it to verify it passes**
|
||||
|
||||
Run: `cd frontend && npm run test:run -- src/lib/brand.test.js`
|
||||
Expected: PASS — 2 tests pass.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/package.json frontend/package-lock.json frontend/vitest.config.js frontend/src/test/setup.js frontend/src/lib/brand.js frontend/src/lib/brand.test.js
|
||||
git commit -m "test(frontend): add Vitest unit-test harness with first brand helper
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Backend Docker image
|
||||
|
||||
**Files:**
|
||||
- Create: `testing/backend.Dockerfile`
|
||||
- Create: `testing/.env.tier1`
|
||||
|
||||
- [ ] **Step 1: Write the backend Dockerfile**
|
||||
|
||||
Create `testing/backend.Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
COPY backend/requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt uvicorn
|
||||
|
||||
COPY backend/ /app/
|
||||
|
||||
RUN mkdir -p /data
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
Note: build context is the repo root (set in compose), so `COPY backend/...` resolves.
|
||||
|
||||
- [ ] **Step 2: Write the backend env file**
|
||||
|
||||
Create `testing/.env.tier1`:
|
||||
|
||||
```
|
||||
GITEA_URL=http://gitea:3000
|
||||
GITEA_BOT_USER=rfc-bot
|
||||
GITEA_BOT_TOKEN=tier1-bot-token-PLACEHOLDER
|
||||
GITEA_ORG=wiggleverse
|
||||
META_REPO=ohm-content
|
||||
REGISTRY_REPO=
|
||||
OAUTH_CLIENT_ID=tier1-oauth-client-PLACEHOLDER
|
||||
OAUTH_CLIENT_SECRET=tier1-oauth-secret-PLACEHOLDER
|
||||
APP_URL=http://localhost:8080
|
||||
SECRET_KEY=tier1-not-secret
|
||||
DATABASE_PATH=/data/rfc-app.db
|
||||
OWNER_GITEA_LOGIN=owner
|
||||
GITEA_WEBHOOK_SECRET=tier1-webhook-secret
|
||||
ENABLED_MODELS=claude
|
||||
SMTP_HOST=mailpit
|
||||
SMTP_PORT=1025
|
||||
SMTP_STARTTLS=false
|
||||
EMAIL_FROM=rfc@example.test
|
||||
EMAIL_FROM_NAME=RFC Tier1
|
||||
EMAIL_ENABLED=true
|
||||
TURNSTILE_REQUIRED=false
|
||||
```
|
||||
|
||||
The `*-PLACEHOLDER` token/oauth values are overwritten at runtime by the seed step (Task 4) which writes the real values into `testing/.env.tier1.generated`; compose loads both files (Task 5), generated last so it wins. Leaving the placeholders here documents the full contract and lets the backend start to fail loudly if seeding was skipped.
|
||||
|
||||
- [ ] **Step 3: Verify the image builds**
|
||||
|
||||
Run: `docker build -f testing/backend.Dockerfile -t rfc-backend:tier1 .`
|
||||
Expected: image builds, no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add testing/backend.Dockerfile testing/.env.tier1
|
||||
git commit -m "test(tier1): backend Docker image + env contract
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Web (nginx + built SPA) Docker image
|
||||
|
||||
**Files:**
|
||||
- Create: `testing/web.Dockerfile`
|
||||
- Create: `testing/web.nginx.conf`
|
||||
|
||||
- [ ] **Step 1: Write the nginx config (adapted from prod, TLS stripped)**
|
||||
|
||||
Create `testing/web.nginx.conf`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
location /auth/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the web Dockerfile (multi-stage build → nginx)**
|
||||
|
||||
Create `testing/web.Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
FROM node:20-slim AS build
|
||||
WORKDIR /app
|
||||
COPY frontend/package.json frontend/package-lock.json /app/
|
||||
RUN npm ci
|
||||
COPY frontend/ /app/
|
||||
ENV VITE_APP_NAME="RFC Tier1"
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY testing/web.nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
```
|
||||
|
||||
Note: `VITE_APP_NAME` is still build-required until M3c does the hard cut (`frontend/vite.config.js` throws without it). Supplying a test value keeps the build green now; M3c removes this line.
|
||||
|
||||
- [ ] **Step 3: Verify the image builds**
|
||||
|
||||
Run: `docker build -f testing/web.Dockerfile -t rfc-web:tier1 .`
|
||||
Expected: build succeeds; the SPA compiles with the test brand.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add testing/web.Dockerfile testing/web.nginx.conf
|
||||
git commit -m "test(tier1): nginx web image serving the built SPA
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Gitea seed script
|
||||
|
||||
**Files:**
|
||||
- Create: `testing/seed-gitea.sh`
|
||||
|
||||
This script runs inside a small `curl`+`git`-capable container (the `gitea-seed` service, Task 5). It assumes Gitea is reachable at `http://gitea:3000` with install-lock on and a known admin password from env. It is idempotent: every create tolerates "already exists".
|
||||
|
||||
- [ ] **Step 1: Write the seed script**
|
||||
|
||||
Create `testing/seed-gitea.sh`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
GITEA="${GITEA_URL:-http://gitea:3000}"
|
||||
ADMIN_USER="${GITEA_ADMIN_USER:-giteaadmin}"
|
||||
ADMIN_PASS="${GITEA_ADMIN_PASSWORD:-giteaadmin-pass}"
|
||||
ADMIN_EMAIL="${GITEA_ADMIN_EMAIL:-admin@example.test}"
|
||||
ORG="${GITEA_ORG:-wiggleverse}"
|
||||
BOT_USER="${GITEA_BOT_USER:-rfc-bot}"
|
||||
BOT_PASS="${GITEA_BOT_PASSWORD:-rfc-bot-pass}"
|
||||
CONTENT_REPO="${META_REPO:-ohm-content}"
|
||||
APP_URL="${APP_URL:-http://localhost:8080}"
|
||||
WEBHOOK_SECRET="${GITEA_WEBHOOK_SECRET:-tier1-webhook-secret}"
|
||||
OUT="${SEED_OUT:-/seed/.env.tier1.generated}"
|
||||
|
||||
echo "seed: waiting for gitea at $GITEA"
|
||||
i=0
|
||||
while ! curl -sf "$GITEA/api/healthz" >/dev/null 2>&1; do
|
||||
i=$((i+1)); [ "$i" -gt 60 ] && echo "gitea never came up" && exit 1
|
||||
sleep 2
|
||||
done
|
||||
|
||||
auth_admin() { curl -sf -u "$ADMIN_USER:$ADMIN_PASS" "$@"; }
|
||||
|
||||
echo "seed: ensuring bot user"
|
||||
auth_admin -X POST "$GITEA/api/v1/admin/users" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"username\":\"$BOT_USER\",\"email\":\"$BOT_USER@example.test\",\"password\":\"$BOT_PASS\",\"must_change_password\":false}" \
|
||||
|| echo "seed: bot user exists, continuing"
|
||||
|
||||
echo "seed: ensuring owner user (for OWNER_GITEA_LOGIN)"
|
||||
auth_admin -X POST "$GITEA/api/v1/admin/users" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"username\":\"owner\",\"email\":\"owner@example.test\",\"password\":\"owner-pass\",\"must_change_password\":false}" \
|
||||
|| echo "seed: owner exists, continuing"
|
||||
|
||||
echo "seed: minting bot access token"
|
||||
TOKEN=$(curl -sf -u "$BOT_USER:$BOT_PASS" -X POST "$GITEA/api/v1/users/$BOT_USER/tokens" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"tier1-bot","scopes":["write:repository","write:organization","write:user","write:admin"]}' \
|
||||
| sed -n 's/.*"sha1":"\([^"]*\)".*/\1/p')
|
||||
[ -n "$TOKEN" ] || { echo "seed: failed to mint bot token" ; exit 1; }
|
||||
|
||||
echo "seed: ensuring org $ORG (owned by bot)"
|
||||
curl -sf -H "Authorization: token $TOKEN" -X POST "$GITEA/api/v1/orgs" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"username\":\"$ORG\"}" || echo "seed: org exists, continuing"
|
||||
|
||||
echo "seed: ensuring content repo $ORG/$CONTENT_REPO"
|
||||
curl -sf -H "Authorization: token $TOKEN" -X POST "$GITEA/api/v1/orgs/$ORG/repos" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"name\":\"$CONTENT_REPO\",\"auto_init\":true,\"default_branch\":\"main\"}" \
|
||||
|| echo "seed: content repo exists, continuing"
|
||||
|
||||
echo "seed: seeding one entry under rfcs/ so the catalog is non-empty"
|
||||
B64=$(printf '%s' '---
|
||||
title: Intro
|
||||
status: graduated
|
||||
id: RFC-0001
|
||||
owners: [owner]
|
||||
---
|
||||
|
||||
# Intro
|
||||
|
||||
Seed entry for Tier-1 e2e.
|
||||
' | base64 | tr -d '\n')
|
||||
curl -s -H "Authorization: token $TOKEN" -X POST \
|
||||
"$GITEA/api/v1/repos/$ORG/$CONTENT_REPO/contents/rfcs/intro.md" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"message\":\"seed intro\",\"content\":\"$B64\",\"branch\":\"main\"}" \
|
||||
|| echo "seed: intro.md exists, continuing"
|
||||
|
||||
echo "seed: registering OAuth application"
|
||||
OAUTH_JSON=$(curl -sf -u "$ADMIN_USER:$ADMIN_PASS" -X POST "$GITEA/api/v1/user/applications/oauth2" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"name\":\"rfc-app-tier1\",\"redirect_uris\":[\"$APP_URL/auth/callback\"],\"confidential_client\":true}")
|
||||
CLIENT_ID=$(printf '%s' "$OAUTH_JSON" | sed -n 's/.*"client_id":"\([^"]*\)".*/\1/p')
|
||||
CLIENT_SECRET=$(printf '%s' "$OAUTH_JSON" | sed -n 's/.*"client_secret":"\([^"]*\)".*/\1/p')
|
||||
|
||||
echo "seed: registering webhook on content repo -> backend"
|
||||
curl -s -H "Authorization: token $TOKEN" -X POST \
|
||||
"$GITEA/api/v1/repos/$ORG/$CONTENT_REPO/hooks" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"type\":\"gitea\",\"active\":true,\"events\":[\"push\",\"pull_request\"],\"config\":{\"url\":\"http://backend:8000/api/webhooks/gitea\",\"content_type\":\"json\",\"secret\":\"$WEBHOOK_SECRET\"}}" \
|
||||
|| echo "seed: webhook exists, continuing"
|
||||
|
||||
echo "seed: writing generated env to $OUT"
|
||||
cat > "$OUT" <<EOF
|
||||
GITEA_BOT_TOKEN=$TOKEN
|
||||
OAUTH_CLIENT_ID=$CLIENT_ID
|
||||
OAUTH_CLIENT_SECRET=$CLIENT_SECRET
|
||||
EOF
|
||||
echo "seed: done"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Make it executable**
|
||||
|
||||
Run: `chmod +x testing/seed-gitea.sh`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add testing/seed-gitea.sh
|
||||
git commit -m "test(tier1): idempotent Gitea seed script (bot token, org, content repo, OAuth app, webhook)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
> Verification of this script happens in Task 5 against the real Gitea image. The Gitea admin user is created by the gitea service's own init env (Task 5), so the script can authenticate as admin from its first call. If a Gitea-version API mismatch appears (e.g. the token `scopes` vocabulary, or the OAuth-app endpoint path), fix it against the pinned image `gitea/gitea:1.22` and keep the script idempotent.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Compose the stack and bring it up
|
||||
|
||||
**Files:**
|
||||
- Create: `testing/docker-compose.yml`
|
||||
- Create/modify: `Makefile`
|
||||
|
||||
- [ ] **Step 1: Write the compose file**
|
||||
|
||||
Create `testing/docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
name: rfc-tier1
|
||||
|
||||
services:
|
||||
gitea:
|
||||
image: gitea/gitea:1.22
|
||||
environment:
|
||||
GITEA__security__INSTALL_LOCK: "true"
|
||||
GITEA__server__ROOT_URL: "http://gitea:3000/"
|
||||
GITEA__server__HTTP_PORT: "3000"
|
||||
GITEA__database__DB_TYPE: "sqlite3"
|
||||
GITEA__webhook__ALLOWED_HOST_LIST: "*"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:3000/api/healthz"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
ports:
|
||||
- "3001:3000"
|
||||
|
||||
gitea-admin-init:
|
||||
image: gitea/gitea:1.22
|
||||
depends_on:
|
||||
gitea:
|
||||
condition: service_healthy
|
||||
volumes_from:
|
||||
- gitea
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- >
|
||||
gitea admin user create --admin --username giteaadmin
|
||||
--password giteaadmin-pass --email admin@example.test
|
||||
--must-change-password=false || true
|
||||
restart: "no"
|
||||
|
||||
gitea-seed:
|
||||
image: alpine:3.20
|
||||
depends_on:
|
||||
gitea-admin-init:
|
||||
condition: service_completed_successfully
|
||||
env_file:
|
||||
- .env.tier1
|
||||
environment:
|
||||
GITEA_ADMIN_USER: giteaadmin
|
||||
GITEA_ADMIN_PASSWORD: giteaadmin-pass
|
||||
GITEA_BOT_PASSWORD: rfc-bot-pass
|
||||
SEED_OUT: /seed/.env.tier1.generated
|
||||
volumes:
|
||||
- ./seed-gitea.sh:/seed-gitea.sh:ro
|
||||
- ./generated:/seed
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- apk add --no-cache curl >/dev/null && sh /seed-gitea.sh
|
||||
restart: "no"
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: testing/backend.Dockerfile
|
||||
depends_on:
|
||||
gitea-seed:
|
||||
condition: service_completed_successfully
|
||||
env_file:
|
||||
- .env.tier1
|
||||
- ./generated/.env.tier1.generated
|
||||
volumes:
|
||||
- backend-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/api/health').status==200 else 1)"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: testing/web.Dockerfile
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8080:80"
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
ports:
|
||||
- "8025:8025"
|
||||
- "1025:1025"
|
||||
|
||||
volumes:
|
||||
backend-data:
|
||||
```
|
||||
|
||||
Notes: the backend reads `.env.tier1` then `./generated/.env.tier1.generated` (seed-written), so the real bot token / OAuth client overwrite the placeholders. `APP_URL=http://localhost:8080` matches the `web` published port and the OAuth redirect URI the seed registers. Mailpit API is on `8025`, SMTP on `1025`.
|
||||
|
||||
- [ ] **Step 2: Create the generated dir placeholder**
|
||||
|
||||
Run: `mkdir -p testing/generated && touch testing/generated/.gitkeep`
|
||||
|
||||
Create `testing/generated/.gitignore`:
|
||||
|
||||
```
|
||||
.env.tier1.generated
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add Makefile targets**
|
||||
|
||||
Create (or append to) `Makefile` at repo root:
|
||||
|
||||
```makefile
|
||||
tier1-up:
|
||||
docker compose -f testing/docker-compose.yml up --build -d
|
||||
|
||||
tier1-down:
|
||||
docker compose -f testing/docker-compose.yml down -v
|
||||
|
||||
tier1-logs:
|
||||
docker compose -f testing/docker-compose.yml logs -f
|
||||
|
||||
fe-unit:
|
||||
cd frontend && npm run test:run
|
||||
|
||||
e2e:
|
||||
cd e2e && BASE_URL=$${BASE_URL:-http://localhost:8080} MAILSINK_URL=$${MAILSINK_URL:-http://localhost:8025} npm run e2e
|
||||
```
|
||||
|
||||
(Use real tabs for Makefile recipes, not spaces.)
|
||||
|
||||
- [ ] **Step 4: Bring the stack up**
|
||||
|
||||
Run: `make tier1-up`
|
||||
Expected: gitea → admin-init → seed → backend (healthy) → web come up in order. `docker compose -f testing/docker-compose.yml ps` shows backend healthy.
|
||||
|
||||
- [ ] **Step 5: Verify the app is reachable through nginx**
|
||||
|
||||
Run: `curl -sf http://localhost:8080/api/health`
|
||||
Expected: HTTP 200 with the version JSON (proves web→backend proxy + migrations-on-startup worked).
|
||||
|
||||
Run: `curl -sf http://localhost:8080/ | grep -i "<title"`
|
||||
Expected: the SPA `index.html` is served (title present).
|
||||
|
||||
- [ ] **Step 6: Verify seeding produced real credentials**
|
||||
|
||||
Run: `cat testing/generated/.env.tier1.generated`
|
||||
Expected: non-placeholder `GITEA_BOT_TOKEN=`, `OAUTH_CLIENT_ID=`, `OAUTH_CLIENT_SECRET=` lines.
|
||||
|
||||
- [ ] **Step 7: Tear down and commit**
|
||||
|
||||
Run: `make tier1-down`
|
||||
|
||||
```bash
|
||||
git add testing/docker-compose.yml testing/generated/.gitignore Makefile
|
||||
git commit -m "test(tier1): docker compose stack (gitea seed + backend + web + mailpit)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Playwright harness + mail-sink helper
|
||||
|
||||
**Files:**
|
||||
- Create: `e2e/package.json`
|
||||
- Create: `e2e/playwright.config.js`
|
||||
- Create: `e2e/lib/mailpit.js`
|
||||
|
||||
- [ ] **Step 1: Create the e2e package**
|
||||
|
||||
Create `e2e/package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "rfc-e2e",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"e2e": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Install Playwright + its browser**
|
||||
|
||||
Run: `cd e2e && npm install && npx playwright install chromium`
|
||||
Expected: `@playwright/test` installed; chromium downloaded.
|
||||
|
||||
- [ ] **Step 3: Write the Playwright config**
|
||||
|
||||
Create `e2e/playwright.config.js`:
|
||||
|
||||
```js
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 10_000 },
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:8080',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
reporter: [['list']],
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write the Mailpit helper**
|
||||
|
||||
Create `e2e/lib/mailpit.js`:
|
||||
|
||||
```js
|
||||
const MAILSINK = process.env.MAILSINK_URL || 'http://localhost:8025'
|
||||
|
||||
export async function waitForLatestOtc(toAddress, { attempts = 20, delayMs = 500 } = {}) {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const res = await fetch(`${MAILSINK}/api/v1/messages`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const msg = (data.messages || []).find(
|
||||
(m) => (m.To || []).some((t) => t.Address === toAddress),
|
||||
)
|
||||
if (msg) {
|
||||
const full = await fetch(`${MAILSINK}/api/v1/message/${msg.ID}`)
|
||||
const body = await full.json()
|
||||
const text = `${body.Text || ''} ${body.HTML || ''}`
|
||||
const code = text.match(/\b(\d{6})\b/)
|
||||
if (code) return code[1]
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, delayMs))
|
||||
}
|
||||
throw new Error(`no OTC email for ${toAddress} arrived in Mailpit`)
|
||||
}
|
||||
|
||||
export async function clearMailpit() {
|
||||
await fetch(`${MAILSINK}/api/v1/messages`, { method: 'DELETE' })
|
||||
}
|
||||
```
|
||||
|
||||
Note: the `\d{6}` pattern assumes the OTC code is a 6-digit number. Confirm against `app/otc.py` / the OTC email template; adjust the regex if the real code shape differs (this is the one detail to verify when the spec first runs).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add e2e/package.json e2e/package-lock.json e2e/playwright.config.js e2e/lib/mailpit.js
|
||||
git commit -m "test(e2e): Playwright harness parameterized by BASE_URL + Mailpit mail sink
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: The smoke e2e spec (OTC login → landing renders)
|
||||
|
||||
**Files:**
|
||||
- Create: `e2e/smoke.spec.js`
|
||||
|
||||
- [ ] **Step 1: Write the smoke spec**
|
||||
|
||||
Create `e2e/smoke.spec.js`:
|
||||
|
||||
```js
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { waitForLatestOtc, clearMailpit } from './lib/mailpit.js'
|
||||
|
||||
const EMAIL = 'e2e-user@example.test'
|
||||
|
||||
test('app loads and an OTC sign-in succeeds', async ({ page, request }) => {
|
||||
await clearMailpit()
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page).toHaveTitle(/.+/)
|
||||
|
||||
const reqRes = await request.post('/auth/otc/request', {
|
||||
data: { email: EMAIL },
|
||||
})
|
||||
expect(reqRes.ok()).toBeTruthy()
|
||||
|
||||
const code = await waitForLatestOtc(EMAIL)
|
||||
|
||||
const verifyRes = await request.post('/auth/otc/verify', {
|
||||
data: { email: EMAIL, code },
|
||||
})
|
||||
expect(verifyRes.ok()).toBeTruthy()
|
||||
})
|
||||
```
|
||||
|
||||
Note: payload field names (`email`, `code`) must match `app/main.py`'s `/auth/otc/request` and `/auth/otc/verify` request models. Read those two handlers (around `app/main.py:259` and `:296`) and align field names before running. If OTC sign-in requires the account to be pre-provisioned or "granted", seed that state in the spec's setup (an admin call) or document the precondition; the e2e must end with an authenticated session cookie set on `page`'s context.
|
||||
|
||||
- [ ] **Step 2: Bring the stack up**
|
||||
|
||||
Run: `make tier1-up`
|
||||
Wait until `curl -sf http://localhost:8080/api/health` returns 200.
|
||||
|
||||
- [ ] **Step 3: Run the smoke spec — verify it passes**
|
||||
|
||||
Run: `make e2e`
|
||||
Expected: 1 passed. (If it fails on field names / OTC code shape / provisioning, fix per the notes in Step 1 and `e2e/lib/mailpit.js`, then re-run.)
|
||||
|
||||
- [ ] **Step 4: Tear down**
|
||||
|
||||
Run: `make tier1-down`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add e2e/smoke.spec.js
|
||||
git commit -m "test(e2e): smoke spec — app loads and OTC sign-in succeeds via Mailpit
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Documentation — running the tiers
|
||||
|
||||
**Files:**
|
||||
- Create: `testing/README.md`
|
||||
|
||||
- [ ] **Step 1: Write the harness README**
|
||||
|
||||
Create `testing/README.md`:
|
||||
|
||||
```markdown
|
||||
# Test harness (handbook §10.3 two-tier testing)
|
||||
|
||||
One environment-agnostic suite, two targets.
|
||||
|
||||
## Tier 1 — local Docker (every PR)
|
||||
|
||||
```sh
|
||||
make tier1-up # build + start: gitea(seeded) + backend + web(nginx) + mailpit
|
||||
make e2e # run Playwright against http://localhost:8080
|
||||
make fe-unit # run Vitest frontend unit tests
|
||||
make tier1-down # stop + wipe volumes
|
||||
```
|
||||
|
||||
- App (SPA + API): http://localhost:8080
|
||||
- Mailpit UI / API: http://localhost:8025
|
||||
- Gitea (disposable): http://localhost:3001
|
||||
|
||||
The stack is hermetic and disposable — fresh SQLite + fresh seeded Gitea each
|
||||
`tier1-up`. e2e signs in via the email OTC flow, reading the code back from
|
||||
Mailpit, so no real OAuth provider is needed.
|
||||
|
||||
## Tier 2 — PPE (deploy gate)
|
||||
|
||||
The SAME suite, pointed at the PPE instance (once `rfc-app-ppe.<base>` is stood
|
||||
up via flotilla — see the engineering handbook §10.1/§10.3):
|
||||
|
||||
```sh
|
||||
cd e2e && BASE_URL=https://rfc-app-ppe.<base> MAILSINK_URL=<ppe-mailpit-api> npm run e2e
|
||||
```
|
||||
|
||||
PPE provides the real nginx/systemd/SQLite topology + its own isolated Gitea +
|
||||
always-pass Turnstile keys. Standing up the PPE VM is an operator task, not part
|
||||
of this repo.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add testing/README.md
|
||||
git commit -m "docs(testing): how to run Tier-1 local Docker and Tier-2 PPE
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
- [ ] **Frontend unit:** `make fe-unit` → all pass.
|
||||
- [ ] **Stack health:** `make tier1-up` then `curl -sf http://localhost:8080/api/health` → 200.
|
||||
- [ ] **E2e:** `make e2e` → smoke spec passes.
|
||||
- [ ] **Disposability:** `make tier1-down && make tier1-up` → second bring-up is green from scratch (seed is idempotent / fresh-volume clean).
|
||||
- [ ] **Teardown:** `make tier1-down` leaves no running containers (`docker ps` clean).
|
||||
|
||||
When all five pass, M3-0 is complete and every later M3 sub-plan (M3a–M3d) can add unit/integration/functional tests under `backend/tests/` and e2e specs under `e2e/`, runnable on localhost now and against PPE by setting `BASE_URL`.
|
||||
|
||||
---
|
||||
|
||||
## Notes for the executor
|
||||
|
||||
- **Verify-against-reality points** (flagged inline, not placeholders): the Gitea `1.22` API specifics in `seed-gitea.sh` (token scopes vocabulary, OAuth-app endpoint), the OTC request/verify field names in `app/main.py`, the OTC code regex in `mailpit.js`, and whether OTC sign-in needs a pre-granted account. Each has a concrete first guess and a one-line "confirm against X" instruction.
|
||||
- **Stay off `main`.** Branch `feat/m3-0-test-foundation`.
|
||||
- **Do not** modify backend app logic in this plan — only `testing/` assets, `frontend/` test tooling, and `e2e/`. The one app-adjacent file is `testing/backend.Dockerfile`, which only packages existing code.
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,413 @@
|
||||
# M3-backend — §22 multi-project: registry mirror + data spine + APIs
|
||||
|
||||
> Design spec for the backend half of §22 slice **M3** ("Registry mirror +
|
||||
> routing + runtime branding"). The roadmap bundles M3 as one slice; this
|
||||
> session splits it at the natural backend/frontend seam. **M3-backend** (this
|
||||
> doc) ships the data spine, the registry mirror, the two runtime-config APIs,
|
||||
> and the entry-state/review semantics. **M3-frontend** (a separate spec) ships
|
||||
> `/p/<project>/` routing, the 308 redirects, the `VITE_APP_NAME`→runtime-config
|
||||
> cut, the per-project theme overlay, the deployment directory at `/`, and the
|
||||
> project switcher — all consuming the APIs defined here.
|
||||
>
|
||||
> Section references `§22.x` point at `docs/design/multi-project-spec.md` (the
|
||||
> draft §22 + slicing plan). The SPEC.md §22 merge itself lands in M7.
|
||||
|
||||
## Status
|
||||
|
||||
- **Date:** 2026-06-03
|
||||
- **Slice:** §22 M3 (backend half). M1 + M2 landed and merged to `main`.
|
||||
- **Version impact:** minor bump, breaking (pre-1.0) — see §8.
|
||||
|
||||
## Goal
|
||||
|
||||
After M3-backend, the framework learns its projects from a git **registry**
|
||||
(not from `META_REPO`), the `projects` cache table and all slug-bearing tables
|
||||
are keyed by `(project_id, …)` so a second project can exist without collision,
|
||||
the default project's identity is re-stamped to its real slug while no `/p/`
|
||||
URL is yet public, and the runtime exposes deployment + project config over two
|
||||
new endpoints. The entry-state/review semantics (`initial_state`, `unreviewed`)
|
||||
ship complete even though OHM (a `document`/`super-draft` project) does not yet
|
||||
exercise them.
|
||||
|
||||
Non-goals (M3-frontend, later): `/p/<project>/` routing, 308 redirects off
|
||||
`/rfc/<slug>`, runtime branding in the UI, theme application, the deployment
|
||||
directory, the project switcher, the catalog's unreviewed-filter **UI**.
|
||||
|
||||
## Decisions taken in brainstorming
|
||||
|
||||
1. **Scope split** — backend spine first; frontend surfaces are a separate
|
||||
spec/session.
|
||||
2. **Review machinery** — build the full `initial_state` / `unreviewed`
|
||||
plumbing now (parse + columns + mirror + landing logic + mark-reviewed +
|
||||
catalog filter query side), per the spec's M3 bundle, even though no live
|
||||
project exercises it yet.
|
||||
3. **Config cut** — hard cut. `REGISTRY_REPO` required (loud fail if unset),
|
||||
`META_REPO` removed. Upgrade-steps document the manual registry creation.
|
||||
4. **Re-stamp** — rewrite `project_id` everywhere: rename `projects.id`
|
||||
`default` → the config slug and rewrite every child row, folded into the
|
||||
same create-copy-drop-rename rebuild that adds the `project_id` FK. One
|
||||
identifier; DB and URL agree.
|
||||
5. **Mirror structure** — a self-contained `app/registry.py` module (not folded
|
||||
into `cache.py`), driven by the existing webhook dispatcher + the existing
|
||||
`Reconciler.sweep()`.
|
||||
6. **Two execution plans (found during planning).** The 12-table PK rebuild is
|
||||
not self-contained: folding `project_id` into keys + FK forces every
|
||||
`ON CONFLICT` upsert target to gain `project_id` (~10 sites across 6 modules)
|
||||
and — because the rebuilt tables can no longer default `project_id` to a live
|
||||
value once the default is re-stamped — forces **every RFC/branch writer** to
|
||||
be threaded to supply the real `project_id`. That activation is larger and
|
||||
riskier than the rest of M3-backend combined, and it is only required *before
|
||||
a second project can collide* (i.e. right before M4). So M3-backend is split
|
||||
into two plans at that seam:
|
||||
- **Plan A (ships first):** registry mirror + the two APIs + `initial_state`/
|
||||
`unreviewed` semantics. Migration `027` is **additive only** (no rebuilds).
|
||||
Operates entirely on the `default`-id project — no re-stamp, no rebuild, no
|
||||
writer threading. `cached_rfcs` keeps its `slug` PK, so no upsert breakage.
|
||||
- **Plan B (before M4 / before public `/p/` URLs):** the 12-table PK rebuild
|
||||
(migration `028`), `project_id` threading through every writer, the
|
||||
`default`→slug **re-stamp** (which rides here because it is only correct
|
||||
once the rebuild's column-default fix + threading land), and two-project
|
||||
isolation tests.
|
||||
|
||||
The §1 sections below describe the **full** backend (both plans); §1c (the
|
||||
rebuilds) and §1d (the re-stamp) are **Plan B**. Everything else is Plan A.
|
||||
|
||||
---
|
||||
|
||||
## 1. Migration `027_projects_activate.sql`
|
||||
|
||||
Runs while `default` is still the sole project and no `/p/` URL exists — the
|
||||
safe window for an identity rewrite. One transaction (SQLite DDL is
|
||||
transactional): the deployment either fully advances to `027` or stays on `026`.
|
||||
|
||||
`DEFAULT_PROJECT_ID` is read at migration time, so it **must be set before the
|
||||
upgrade deploy** (documented in §8). The slug it names is referred to below as
|
||||
`<slug>`; absent the env var, `<slug>` stays `default`.
|
||||
|
||||
### 1a. Additive columns
|
||||
|
||||
- `projects`:
|
||||
- `type TEXT NOT NULL DEFAULT 'document' CHECK (type IN ('document','specification','bdd'))`
|
||||
- `initial_state TEXT NOT NULL DEFAULT 'super-draft' CHECK (initial_state IN ('super-draft','active'))`
|
||||
- `cached_rfcs`:
|
||||
- `unreviewed INTEGER NOT NULL DEFAULT 0`
|
||||
- `reviewed_at TEXT`
|
||||
- `reviewed_by TEXT`
|
||||
|
||||
### 1b. New `deployment` singleton table
|
||||
|
||||
Holds deployment-level identity mirrored from the registry's `deployment:`
|
||||
block (rather than overloading `projects`):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS deployment (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
name TEXT,
|
||||
tagline TEXT,
|
||||
registry_sha TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
INSERT OR IGNORE INTO deployment (id) VALUES (1);
|
||||
```
|
||||
|
||||
> **Re-stamp split (correctness, found during planning).** The migration
|
||||
> runner executes pure-SQL files and **cannot read `DEFAULT_PROJECT_ID` from the
|
||||
> environment** — the same constraint that forced M1's `seed_default_project`
|
||||
> into Python. So the re-stamp is **not** in the `.sql` file. Migration `027`
|
||||
> (pure SQL) does §1a–§1c with `project_id` copied **verbatim** (`default` stays
|
||||
> `default`); the rebuilt FKs are declared `ON UPDATE CASCADE ON DELETE
|
||||
> CASCADE`. The re-stamp (§1d) is a **Python startup step** in `app/projects.py`,
|
||||
> run after migrations and before the registry mirror, that issues a single
|
||||
> `UPDATE projects SET id = <slug>` (cascading to the 12 FK tables) plus a plain
|
||||
> `UPDATE` of the 7 non-FK `project_id` tables. Idempotent: a no-op once no
|
||||
> `default` row remains.
|
||||
|
||||
### 1c. PK / uniqueness rebuilds (the 12 deferred tables)
|
||||
|
||||
Per migration 026's deferred block, create-copy-drop-rename each table to fold
|
||||
`project_id` into the key and add `project_id … REFERENCES projects(id) ON
|
||||
UPDATE CASCADE ON DELETE CASCADE` (the `ON UPDATE CASCADE` is what lets the
|
||||
§1d Python re-stamp move all child rows with one parent UPDATE):
|
||||
|
||||
| Table | Key change |
|
||||
| --- | --- |
|
||||
| `cached_rfcs` | PK `(slug)` → `(project_id, slug)` |
|
||||
| `cached_branches` | UNIQUE `(rfc_slug, branch_name)` → `+project_id` |
|
||||
| `branch_visibility` | UNIQUE `(rfc_slug, branch_name)` → `+project_id` |
|
||||
| `branch_contribute_grants` | UNIQUE `(rfc_slug, branch_name, grantee_user_id)` → `+project_id` |
|
||||
| `stars` | UNIQUE `(user_id, rfc_slug)` → `+project_id` |
|
||||
| `watches` | UNIQUE `(user_id, rfc_slug)` → `+project_id` |
|
||||
| `pr_seen` | UNIQUE `(user_id, rfc_slug, pr_number)` → `+project_id` |
|
||||
| `branch_chat_seen` | UNIQUE `(user_id, rfc_slug, branch_name)` → `+project_id` |
|
||||
| `funder_consents` | PK `(user_id, rfc_slug)` → `+project_id` |
|
||||
| `rfc_collaborators` | UNIQUE INDEX `(rfc_slug, user_id)` → `+project_id` |
|
||||
| `contribution_requests` | UNIQUE INDEX `(rfc_slug, requester_user_id) WHERE pending` → `+project_id` |
|
||||
| `proposed_use_cases` | UNIQUE `(scope, pr_number)` → `+project_id` |
|
||||
|
||||
`cached_prs` is already globally unique (`repo` is the full `org/repo` string,
|
||||
distinct per project) — **no rebuild**.
|
||||
|
||||
The copy step writes `<slug>` in place of `default` for `project_id`, so these
|
||||
12 tables are re-stamped for free (§1d).
|
||||
|
||||
### 1d. Re-stamp `default` → `<slug>` (Python startup step)
|
||||
|
||||
In `app/projects.py`, run at startup after `db.init` and before
|
||||
`refresh_registry`. When `DEFAULT_PROJECT_ID` is set and a `default` project row
|
||||
still exists, in one `db.tx()`:
|
||||
|
||||
- `UPDATE projects SET id = <slug>, updated_at = datetime('now') WHERE id =
|
||||
'default'` — cascades `project_id` across the 12 FK tables via `ON UPDATE
|
||||
CASCADE`.
|
||||
- For the 7 `project_id`-bearing tables with **no** FK (`threads`, `changes`,
|
||||
`notifications`, `actions`, `pr_resolution_branches`, `rfc_invitations`,
|
||||
`cached_prs`): `UPDATE <t> SET project_id = <slug> WHERE project_id =
|
||||
'default'`.
|
||||
|
||||
End state: a single `project_id` value DB-wide. Idempotent — a no-op once no
|
||||
`default` row remains, so it is safe on every boot.
|
||||
|
||||
### 1e. Notes
|
||||
|
||||
- Migration `016` is absent from the on-disk sequence (`015 → 017`); the runner
|
||||
already tolerates the gap (the app runs today). **Do not renumber.** New file
|
||||
is `027`.
|
||||
- `PRAGMA foreign_keys` is honored going forward; the FK lands on the 12 rebuilt
|
||||
tables. The other 7 keep app-layer integrity (matching today's posture for
|
||||
non-rebuilt tables).
|
||||
|
||||
---
|
||||
|
||||
## 2. Registry format + `app/registry.py`
|
||||
|
||||
### 2a. `projects.yaml` (root of `REGISTRY_REPO`)
|
||||
|
||||
```yaml
|
||||
deployment:
|
||||
name: Open Human Model # replaces VITE_APP_NAME (M3-frontend consumes)
|
||||
tagline: ...
|
||||
projects:
|
||||
- id: ohm # url-stable slug, unique in the deployment
|
||||
name: Open Human Model
|
||||
type: document # document | specification | bdd — immutable
|
||||
content_repo: ohm # repo under the deployment's Gitea org
|
||||
visibility: public # gated | public | unlisted
|
||||
initial_state: super-draft # optional; defaults from type
|
||||
enabled_models: [claude, gemini] # optional; falls back to ENABLED_MODELS
|
||||
theme: { accent: "#5b5bd6" } # optional; M3-frontend consumes
|
||||
```
|
||||
|
||||
### 2b. `refresh_registry(config, gitea) -> RegistryResult`
|
||||
|
||||
The config-side analogue of `cache.refresh_meta_repo`. Fetches `projects.yaml`
|
||||
from `REGISTRY_REPO` at HEAD, parses, **validates**, and upserts:
|
||||
|
||||
- Each project → `projects` row: `id, name, type, content_repo, visibility,
|
||||
initial_state`, plus `config_json` (JSON blob for `theme`, `enabled_models`),
|
||||
plus `registry_sha` (commit SHA, provenance).
|
||||
- The `deployment:` block → the `deployment` singleton (`name`, `tagline`,
|
||||
`registry_sha`).
|
||||
|
||||
Projects present in the table but absent from the registry are **not** deleted
|
||||
in M3-backend (archival semantics are out of scope; a removed entry simply
|
||||
stops being refreshed). This is noted as a known limitation; revisit if/when
|
||||
project archival is specced.
|
||||
|
||||
### 2c. Validation (loud)
|
||||
|
||||
Per the framework's separation-of-concerns rule, malformed config fails
|
||||
visibly rather than shipping wrong content silently:
|
||||
|
||||
- Each project requires `id`, `name`, `type`, `content_repo`.
|
||||
- `type` ∈ {`document`,`specification`,`bdd`}; `visibility` ∈
|
||||
{`gated`,`public`,`unlisted`}.
|
||||
- `initial_state` defaults from `type` when omitted: `document`/`specification`
|
||||
→ `super-draft`, `bdd` → `active`. When present it must be a valid §2.4
|
||||
super-draft entry-state value.
|
||||
- `id` values unique and slug-shaped (`^[a-z0-9][a-z0-9-]*$`).
|
||||
- **`type` is immutable:** an incoming `type` differing from the stored row's is
|
||||
rejected (the entry is skipped, the rest proceed; logged loudly).
|
||||
- **Default-id consistency:** the re-stamped default id (`DEFAULT_PROJECT_ID`,
|
||||
else `default`) MUST appear as an `id` in the registry, or the registry is
|
||||
inconsistent with config → surfaced loudly.
|
||||
|
||||
### 2d. Wiring (Option A)
|
||||
|
||||
- **Webhook** (`app/webhooks.py`): add a branch — if the pushed repo
|
||||
`full_name` matches `REGISTRY_REPO`, call `registry.refresh_registry(...)`.
|
||||
Same HMAC-verified `/api/webhooks/gitea` dispatcher; no new endpoint. Add
|
||||
`REGISTRY_REPO` to the set of repos the dispatcher recognizes.
|
||||
- **Sweep** (`cache.Reconciler.sweep()`): add one `await
|
||||
registry.refresh_registry(...)` at the top of each pass, so the safety-net
|
||||
loop keeps `projects` in sync if a webhook is missed.
|
||||
- **Startup** (`main.py` lifespan): run `refresh_registry` once after
|
||||
migrations. This **replaces** M1's `seed_default_project` (which is removed).
|
||||
|
||||
### 2e. Failure posture
|
||||
|
||||
- **Startup / first boot:** if `REGISTRY_REPO` is unset/unreachable, or
|
||||
`projects.yaml` is missing or fails validation, the app **fails loudly**
|
||||
(refuses to start) — there is no last-known-good to serve.
|
||||
- **Running deployment:** a malformed `projects.yaml` pushed in a later PR is
|
||||
logged and **skipped**, leaving the last-good `projects` rows intact — a bad
|
||||
config PR must not take the deployment down. This mirrors how the corpus
|
||||
reconciler tolerates a bad content push today.
|
||||
|
||||
---
|
||||
|
||||
## 3. Config (`app/config.py`)
|
||||
|
||||
- `registry_repo`: **required** — construction fails loudly if unset (matching
|
||||
the other required vars). Add `registry_repo_full` → `{gitea_org}/{registry_repo}`.
|
||||
- `meta_repo` and `meta_repo_full`: **removed**.
|
||||
- `default_project_id`: optional; consumed by migration `027` (re-stamp) and by
|
||||
`refresh_registry` (the §2c consistency gate).
|
||||
- `enabled_models`: unchanged — the deployment-level fallback for a project's
|
||||
optional `enabled_models`.
|
||||
- `app/projects.py`: `seed_default_project` retired (superseded by the mirror).
|
||||
`DEFAULT_PROJECT_ID` constant and resolution helpers retained as needed.
|
||||
|
||||
---
|
||||
|
||||
## 4. APIs — `app/api_deployment.py`
|
||||
|
||||
A new sub-router mounted in `app/api.py`.
|
||||
|
||||
### `GET /api/deployment`
|
||||
|
||||
Returns `{ name, tagline, projects: [...] }`. The deployment `name`/`tagline`
|
||||
come from the `deployment` singleton. The project list is filtered by caller
|
||||
visibility (§22.5):
|
||||
|
||||
- `public` projects → visible to everyone (incl. anonymous).
|
||||
- `gated` projects → only when the caller is a member (`visible_project_ids`
|
||||
from M2's resolver).
|
||||
- `unlisted` projects → **omitted entirely** (reachable only by direct id).
|
||||
|
||||
Each item: `{ id, name, type, visibility }` — enough for the M3-frontend
|
||||
directory + switcher. (Theme is fetched per project.)
|
||||
|
||||
### `GET /api/projects/:id`
|
||||
|
||||
Returns `{ id, name, tagline, type, visibility, initial_state, theme }`.
|
||||
Guarded by `require_project_readable(user, id)` — 404 for a non-member of a
|
||||
gated project, reusing the M2 resolver. `unlisted` is readable here by direct
|
||||
id (it is hidden only from enumeration).
|
||||
|
||||
---
|
||||
|
||||
## 5. Entry-state & review semantics
|
||||
|
||||
### 5a. Frontmatter (`app/entry.py`)
|
||||
|
||||
Parse three new fields, lenient (default `unreviewed=false`, nulls):
|
||||
`unreviewed: bool`, `reviewed_at`, `reviewed_by`. Add to the `Entry`
|
||||
dataclass. These are git-truth (§2 frontmatter) so they survive a cache
|
||||
rebuild, exactly like `state`.
|
||||
|
||||
### 5b. Cache mirror (`app/cache.py`)
|
||||
|
||||
`_upsert_cached_rfc` writes the three fields into `cached_rfcs`
|
||||
(`unreviewed`, `reviewed_at`, `reviewed_by`).
|
||||
|
||||
### 5c. Entry-landing path
|
||||
|
||||
When a creating idea-PR merges (§2.4), resolve the project's `initial_state`:
|
||||
|
||||
- `super-draft` → today's behavior unchanged (propose → super-draft → graduate).
|
||||
- `active` → land the entry `active` with `unreviewed = true`, skipping the §13
|
||||
graduate gate. The exact merge-handling site (in the PR-merge reconcile path)
|
||||
is located during implementation.
|
||||
|
||||
### 5d. Mark-reviewed
|
||||
|
||||
`POST /api/projects/:pid/rfcs/:slug/mark-reviewed`. Authority:
|
||||
`is_project_superuser` (project_admin or deployment owner/admin) — the same tier
|
||||
that graduates an entry. Effect: the bot writes `unreviewed: false` +
|
||||
`reviewed_at`/`reviewed_by` into the entry frontmatter (a git commit, paralleling
|
||||
graduate), which the mirror then reflects into `cached_rfcs`. Stamps provenance
|
||||
paralleling `graduated_at`/`graduated_by`.
|
||||
|
||||
### 5e. Catalog filter (query side)
|
||||
|
||||
`GET /api/rfcs` (and the project-scoped form) gains an `unreviewed=true` query
|
||||
param that filters on the cached column — the owner's worklist. The UI for it
|
||||
is M3-frontend; only the query side ships here. `unreviewed` applies to
|
||||
`active` entries only.
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing
|
||||
|
||||
- **Migration `027`:** seed a `026`-shaped DB with rows under
|
||||
`project_id='default'`; run `027`; assert: new columns present; the 12 tables
|
||||
rebuilt with composite keys + FK; all 19 `project_id`-bearing tables
|
||||
re-stamped to `<slug>`; row counts preserved; FK integrity on; idempotent
|
||||
re-run is a no-op.
|
||||
- **`registry.py`:** valid `projects.yaml` upserts all fields + `registry_sha`;
|
||||
each validation failure rejected (bad enum, missing field, dup id, `type`
|
||||
mutation, missing default id); `initial_state` type-default applied;
|
||||
startup-strict vs running-tolerant posture.
|
||||
- **APIs:** `/api/deployment` visibility filtering across
|
||||
gated/public/unlisted × member/non-member/anonymous; `/api/projects/:id` 404
|
||||
gate for gated non-member, 200 for unlisted-by-id.
|
||||
- **Review flow:** `initial_state: active` lands `unreviewed=true`;
|
||||
mark-reviewed authority (allow superuser, deny others) + frontmatter write +
|
||||
mirror reflection; catalog `unreviewed` filter returns the worklist.
|
||||
- **Two-project isolation:** extend `test_multi_project_authz_vertical.py` with
|
||||
a genuine **second** registry project sharing a slug with the first, proving
|
||||
the PK rebuilds isolate them (the core point of M3's activation).
|
||||
|
||||
---
|
||||
|
||||
## 7. File-touch summary
|
||||
|
||||
**New**
|
||||
- `backend/migrations/027_projects_activate.sql`
|
||||
- `backend/app/registry.py`
|
||||
- `backend/app/api_deployment.py`
|
||||
- tests: `backend/tests/test_migration_027.py`, `test_registry.py`,
|
||||
`test_api_deployment.py`, `test_review_flow.py`; extend
|
||||
`test_multi_project_authz_vertical.py`
|
||||
|
||||
**Modified**
|
||||
- `backend/app/config.py` (registry_repo required; meta_repo removed;
|
||||
default_project_id)
|
||||
- `backend/app/webhooks.py` (registry-repo branch)
|
||||
- `backend/app/cache.py` (`Reconciler.sweep` registry call;
|
||||
`_upsert_cached_rfc` review fields)
|
||||
- `backend/app/entry.py` (frontmatter fields)
|
||||
- `backend/app/api.py` (mount `api_deployment`; `unreviewed` filter on
|
||||
`/api/rfcs`)
|
||||
- `backend/app/main.py` (startup `refresh_registry`; drop `seed_default_project`)
|
||||
- `backend/app/projects.py` (retire `seed_default_project`)
|
||||
- `backend/.env.example` (`REGISTRY_REPO`, `DEFAULT_PROJECT_ID`; remove
|
||||
`META_REPO`)
|
||||
|
||||
---
|
||||
|
||||
## 8. Versioning & upgrade
|
||||
|
||||
Minor bump, breaking (pre-1.0). `VERSION` + `frontend/package.json#version` move
|
||||
together (§20). `CHANGELOG.md` gets a breaking entry with an **upgrade-steps**
|
||||
block:
|
||||
|
||||
1. Create a registry repo under the deployment's Gitea org.
|
||||
2. Author `projects.yaml`: a `deployment:` block (`name`, `tagline`) and one
|
||||
`projects:` entry for the existing corpus — `id: <slug>`, `name`, `type:
|
||||
document`, `content_repo: <old META_REPO value>`, `visibility: public`.
|
||||
3. Set env: `REGISTRY_REPO=<registry repo name>`, `DEFAULT_PROJECT_ID=<slug>`
|
||||
(must equal the entry's `id`); remove `META_REPO`.
|
||||
4. Deploy. Migration `027` runs the rebuilds + re-stamp; `refresh_registry`
|
||||
reconciles the registry into `projects`. Verify `/api/deployment` returns the
|
||||
project and `/api/health` is green.
|
||||
|
||||
The SPEC.md §22 merge stays in M7 per the slicing plan; this slice references the
|
||||
draft at `docs/design/multi-project-spec.md`.
|
||||
|
||||
## Known limitations / deferred
|
||||
|
||||
- **Project archival/deletion** from the registry is not handled (a removed
|
||||
entry stops refreshing but its rows persist). Defer to a future archival spec.
|
||||
- All routing, redirects, runtime branding, theme application, directory, and
|
||||
switcher are **M3-frontend**.
|
||||
@@ -0,0 +1,147 @@
|
||||
# M3 — Registry mirror + routing + runtime branding — design
|
||||
|
||||
**Date:** 2026-06-03
|
||||
**Spec basis:** `docs/design/multi-project-spec.md` §22, Part C slice **M3**
|
||||
**Status:** design, pending plan
|
||||
|
||||
## Goal
|
||||
|
||||
Carry §22 slice **M3** end-to-end: turn the framework from single-project
|
||||
(the N=1 default at the legacy corpus root) into genuinely multi-project —
|
||||
projects declared in a registry, reachable at `/p/<project>/`, branded at
|
||||
runtime. M3 is the slice that makes a *second, named* project reachable by
|
||||
URL at all.
|
||||
|
||||
**Driving objective:** stand up the deployment's `bdd` "planner" project,
|
||||
reachable at `https://rfc.wiggleverse.org/p/bdd/` as an **`unlisted`**
|
||||
project (readable by direct link; not in the directory). After **M3c** the
|
||||
planner is navigable; after **M3d** its entries land `active` (the behavior a
|
||||
planner wants). The planner's `bdd`-specific scenario/coverage *surface* is
|
||||
**M5**, out of scope here.
|
||||
|
||||
> Note on the original URL ask `/bdd/{PLANNER_TOKEN}/`: resolved to the
|
||||
> existing §22 model — `bdd` is an `unlisted` project at `/p/bdd/`; the
|
||||
> "token" is just the shareable link (the unguessable slug). No token-gate
|
||||
> feature is built; `unlisted` visibility already exists (gated in M2).
|
||||
|
||||
## Decisions
|
||||
|
||||
### Already resolved upstream (commit `ad2ece1`, §22.13/§22.10/§22.4)
|
||||
1. **Default project `id`** = config-derived slug
|
||||
(`DEFAULT_PROJECT_ID` > slug(deployment name) > `default`). M1's `default`
|
||||
bootstrap id is **re-stamped** to it in M3 before any `/p/` URL is public.
|
||||
2. **Entry segment** = generic `/p/<project>/e/<slug>` for every type; the
|
||||
noun (RFC/Spec/Feature) is a type-driven UI label, not in the path.
|
||||
3. **Legacy `RFC-NNNN`** = frozen, read-only display label in frontmatter;
|
||||
never used for routing/lookup; never assigned to new entries.
|
||||
|
||||
### Resolved in this brainstorm
|
||||
4. **Plan slicing** — M3 is delivered as a **foundation plan (M3-0) + four
|
||||
sequential feature sub-plans (M3a–M3d)**, each its own branch off `main`,
|
||||
its own review checkpoint, its own merge. Lower risk for the live OHM
|
||||
deployment than one long-running branch.
|
||||
5. **Branding cutover** — **hard cut + loud failure.** Frontend reads the
|
||||
deployment/project name only from `GET /api/deployment`; `VITE_APP_NAME`
|
||||
is removed from the frontend; `REGISTRY_REPO` becomes **required** at
|
||||
startup (per CLAUDE.md's loud-failure rule).
|
||||
6. **Registry mirror mechanism** — follow the **existing §4.1 cache pattern**
|
||||
(`Reconciler.sweep()` + webhook writer, reading via the Gitea API), not a
|
||||
new mechanism (no local clone, no separate service).
|
||||
7. **Test strategy** — adopt the handbook's **§10.3 two-tier testing**
|
||||
standard. Build the **Tier-1 local-Docker suite first** as M3-0; each
|
||||
feature sub-plan adds unit/integration/functional/e2e coverage on top.
|
||||
Tier-2 (PPE) is a separate flotilla/Stage-2 task (below).
|
||||
8. **Deployment stage** — OHM is **Stage 1** (pre-v1, single prod VM,
|
||||
forward-only migrations). So M3a's table-rebuild migration is allowed as
|
||||
**one forward-only migration**. The §10.2 expand/contract rule is recorded
|
||||
as a **future Stage-2 obligation**, not a constraint on M3a today.
|
||||
|
||||
## Decomposition
|
||||
|
||||
Order is dependency-driven: **M3-0 → M3a → M3b → M3c → M3d.**
|
||||
|
||||
### M3-0 — Test & local-env foundation (the §10.3 Tier-1 suite)
|
||||
Greenfield; lands before feature work so every sub-plan can be verified at
|
||||
all levels. Reuses the existing in-process `app_with_fake_gitea` fixture seam
|
||||
where possible.
|
||||
|
||||
- **Docker local stack** (`docker compose`): backend (FastAPI) + built
|
||||
frontend + **fresh SQLite** + **Mailpit** (mail sink for OTC/invite flows)
|
||||
+ a disposable/stub Gitea for content **and** registry repos.
|
||||
- *Open design point for the M3-0 plan:* promote the in-process
|
||||
`fake_gitea` double into a small standalone HTTP stub vs. run real Gitea
|
||||
in a container. Decide in M3-0's own plan.
|
||||
- **Frontend unit:** add **Vitest** + testing-library to
|
||||
`frontend/package.json` (none today).
|
||||
- **E2E:** add **Playwright**, suite **environment-agnostic** —
|
||||
parameterized by `BASE_URL` + the **mail-sink API URL** (§10.3). Default
|
||||
target = localhost Docker; PPE host when `BASE_URL` is set to `ppe.<host>`.
|
||||
One smoke spec: load `/` directory → open a project → view an entry.
|
||||
- **Tier-2 / PPE is NOT built here.** Standing up `rfc-app-ppe.<base>` (its
|
||||
own micro VM, own gcloud project, own isolated Gitea, always-pass Turnstile
|
||||
keys) is a **flotilla operator task** in the deployment tooling, tracked
|
||||
separately. The suite is written to point at it once it exists.
|
||||
|
||||
### M3a — Schema migration + default-id restamp *(foundation; runs against live OHM data)*
|
||||
- **Migration `027`** (next number after `026`): create-copy-drop-rename for
|
||||
the 12 tables enumerated in the `026_projects.sql` header, folding
|
||||
`project_id` into each PK/UNIQUE key and adding the `project_id` FK →
|
||||
`projects(id)`. (`cached_prs` needs no rebuild — already globally unique.)
|
||||
- Additive `type` + `initial_state` columns on `projects`.
|
||||
- **Restamp** (§22.13 step 1): default project `id` `default` →
|
||||
config-derived slug, cascaded across every `project_id` FK. Idempotent.
|
||||
- **Live-data safety (Stage 1):** back up prod SQLite; rehearse on a copy of
|
||||
prod; assert per-table row-count parity; FK enforcement on. Forward-only;
|
||||
brief restart blip acceptable per §9.
|
||||
- *Gate: until this lands, a second project can collide with the first.*
|
||||
|
||||
### M3b — Registry mirror
|
||||
- `REGISTRY_REPO` **required** at startup (loud failure if unset).
|
||||
- `refresh_registry()` in `cache.py`: read `projects.yaml` from
|
||||
`REGISTRY_REPO` via the Gitea API; upsert `projects` rows (`id`, `name`,
|
||||
`content_repo`, `type`, `visibility`, `initial_state`,
|
||||
`enabled_models`/`theme` → `config_json`, `registry_sha`); cache deployment
|
||||
`name`/`tagline`. Rows never written from user actions.
|
||||
- Webhook branch in `webhooks.py` on push to `REGISTRY_REPO`;
|
||||
`Reconciler.sweep()` calls `refresh_registry()`.
|
||||
|
||||
### M3c — Routing + redirects + runtime branding *(planner becomes navigable)*
|
||||
- Backend: `GET /api/deployment` (name, tagline, visible projects per §22.5),
|
||||
`GET /api/projects/:id` (name, tagline, type, philosophy pointer, theme);
|
||||
`/p/<project>` scoping on RFC routes.
|
||||
- Frontend: `/p/<project>/` prefix + `/e/<slug>` segment; **308 redirects**
|
||||
off old corpus-root URLs (`/rfc/<slug>` → `/p/<default-id>/e/<slug>`, etc.);
|
||||
**hard cut** off `VITE_APP_NAME` to runtime branding; per-project `theme`
|
||||
token overlay; deployment **directory** at `/`; project **switcher**.
|
||||
|
||||
### M3d — Landing-state + review behavior
|
||||
- `initial_state=active` creation path: land a new entry `active`, stamp
|
||||
`unreviewed`, skip the graduate gate when the project says so.
|
||||
- `unreviewed` frontmatter fields mirrored into `cached_rfcs`; owner/admin
|
||||
**mark-reviewed** action; §7 catalog **unreviewed filter** querying the
|
||||
cached column; type-driven entry noun in chrome.
|
||||
|
||||
## Test coverage per sub-plan (all four levels)
|
||||
|
||||
- **M3a** — migration unit/integration: fresh DB **and** copy-of-prod
|
||||
fixture; row-count parity; FK enforcement; restamp idempotence.
|
||||
- **M3b** — reconciler unit (yaml parse, upsert, `registry_sha`) +
|
||||
integration (webhook → `projects` rows) on the Docker stub Gitea.
|
||||
- **M3c** — backend functional (`/api/deployment`, `/api/projects/:id`, 308
|
||||
redirects, `/p` scoping) + frontend unit (branding/theme from API) + **e2e**
|
||||
(directory → project → entry nav; old-URL redirect resolves).
|
||||
- **M3d** — backend functional (active landing, `unreviewed`, mark-reviewed,
|
||||
filter) + **e2e** (unreviewed filter, mark-reviewed action).
|
||||
|
||||
## Out of scope / dependencies
|
||||
|
||||
- **M4** (second-project acceptance pass), **M5** (`bdd` scenario/coverage
|
||||
surface — the planner's type-specific UI), **M6/M7**.
|
||||
- **PPE standup** (`rfc-app-ppe.<base>`) — flotilla operator task; prerequisite
|
||||
to running the suite's Tier-2 gate, not framework code.
|
||||
- **Org-wide testing-standard activation** — §10.3 already *is* the canonical
|
||||
standard; it should not be duplicated into per-repo memory. Recommended
|
||||
follow-up (separate, in the engineering repo + dev plugin): have
|
||||
`wgl-coding-session-init` read app.json and, for apps that deploy the §8
|
||||
standard stack, surface §10.3 + check whether the Tier-1 suite exists. Not
|
||||
part of M3.
|
||||
@@ -0,0 +1,85 @@
|
||||
# M3-frontend — §22 multi-project: routing, runtime branding, directory, switcher — design
|
||||
|
||||
**Date:** 2026-06-03
|
||||
**Slice:** §22 M3 (frontend half). Pairs with **M3-backend** (`2026-06-03-m3-backend-design.md`) which ships the data spine, registry mirror, and the two runtime-config APIs this slice consumes.
|
||||
**Status:** design, pending plan. *Implementation is gated on M3-backend Plan A's APIs (see §7).*
|
||||
|
||||
## Goal
|
||||
|
||||
Ship the §22.9/§22.10 frontend: `/p/<project>/` routing, the deployment **directory** + **project switcher**, the `VITE_APP_NAME`→runtime-config **hard cut**, per-project **theme** overlay, and real **308 redirects** off the old corpus-root URLs. After this slice the framework presents deployment chrome (directory/switcher/brand) over project chrome (catalog/entry view), branded entirely at runtime.
|
||||
|
||||
**Honest scope boundary.** The RFC *data* calls stay **unscoped** (`/api/rfcs/...`) this slice, so a project's actual corpus renders only for the backend's **corpus-served (default) project**. A *second* project's entries (the `bdd` "planner") need the backend to serve per-project RFCs — that is **M3-backend Plan B**, not this slice. M3-frontend therefore delivers the **shell**: directory, switcher, runtime branding, theme, `/p/<default>/` fully working, and the 308s. `projectId` is threaded through context so a later slice swaps unscoped calls for scoped ones with minimal churn.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
1. **Routing architecture** — keep `<BrowserRouter>` + nested `<Routes>` (no migration to a data-router). Add a `DeploymentProvider` context (boots `/api/deployment`) and a `ProjectLayout`/`ProjectProvider` for the `/p/:projectId/*` subtree (`/api/projects/:id`). Extract the catalog+main-pane composition out of `App.jsx` (480 lines) into `ProjectLayout`.
|
||||
2. **N=1 landing** — when exactly **one** project is visible to the caller, `/` redirects to `/p/<that-id>/`; the `<Directory>` renders only when 2+ are visible. Preserves OHM's "land in the corpus" UX; the directory appears when a second *public* project exists. (Visibility is per-caller, §22.5; the `unlisted` planner never counts toward the directory.)
|
||||
3. **Redirects** — **real HTTP 308**, server-side (§5), not client-side `<Navigate>`.
|
||||
4. **Runtime branding** — **hard cut**: remove `VITE_APP_NAME` from the build; name/tagline come from `/api/deployment` at runtime; `brandTitle()` (from M3-0) is the neutral `'RFC'` fallback during the pre-fetch paint.
|
||||
5. **The guard** — a non-corpus-served `projectId` renders a deliberate "content not yet served" placeholder, never mislabeled default-project content (decouples this slice from Plan B without a wrong-content footgun).
|
||||
6. **Philosophy stays deployment-level** this slice — `/api/projects/:id` (per the M3-backend spec) returns no philosophy pointer, so the per-project philosophy split (§14.1) is deferred until the backend serves one.
|
||||
|
||||
## 1. Routing & layout
|
||||
|
||||
`main.jsx` keeps `<BrowserRouter>`. New route table in `App.jsx`:
|
||||
|
||||
| Path | Renders |
|
||||
| --- | --- |
|
||||
| `/` | `<DeploymentLanding>` — if exactly one visible project → `<Navigate replace to="/p/<id>/">`; else `<Directory>` (cards from `/api/deployment`) |
|
||||
| `/p/:projectId/*` | `<ProjectLayout>` (fetch `/api/projects/:id`, apply theme, provide `ProjectContext`) wrapping the Catalog left pane + nested routes below |
|
||||
| `/p/:projectId/` | catalog home (today's `<Welcome>`) |
|
||||
| `/p/:projectId/e/:slug` | `<RFCView>` — generic `/e/` segment; noun ("RFC/Spec/Feature") from `project.type` |
|
||||
| `/p/:projectId/e/:slug/pr/:prNumber` | `<PRView>` |
|
||||
| `/p/:projectId/proposals/:prNumber` | `<ProposalView>` |
|
||||
| `/login`, `/docs/*`, `/admin/*`, `/privacy`, `/cookies`, `/settings/*`, `/invitations/accept`, `/invites/claim` | unchanged (top-level, full-width chrome) |
|
||||
|
||||
- Old `/rfc/:slug`, `/proposals/:n` are **removed from the SPA** — served as 308s (§5), so they never reach the router.
|
||||
- `<ProjectLayout>` owns the per-project chrome and the **guard** (§4): corpus routes render only when the project is corpus-served; otherwise a placeholder.
|
||||
|
||||
## 2. Runtime branding (the hard cut)
|
||||
|
||||
- **`DeploymentProvider`** fetches `GET /api/deployment` once on boot (alongside `getMe()`); provides `{ name, tagline, projects[] }` via context.
|
||||
- Replace the **6** `import.meta.env.VITE_APP_NAME` reads with the context value wrapped in `brandTitle(deployment?.name)` (neutral `'RFC'` fallback during pre-fetch):
|
||||
- `App.jsx:208` (header brand), `Landing.jsx:16`, `BetaPending.jsx:25`, `Login.jsx:594`, `pages/Cookies.jsx:32`, `pages/Privacy.jsx:25`.
|
||||
- **Tab title:** drop the build-time `%VITE_APP_NAME%` token; `index.html` ships static `<title>RFC</title>`; JS sets `document.title` after config loads (`ProjectLayout` → project name; deployment chrome → deployment name).
|
||||
- **`vite.config.js`:** remove the `VITE_APP_NAME` build-time `throw` and the `inject-app-name` plugin. A build no longer needs the env var (the actual hard cut).
|
||||
|
||||
## 3. Theme overlay
|
||||
|
||||
`ProjectLayout` applies `project.theme` by setting CSS custom properties on `document.documentElement` (e.g. `style.setProperty('--c-accent', theme.accent)`), riding the existing `tokens.css` `:root` variable system. Properties are reapplied on project switch and **reset on unmount** so one project's accent never bleeds into deployment chrome or another project.
|
||||
|
||||
## 4. Chrome split + the guard
|
||||
|
||||
- **Deployment chrome:** header brand (→ deployment name), a **project switcher** dropdown (visible projects from `/api/deployment`), and `<Directory>` at `/`.
|
||||
- **Project chrome:** catalog, entry view — under `ProjectLayout`, scoped to one project.
|
||||
- **Breadcrumb (§8.1):** gains a leading project segment with the type-driven noun (e.g. `OHM / Human › main`); threaded in from `ProjectContext` into the existing inline breadcrumb markup in `RFCView.jsx`/`PRView.jsx` (no component extraction — `RFCView` is 1260 lines; full extraction is out of scope).
|
||||
- **The guard:** `ProjectLayout` renders the catalog/entry routes only for the corpus-served (default) project; any other `projectId` renders a "content not yet served" placeholder. **Contract detail to settle at implementation time** (against Plan A/B): how the frontend learns which project is corpus-served — a flag on `/api/projects/:id`, or matching the deployment's default id. Deliberately not over-specified now.
|
||||
|
||||
## 5. 308 redirects (server-side — coordination with M3-backend)
|
||||
|
||||
- **FastAPI:** `GET /rfc/{slug}` → 308 `/p/{default_id}/e/{slug}`; `GET /proposals/{n}` → 308 `/p/{default_id}/proposals/{n}`. `default_id` from config (`DEFAULT_PROJECT_ID`; post-restamp = the project's real id).
|
||||
- **nginx** (`testing/web.nginx.conf` for Tier-1, and the prod `deploy/nginx/*.conf`): route `/rfc/` and `/proposals/` to the backend (`proxy_pass`) instead of `try_files → index.html`.
|
||||
- This is the backend/ops layer (it needs `DEFAULT_PROJECT_ID`, backend-owned). **Coordination point:** either the parallel M3-backend session adds it, or this slice contributes the small backend route + nginx rule. Owner to be assigned during planning.
|
||||
|
||||
## 6. Testing
|
||||
|
||||
- **Vitest unit:** `DeploymentProvider` fallback via `brandTitle`; theme `setProperty` apply/reset; N=1 redirect logic; the guard placeholder; the directory render with 0/1/2+ visible projects.
|
||||
- **Playwright e2e (M3-0 Tier-1 harness):** N=1 `/`→`/p/<id>/` redirect; directory with 2+ public projects; **308** from `/rfc/<slug>` → `/p/<id>/e/<slug>`; branding from `/api/deployment`; theme accent applied; switcher navigation; non-served project placeholder. **e2e lands once M3-backend Plan A (registry + the two APIs) is in the Tier-1 stack** (the seeded Gitea needs a `REGISTRY_REPO` + `projects.yaml`).
|
||||
|
||||
## 7. Dependencies & sequencing
|
||||
|
||||
- **Design:** now (unblocked).
|
||||
- **Implementation gated on:** M3-backend **Plan A** — `GET /api/deployment`, `GET /api/projects/:id` (`api_deployment.py`, not yet built).
|
||||
- **Planner's actual content gated on:** M3-backend **Plan B** — per-project RFC serving + scoped frontend calls (a follow-on slice that relaxes the §4 guard).
|
||||
- **308 piece:** needs the backend route + nginx rule (§5).
|
||||
- **Tier-1 e2e:** needs Plan A's registry + APIs wired into the M3-0 Docker stack.
|
||||
|
||||
## 8. File-touch summary
|
||||
|
||||
**New (frontend):** `src/context/DeploymentProvider.jsx`, `src/components/ProjectLayout.jsx` (+ `ProjectContext`), `src/components/Directory.jsx`, `src/components/ProjectSwitcher.jsx`, `src/components/NotServedPlaceholder.jsx`; `src/api.js` additions (`getDeployment`, `getProject`).
|
||||
**Modified (frontend):** `App.jsx` (route table, brand), `main.jsx` (provider wrap), `index.html` (static title), `vite.config.js` (drop `VITE_APP_NAME`), the 6 brand-read files, `RFCView.jsx`/`PRView.jsx` (breadcrumb project segment), `tokens.css` (no change expected; theme is applied via JS).
|
||||
**Server (coordination):** a FastAPI redirect route + nginx `/rfc/` `/proposals/` rule (§5).
|
||||
|
||||
## 9. Versioning
|
||||
|
||||
Per the 2026-06-03 decision (bump per breaking slice), M3-frontend's `VITE_APP_NAME` removal is a build-surface change deployments must act on (set up the registry / runtime config). It rides the same pre-1.0 minor + CHANGELOG upgrade-steps as the M3-backend cut if they land together, or carries its own upgrade-steps block if separate. `VERSION` + `frontend/package.json#version` move together (§20).
|
||||
@@ -0,0 +1,194 @@
|
||||
# M3-backend Plan B — §22 multi-project: per-project RFC serving, default-id re-stamp, slug-keyed PK rebuilds — design
|
||||
|
||||
**Date:** 2026-06-04
|
||||
**Slice:** §22 M3 (the backend half that M3-frontend deferred). Pairs with
|
||||
**M3-frontend** (`2026-06-03-m3-frontend-design.md`, shipped v0.35.0) which
|
||||
delivered the shell (routing, runtime branding, directory/switcher, 308s) but
|
||||
kept RFC *data* calls unscoped, so only the corpus-served default project
|
||||
renders. This slice makes a **second project's corpus actually serve and
|
||||
render**, and lands the two §22.13/migration-026 items that must precede
|
||||
project #2.
|
||||
**Status:** design, pending plan. Authoritative model: `multi-project-spec.md`
|
||||
(Part A §22, Part C M3) + `multi-project.md`.
|
||||
**Target release:** the next pre-1.0 minor (breaking: URL/migration). Likely
|
||||
**v0.36.0**; may split into two minors (B-1 read path, B-2 write path) — see §7.
|
||||
|
||||
## Goal
|
||||
|
||||
After this slice a deployment with **N≥2** registry projects serves and renders
|
||||
every project's own corpus under `/p/<id>/`, identified by slug *within* the
|
||||
project (§22.4). The M3-frontend guard (`NotServedPlaceholder` for any
|
||||
non-default project) is **removed** — the guard contract (`default_project_id`)
|
||||
stays on `/api/deployment` only as the redirect target for legacy URLs.
|
||||
|
||||
Three things land together because none is safe without the others:
|
||||
|
||||
1. **Default-project-id re-stamp** (§22.13 step 1) — `default` → a config slug.
|
||||
2. **Slug-keyed PK/UNIQUE rebuilds** (migration 026 header) — fold `project_id`
|
||||
into the composite keys so a second project can't collide on a slug.
|
||||
3. **Per-project RFC serving** — endpoints, cache mirror, bot, and webhook
|
||||
routing all dispatch by project; the frontend calls the scoped routes.
|
||||
|
||||
## 1. Default-project-id re-stamp (§22.13 step 1)
|
||||
|
||||
Today `projects.resolved_default_id()` always returns the literal `"default"`
|
||||
(Plan A), and M1's backfill stamped every existing row `project_id='default'`.
|
||||
This slice re-stamps that bootstrap id to a **config-derived slug** so the
|
||||
deployment's URL is meaningful (`/p/ohm/` not `/p/default/`) and stable.
|
||||
|
||||
- **Resolution order** (already drafted in `resolved_default_id`'s docstring):
|
||||
`DEFAULT_PROJECT_ID` env var → else slug of the deployment name → else
|
||||
`default`. Decision: keep it config-explicit — OHM sets
|
||||
`DEFAULT_PROJECT_ID=ohm` in its overlay; the registry's first project `id`
|
||||
SHOULD match.
|
||||
- **Why it must precede public `/p/` URLs:** once `/p/<id>/` is linkable, the
|
||||
id is a permanent URL; renaming it later breaks links. M3-frontend shipped
|
||||
`/p/<default>/` already, so strictly this re-stamp is now *slightly late* —
|
||||
acknowledge that and treat the re-stamp as a one-time 308-preserving rename
|
||||
(add `/p/default/* → /p/<slug>/*` 308s for one release if `DEFAULT_PROJECT_ID`
|
||||
differs from `default`). Open: do we bother, given OHM isn't deployed on the
|
||||
multi-project stack yet (pinned 0.31.5)? If OHM cuts over *directly* to a
|
||||
re-stamped slug, no `default` URL was ever public and the extra 308s are
|
||||
unnecessary. **Recommendation: re-stamp lands in the SAME deploy OHM first
|
||||
adopts the registry, so `default` is never public for OHM → no legacy 308
|
||||
needed.** Code the re-stamp as part of the registry-mirror reconcile (rename
|
||||
rows whose `project_id` is the stale bootstrap value to the resolved id),
|
||||
idempotent.
|
||||
- **Mechanics:** a migration (or the reconciler, run-once guarded) `UPDATE`s
|
||||
`project_id` from the old bootstrap value to the resolved slug across the
|
||||
`projects` row, `project_members`, and every scoped table (the ~19 from
|
||||
migration 026). Must run **inside the same transaction** as / before the PK
|
||||
rebuilds (§2) so FKs stay consistent.
|
||||
|
||||
## 2. Slug-keyed PK / UNIQUE rebuilds (migration 026 header)
|
||||
|
||||
> **STATUS: SHIPPED — rfc-app v0.36.0 (Session 0071.0).** Migration `028` lands
|
||||
> the rebuilds (13 tables, incl. composite FKs on `rfc_invitations`/
|
||||
> `rfc_collaborators`/`contribution_requests` → `cached_rfcs(project_id, slug)`),
|
||||
> the migration-runner `-- migrate:no-foreign-keys` capability, and the
|
||||
> `ON CONFLICT` target updates. 442 backend tests green; two-project same-slug
|
||||
> coexistence proven (`test_migration_028_project_scoped_keys.py`). No behavior
|
||||
> change. **Remaining Plan B = the §1 re-stamp + §3–§5 per-project serving.**
|
||||
|
||||
SQLite can't `ALTER` a PRIMARY KEY/UNIQUE in place, so each table below is
|
||||
rebuilt with the create-new → copy → drop-old → rename pattern, inside one
|
||||
transaction with `PRAGMA foreign_keys=OFF` around it (per the existing
|
||||
migration-runner convention — confirm in `db.py`). The composite-key targets
|
||||
(verbatim from `026_projects.sql`):
|
||||
|
||||
| Table | old key | new key |
|
||||
| --- | --- | --- |
|
||||
| `cached_rfcs` | PK `(slug)` | `(project_id, slug)` |
|
||||
| `cached_branches` | UNIQUE `(rfc_slug, branch_name)` | `+project_id` |
|
||||
| `branch_visibility` | UNIQUE `(rfc_slug, branch_name)` | `+project_id` |
|
||||
| `branch_contribute_grants` | UNIQUE `(rfc_slug, branch_name, grantee_user_id)` | `+project_id` |
|
||||
| `stars` | UNIQUE `(user_id, rfc_slug)` | `+project_id` |
|
||||
| `watches` | UNIQUE `(user_id, rfc_slug)` | `+project_id` |
|
||||
| `pr_seen` | UNIQUE `(user_id, rfc_slug, pr_number)` | `+project_id` |
|
||||
| `branch_chat_seen` | UNIQUE `(user_id, rfc_slug, branch_name)` | `+project_id` |
|
||||
| `funder_consents` | PK `(user_id, rfc_slug)` | `+project_id` |
|
||||
| `rfc_collaborators` | UNIQUE idx `(rfc_slug, user_id)` | `+project_id` |
|
||||
| `contribution_requests` | UNIQUE idx `(rfc_slug, requester_user_id) WHERE pending` | `+project_id` |
|
||||
| `proposed_use_cases` | UNIQUE `(scope, pr_number)` | `+project_id` |
|
||||
|
||||
`cached_prs` UNIQUE `(repo, pr_number)` needs **no** rebuild — `repo` is the
|
||||
full `org/repo` string, already distinct per project.
|
||||
|
||||
- Every dependent index/trigger/view is recreated against the new table.
|
||||
- Backfilled rows already carry `project_id` (M1), so the copy is a straight
|
||||
`INSERT INTO new SELECT * FROM old`.
|
||||
- Add the FK `project_id REFERENCES projects(id)` on rebuild where it was
|
||||
deferred.
|
||||
- **Test:** a migration test that seeds two projects with the *same* slug and
|
||||
asserts both rows coexist post-rebuild (the collision M1 couldn't allow).
|
||||
|
||||
## 3. Per-project RFC serving — endpoints
|
||||
|
||||
Decision to pin in planning: **path-scoped** routes, mirroring the frontend's
|
||||
`/p/<project>/` and §22.4's `(project_id, slug)` identity:
|
||||
|
||||
```
|
||||
GET /api/projects/{pid}/rfcs catalog (replaces GET /api/rfcs)
|
||||
GET /api/projects/{pid}/rfcs/{slug} entry (replaces /api/rfcs/{slug})
|
||||
POST /api/projects/{pid}/rfcs/propose propose (already partly there:
|
||||
mark-reviewed is /api/projects/{pid}/…)
|
||||
GET /api/projects/{pid}/proposals[/{n}] idea PRs
|
||||
…branches / prs / discussion / graduation analogously gain the {pid} prefix.
|
||||
```
|
||||
|
||||
- Keep the old unscoped `/api/rfcs*` as **thin shims** that resolve the default
|
||||
project and delegate, for one release, so a stale frontend bundle mid-deploy
|
||||
still works; remove in the following minor. (Or hard-cut — decide in planning;
|
||||
the frontend ships scoped calls in the same release, so the shim is only for
|
||||
in-flight bundles.)
|
||||
- Every handler runs the §22.5 read/write gate on `{pid}`
|
||||
(`require_project_readable`, `can_contribute_in_project`) — the resolver
|
||||
primitives already exist (M2). The per-RFC lookups change from `WHERE slug=?`
|
||||
to `WHERE project_id=? AND slug=?`.
|
||||
- `propose`/graduation already call `projects_mod.resolved_default_id(config)`
|
||||
(api.py:874) — change to the path `{pid}`.
|
||||
|
||||
## 4. Cache mirror, bot, webhook — dispatch by project
|
||||
|
||||
- **Mirror:** `cache.refresh_meta_repo()` reads one `content_repo` today
|
||||
(`projects.default_content_repo`). Generalize to iterate **all** projects'
|
||||
`content_repo`s, mirroring each into `cached_rfcs` (etc.) stamped with that
|
||||
project's id. Loop over `projects` rows.
|
||||
- **Bot:** `bot.open_idea_pr(...)` and the branch/PR helpers take a
|
||||
`project_id` (or a resolved `content_repo`) instead of the default.
|
||||
- **Webhook (§4):** `/api/webhooks/gitea` maps the pushed repo →
|
||||
`projects.content_repo` → project, and refreshes only that project's cache
|
||||
(the planner does exactly this `match_repo` step — mirror its shape).
|
||||
- **Registry webhook** already reconciles `projects` (Plan A). No change.
|
||||
|
||||
## 5. Frontend — relax the guard, scope the calls
|
||||
|
||||
- `api.js`: `listRFCs`/`getRFC`/`listProposals`/`getProposal`/`proposeRFC`/…
|
||||
gain a `projectId` arg and hit the `/api/projects/{pid}/…` routes. `useProjectId()`
|
||||
(already added in M3-frontend, `lib/entryPaths.js`) supplies it.
|
||||
- `ProjectLayout`: **remove the `served`/`NotServedPlaceholder` guard** — every
|
||||
registry project now serves. Keep `NotServedPlaceholder` only as the 404/not-
|
||||
readable surface (or delete and reuse the not-found branch).
|
||||
- `Catalog`, `RFCView`, `PRView`, `ProposalView`, `ProposeModal` read their
|
||||
data through the scoped api with `useProjectId()`.
|
||||
- `/api/deployment.default_project_id` stays (the legacy-URL 308 target).
|
||||
|
||||
## 6. Testing
|
||||
|
||||
- **Migration:** two-project same-slug coexistence (§2); re-stamp idempotence;
|
||||
FK integrity post-rebuild.
|
||||
- **Backend vertical:** a second `document` project end-to-end — propose →
|
||||
super-draft → graduate, identified by slug *in that project*; visibility gate
|
||||
(gated 2nd project 404s a non-member while the default stays readable);
|
||||
cross-project isolation (a slug in project A is not found under project B).
|
||||
- **Frontend unit:** scoped api calls carry the right `pid`; the guard removal
|
||||
renders a non-default project's catalog.
|
||||
- **Tier-1 e2e (now unblockable):** seed a **registry repo + `projects.yaml`
|
||||
with two projects** into the dockerized Gitea and set `REGISTRY_REPO` in
|
||||
`testing/.env.tier1` (currently empty — this is the blocker M3-frontend's
|
||||
e2e + this slice's e2e share). Then the M3-frontend Playwright specs (N=1
|
||||
redirect, directory with 2+, 308s, switcher) AND a second-project corpus
|
||||
render become runnable. **Land the Tier-1 registry seed as the first task of
|
||||
this slice** — it pays off both slices' deferred e2e.
|
||||
|
||||
## 7. Sequencing & risk
|
||||
|
||||
- **Highest risk:** the §2 PK rebuilds (12 table recreates in one migration).
|
||||
Mitigate with an isolated migration test DB seeded from a realistic dump and
|
||||
a row-count assertion before/after each table.
|
||||
- **Possible split:** B-1 = re-stamp + PK rebuilds + read-path serving
|
||||
(catalog + entry view scoped) — enough for a 2nd project's corpus to *render*
|
||||
read-only; B-2 = write path (propose/branch/PR/graduate) scoped. Each is
|
||||
independently shippable behind the N=1 default. Decide in planning; a single
|
||||
v0.36.0 is fine if the write-path rescope is mechanical.
|
||||
- **OHM impact:** OHM is pinned 0.31.5 and not yet on the registry stack, so
|
||||
this slice has **no live deployment to migrate** until the OHM cutover
|
||||
milestone (ohm-rfc ROADMAP Phase G). Land it on `main`; it deploys to OHM as
|
||||
part of that cutover.
|
||||
|
||||
## 8. Versioning
|
||||
|
||||
Pre-1.0 minor, breaking (URL move + migration). `VERSION` +
|
||||
`frontend/package.json` move together (§20.1). CHANGELOG upgrade-steps:
|
||||
migration runs automatically; old `/api/rfcs*` shims (if kept) deprecated; the
|
||||
re-stamp note (`DEFAULT_PROJECT_ID`); the SPEC §22 merge stays for M7.
|
||||
@@ -0,0 +1,2 @@
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -0,0 +1,32 @@
|
||||
const MAILSINK = process.env.MAILSINK_URL || 'http://localhost:8025'
|
||||
|
||||
export async function waitForLatestOtc(toAddress, { attempts = 20, delayMs = 500 } = {}) {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const res = await fetch(`${MAILSINK}/api/v1/messages`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const msg = (data.messages || []).find(
|
||||
(m) => (m.To || []).some((t) => t.Address === toAddress),
|
||||
)
|
||||
if (msg) {
|
||||
const full = await fetch(`${MAILSINK}/api/v1/message/${msg.ID}`)
|
||||
if (!full.ok) continue
|
||||
const body = await full.json()
|
||||
// Search the plain-text part first — the OTC mail is plain text
|
||||
// (see backend/app/email_otc.py), and scanning Text before HTML
|
||||
// keeps the \d{6} match from latching onto a stray number that a
|
||||
// future HTML template might carry (style widths, year, etc.).
|
||||
const text = body.Text || ''
|
||||
const html = body.HTML || ''
|
||||
const code = text.match(/\b(\d{6})\b/) || html.match(/\b(\d{6})\b/)
|
||||
if (code) return code[1]
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, delayMs))
|
||||
}
|
||||
throw new Error(`no OTC email for ${toAddress} arrived in Mailpit`)
|
||||
}
|
||||
|
||||
export async function clearMailpit() {
|
||||
await fetch(`${MAILSINK}/api/v1/messages`, { method: 'DELETE' })
|
||||
}
|
||||
Generated
+76
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "rfc-e2e",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rfc-e2e",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "rfc-e2e",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"e2e": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 10_000 },
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:8080',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
reporter: [['list']],
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { waitForLatestOtc, clearMailpit } from './lib/mailpit.js'
|
||||
|
||||
// Unique per-run address. The §6.2 request path enforces a per-email
|
||||
// cooldown (OTC_REQUEST_COOLDOWN_SECONDS, default 60s), so a fixed
|
||||
// address would 429 on any re-run inside the window. A fresh address per
|
||||
// run sidesteps that without touching backend config.
|
||||
const EMAIL = `e2e-${Date.now()}-${Math.floor(Math.random() * 1e6)}@example.test`
|
||||
|
||||
// First end-to-end smoke spec (M3-0 Task 7). Validates the whole Tier-1
|
||||
// harness: the web tier serves the app, the backend's OTC sign-in path
|
||||
// (§6.2) issues a code, Mailpit captures the outbound mail, and a verify
|
||||
// of that code succeeds and mints a session.
|
||||
//
|
||||
// No precondition/provisioning step is needed: the §6.2 OTC path admits
|
||||
// any syntactically-valid email and provisions a fresh `pending` user on
|
||||
// verify (see backend/app/otc.py). Turnstile is open in this stack
|
||||
// (TURNSTILE_REQUIRED=false, no secret), so the request body carries only
|
||||
// the email.
|
||||
test('app loads and an OTC sign-in succeeds', async ({ page, request }) => {
|
||||
await clearMailpit()
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page).toHaveTitle(/.+/)
|
||||
|
||||
const reqRes = await request.post('/auth/otc/request', {
|
||||
data: { email: EMAIL },
|
||||
})
|
||||
expect(reqRes.ok()).toBeTruthy()
|
||||
|
||||
const code = await waitForLatestOtc(EMAIL)
|
||||
expect(code).toMatch(/^\d{6}$/)
|
||||
|
||||
const verifyRes = await request.post('/auth/otc/verify', {
|
||||
data: { email: EMAIL, code },
|
||||
})
|
||||
expect(verifyRes.ok()).toBeTruthy()
|
||||
|
||||
// Prove the sign-in actually took: the verify handler returns ok:true
|
||||
// and sets the `rfc_session` session cookie (§ SessionMiddleware,
|
||||
// backend/app/main.py). Asserting the cookie — not brittle UI text —
|
||||
// is what distinguishes a real sign-in from a bare 2xx.
|
||||
const verifyBody = await verifyRes.json()
|
||||
expect(verifyBody.ok).toBe(true)
|
||||
|
||||
const setCookie = verifyRes.headers()['set-cookie'] || ''
|
||||
expect(setCookie).toContain('rfc_session=')
|
||||
})
|
||||
@@ -4,15 +4,10 @@
|
||||
# and dev (`npm run dev`) time. Real `.env` files are gitignored; only this
|
||||
# `.env.example` is committed.
|
||||
|
||||
# The user-visible name of this deployment. Used as the browser tab title,
|
||||
# the header brand, and the landing page H1. Required — the framework
|
||||
# ships no default on purpose so each deployment names itself. If unset,
|
||||
# `npm run build` fails with a clear message.
|
||||
#
|
||||
# Examples:
|
||||
# VITE_APP_NAME=Wiggleverse RFC
|
||||
# VITE_APP_NAME=Wiggleverse Open Human Model
|
||||
VITE_APP_NAME=
|
||||
# §22.9 (M3, v0.35.0): the deployment name is NO LONGER a build-time var.
|
||||
# It comes from the registry (`projects.yaml` `deployment.name`) and is served
|
||||
# at runtime by GET /api/deployment. VITE_APP_NAME has been removed — set the
|
||||
# name in your registry repo instead, and the same build serves any deployment.
|
||||
|
||||
# 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
|
||||
|
||||
+3
-1
@@ -3,7 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>%VITE_APP_NAME%</title>
|
||||
<!-- §22.9: neutral static title; JS sets document.title from runtime
|
||||
config (deployment/project name) once /api/deployment resolves. -->
|
||||
<title>RFC</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+2548
-4
File diff suppressed because it is too large
Load Diff
+10
-3
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.24.0",
|
||||
"version": "0.39.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@amplitude/unified": "^1.1.9",
|
||||
@@ -19,6 +21,7 @@
|
||||
"@tiptap/pm": "^3.5.0",
|
||||
"@tiptap/react": "^3.5.0",
|
||||
"@tiptap/starter-kit": "^3.5.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"marked": "^18.0.4",
|
||||
"mermaid": "^11.15.0",
|
||||
"react": "^19.2.6",
|
||||
@@ -26,9 +29,13 @@
|
||||
"react-router-dom": "^7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"vite": "^8.0.12"
|
||||
"jsdom": "^25.0.0",
|
||||
"vite": "^8.0.12",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+146
-18
@@ -34,13 +34,31 @@
|
||||
.role-owner { background: var(--c-warning-accent); }
|
||||
.role-admin { background: var(--c-accent-strong); }
|
||||
|
||||
/* The default surface for .btn-link is LIGHT (breadcrumb bar, PR view,
|
||||
* modals, discussion panel, inbox). It renders as a quiet secondary
|
||||
* button: white fill, hairline border, dark label. The dark app-header
|
||||
* reuse ("Sign out") opts back into the translucent-on-dark treatment
|
||||
* via the .app-header scope below. (Before v0.31.4 the base rule WAS the
|
||||
* dark-header style, so every light-surface .btn-link was white-on-near-
|
||||
* white and effectively invisible.) */
|
||||
.btn-link {
|
||||
color: var(--c-white); text-decoration: none;
|
||||
background: var(--color-on-dark-soft);
|
||||
display: inline-flex; align-items: center;
|
||||
color: var(--c-gray-700); text-decoration: none;
|
||||
background: var(--c-white);
|
||||
border: 1px solid var(--c-gray-300);
|
||||
border-radius: var(--radius-md); padding: 4px 10px;
|
||||
font-size: var(--text-base);
|
||||
font-size: var(--text-base); cursor: pointer;
|
||||
}
|
||||
.btn-link:hover {
|
||||
background: var(--c-gray-50); border-color: var(--c-gray-400); color: var(--c-ink);
|
||||
}
|
||||
/* Dark header reuse: restore the original translucent-white treatment. */
|
||||
.app-header .btn-link {
|
||||
color: var(--c-white); background: var(--color-on-dark-soft); border-color: transparent;
|
||||
}
|
||||
.app-header .btn-link:hover {
|
||||
color: var(--c-white); background: var(--color-on-dark-hover); border-color: transparent;
|
||||
}
|
||||
.btn-link:hover { background: var(--color-on-dark-hover); }
|
||||
|
||||
.btn-signin-header {
|
||||
color: var(--c-white); text-decoration: none;
|
||||
@@ -189,9 +207,27 @@
|
||||
padding: 32px 48px;
|
||||
}
|
||||
|
||||
.welcome { max-width: 640px; }
|
||||
.welcome h1 { font-size: var(--text-2xl); font-weight: 600; margin: 0 0 16px; }
|
||||
.welcome p { line-height: 1.7; color: var(--c-gray-600); }
|
||||
/* `.main-pane` is the §8 flex shell and carries no padding (the bare
|
||||
`.main-pane` override below shadows the padded read-view rule), so the
|
||||
welcome surface owns its own breathing room — top offset, comfortable
|
||||
side gutters, and a capped measure for readable line length. */
|
||||
.welcome {
|
||||
max-width: 680px;
|
||||
padding: 56px 48px 64px;
|
||||
}
|
||||
.welcome h1 {
|
||||
font-size: var(--text-3xl); font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 var(--space-8);
|
||||
}
|
||||
.welcome p {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--c-gray-600);
|
||||
margin: 0 0 var(--space-8);
|
||||
}
|
||||
.welcome p:last-child { margin-bottom: 0; }
|
||||
.welcome strong { color: var(--c-gray-800); }
|
||||
|
||||
/* --- RFC / Proposal view (read-only for slice 1) --- */
|
||||
|
||||
@@ -542,7 +578,10 @@
|
||||
font-size: var(--text-md); font-weight: 600; text-decoration: none;
|
||||
}
|
||||
.beta-pending-actions .btn-primary:hover { background: var(--c-gray-700); }
|
||||
.btn-link-quiet { color: var(--c-gray-500); text-decoration: none; font-size: var(--text-base); }
|
||||
.btn-link-quiet {
|
||||
background: none; border: none; padding: 0; cursor: pointer;
|
||||
color: var(--c-gray-500); text-decoration: none; font-size: var(--text-base);
|
||||
}
|
||||
.btn-link-quiet:hover { color: var(--c-ink); text-decoration: underline; }
|
||||
|
||||
/* v0.8.0 — thin "your beta access is in review" banner. Shown on every
|
||||
@@ -578,7 +617,7 @@
|
||||
}
|
||||
|
||||
.rfc-breadcrumb {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--c-gray-200);
|
||||
background: var(--c-gray-50);
|
||||
@@ -591,7 +630,25 @@
|
||||
}
|
||||
.breadcrumb-sep { color: var(--c-gray-300); }
|
||||
.breadcrumb-meta { color: var(--c-gray-500); font-size: var(--text-sm); }
|
||||
.breadcrumb-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
|
||||
.breadcrumb-actions {
|
||||
margin-left: auto;
|
||||
display: flex; flex-wrap: wrap; justify-content: flex-end;
|
||||
gap: 8px; align-items: center; min-width: 0;
|
||||
}
|
||||
/* Normalize every action in the bar to one height + shape so the mode
|
||||
* toggle, the filled CTAs (Start Contributing / Open PR / Graduate) and
|
||||
* the secondary buttons (Metadata / Claim ownership / Invitations / …)
|
||||
* line up as a single, intentional control group. Higher specificity
|
||||
* than the per-variant rules, so it harmonizes their size/radius/type
|
||||
* without disturbing each variant's fill colors. */
|
||||
.breadcrumb-actions > button,
|
||||
.breadcrumb-actions > a {
|
||||
display: inline-flex; align-items: center;
|
||||
height: 30px; padding: 0 12px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm); font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-mode-toggle {
|
||||
font-size: var(--text-sm); font-weight: 600;
|
||||
@@ -1354,6 +1411,41 @@
|
||||
.pr-breadcrumb a:hover { text-decoration: underline; }
|
||||
.pr-title { font-size: var(--text-xl); margin: 0 0 6px 0; }
|
||||
.pr-description { font-size: var(--text-base); color: var(--c-gray-600); margin: 0 0 6px 0; line-height: 1.55; }
|
||||
/* Roadmap #28 Part 1: inline auto-links to referenced RFCs inside PR text
|
||||
and comments. Subtle accent + dotted underline so they read as enriched
|
||||
references, not as primary navigation. */
|
||||
.rfc-autolink {
|
||||
color: var(--color-link);
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.rfc-autolink:hover { text-decoration-style: solid; }
|
||||
|
||||
/* #28 Parts 2–3: a matched term that isn't a live link but carries an
|
||||
offer (contribute to a pending RFC / create a new one). The term reads
|
||||
as enriched (dotted underline, no link colour); the offer is a small
|
||||
trailing chip so the prose stays readable. */
|
||||
.rfc-pending, .rfc-candidate {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.rfc-offer {
|
||||
margin-left: 4px;
|
||||
padding: 0 5px;
|
||||
font-size: 0.74em;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--color-border, #ccc);
|
||||
color: var(--color-link);
|
||||
}
|
||||
.rfc-offer:hover { background: var(--color-surface-alt, rgba(0,0,0,0.04)); }
|
||||
.rfc-offer-create { border-style: dashed; }
|
||||
.pr-header-edit { display: flex; flex-direction: column; gap: 8px; }
|
||||
.pr-header-right {
|
||||
display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
|
||||
@@ -1388,7 +1480,8 @@
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.diff-mode-toolbar .btn-link.active {
|
||||
font-weight: 600; color: var(--c-ink);
|
||||
font-weight: 600; color: var(--c-white);
|
||||
background: var(--c-ink); border-color: var(--c-ink);
|
||||
}
|
||||
.pr-diff-accent {
|
||||
margin-left: auto;
|
||||
@@ -1572,12 +1665,18 @@
|
||||
|
||||
/* ---- §15 / Slice 6: inbox, badge, toasts ---- */
|
||||
|
||||
/* Lives on the dark header — so it speaks the nav-link vocabulary
|
||||
(.header-about et al.): borderless, gray-300 icon brightening to white
|
||||
on a faint translucent-white hover. The old light-gray border + gray-50
|
||||
hover were styled for a light surface and rendered as a pale box that
|
||||
went white-on-white (invisible icon) on hover. */
|
||||
.inbox-trigger {
|
||||
position: relative; background: transparent; border: 1px solid var(--c-gray-200);
|
||||
border-radius: var(--radius-md); padding: 4px 10px; cursor: pointer; font-size: var(--text-lg);
|
||||
margin-right: 12px;
|
||||
position: relative; display: inline-flex; align-items: center; justify-content: center;
|
||||
background: transparent; border: none;
|
||||
color: var(--c-gray-300); cursor: pointer;
|
||||
padding: 5px 8px; border-radius: var(--radius-sm);
|
||||
}
|
||||
.inbox-trigger:hover { background: var(--c-gray-50); }
|
||||
.inbox-trigger:hover { color: var(--c-white); background: rgba(255,255,255,0.08); }
|
||||
.inbox-trigger .badge {
|
||||
position: absolute; top: -6px; right: -6px;
|
||||
background: #dc2626; color: white; font-size: var(--text-2xs);
|
||||
@@ -1821,7 +1920,7 @@
|
||||
}
|
||||
.settings-table th, .admin-table th {
|
||||
text-align: left; padding: 6px 8px;
|
||||
font-size: var(--text-xs); text-transform: uppercase;
|
||||
font-size: var(--text-xs); text-transform: uppercase; white-space: nowrap;
|
||||
color: var(--c-gray-500); letter-spacing: 0.05em; font-weight: 600;
|
||||
border-bottom: 1px solid var(--c-gray-200);
|
||||
}
|
||||
@@ -1902,7 +2001,21 @@
|
||||
.admin-tab-header h2 {
|
||||
margin: 0 0 4px; font-size: var(--text-xl); font-weight: 700;
|
||||
}
|
||||
.admin-tab-header p { margin: 0 0 24px; font-size: var(--text-base); }
|
||||
.admin-tab-header p { margin: 0 0 24px; font-size: var(--text-base); max-width: 70ch; line-height: var(--leading-normal); }
|
||||
/* Title row: heading on the left, primary action flush right. */
|
||||
.admin-tab-heading {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: var(--space-7); margin-bottom: var(--space-2);
|
||||
}
|
||||
.admin-tab-heading h2 { margin: 0; }
|
||||
.admin-tab-actions { flex-shrink: 0; }
|
||||
/* Inline DB-column references in admin copy read as quiet chips, not raw
|
||||
monospace runs jammed against the sans body. */
|
||||
.admin-tab-header code {
|
||||
font-family: var(--font-mono); font-size: var(--text-sm);
|
||||
background: var(--c-gray-100); color: var(--c-gray-700);
|
||||
padding: 1px 5px; border-radius: var(--radius-sm);
|
||||
}
|
||||
.admin-section-h {
|
||||
font-size: var(--text-base); text-transform: uppercase;
|
||||
letter-spacing: 0.05em; color: var(--c-gray-500);
|
||||
@@ -1942,8 +2055,23 @@
|
||||
.allowlist-add .btn-primary:hover:not(:disabled) { background: var(--c-gray-700); }
|
||||
.allowlist-add .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.user-cell { display: flex; flex-direction: column; gap: 1px; }
|
||||
.user-cell { display: flex; flex-direction: column; gap: 2px; }
|
||||
.user-cell-handle { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
|
||||
.user-handle { font-weight: 500; color: var(--c-gray-900); }
|
||||
/* "(pending invite)" — an unclaimed admin-created row. A quiet amber pill
|
||||
so the admin spots it at a glance without it shouting. */
|
||||
.invite-badge {
|
||||
font-size: var(--text-2xs); font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
padding: 1px 6px; border-radius: var(--radius-pill);
|
||||
background: var(--c-warning-bg); color: var(--c-warning-fg);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Timestamps: an intentional date-over-time stack rather than a ragged
|
||||
mid-value wrap. nowrap keeps each line whole. */
|
||||
.user-when { white-space: nowrap; }
|
||||
.user-when-date { display: block; color: var(--c-gray-700); }
|
||||
.user-when-time { display: block; font-size: var(--text-xs); }
|
||||
.mute-toggle {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: var(--text-base); cursor: pointer;
|
||||
|
||||
+106
-16
@@ -1,14 +1,21 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Routes, Route, Link, Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { getMe, subscribeToNotifications } from './api'
|
||||
import { anonymize, EVENTS, identify, track } from './lib/analytics'
|
||||
import { useLastState } from './lib/useLastState'
|
||||
import { brandTitle } from './lib/brand'
|
||||
import { entryPath, proposalPath } from './lib/entryPaths'
|
||||
import { useDeployment } from './context/DeploymentProvider'
|
||||
import ProjectLayout from './components/ProjectLayout.jsx'
|
||||
import Directory from './components/Directory.jsx'
|
||||
import ProjectSwitcher from './components/ProjectSwitcher.jsx'
|
||||
import Catalog from './components/Catalog.jsx'
|
||||
import Inbox from './components/Inbox.jsx'
|
||||
import RFCView from './components/RFCView.jsx'
|
||||
import PRView from './components/PRView.jsx'
|
||||
import ProposalView from './components/ProposalView.jsx'
|
||||
import ProposeModal from './components/ProposeModal.jsx'
|
||||
import ContributeRequestForm from './components/ContributeRequestForm.jsx'
|
||||
import Landing from './components/Landing.jsx'
|
||||
import Login from './components/Login.jsx'
|
||||
import BetaPending from './components/BetaPending.jsx'
|
||||
@@ -51,6 +58,27 @@ export default function App() {
|
||||
const [identifyReady, setIdentifyReady] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
// §22.9 — runtime deployment config (name for the brand, default project id
|
||||
// for building corpus links this slice; see DeploymentProvider).
|
||||
const deployment = useDeployment()
|
||||
// §22.4 — the project the viewer is currently in (from the /p/<id>/ URL),
|
||||
// so the propose modal (App-level chrome, above the route tree) targets the
|
||||
// right project. Falls back to the deployment default off a project route.
|
||||
const _projMatch = location.pathname.match(/^\/p\/([^/]+)/)
|
||||
const currentProjectId = (_projMatch && _projMatch[1]) || deployment.defaultProjectId
|
||||
// #28 Parts 2–3: the LinkedText create/contribute affordances route via
|
||||
// query params so they need no prop-threading from deep in a comment
|
||||
// list. `?propose=<term>` opens the propose modal pre-filled;
|
||||
// `?contribute=<slug>&term=<term>` opens the contribute-request form.
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const proposeParam = searchParams.get('propose')
|
||||
const contributeSlug = searchParams.get('contribute')
|
||||
const contributeTerm = searchParams.get('term')
|
||||
const clearParams = (...keys) => {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
keys.forEach(k => next.delete(k))
|
||||
setSearchParams(next, { replace: true })
|
||||
}
|
||||
// v0.15.0 — Page Viewed event taxonomy. We fire on every
|
||||
// route change; the analytics wrapper itself decides whether
|
||||
// anything ships out (consent + key check). The first fire is
|
||||
@@ -63,6 +91,16 @@ export default function App() {
|
||||
track(EVENTS.PAGE_VIEWED, { path: location.pathname })
|
||||
}, [location.pathname, location.search])
|
||||
|
||||
// §22.9 — tab title from runtime config. On deployment-chrome routes the
|
||||
// title is the deployment name; under a `/p/<project>/` route ProjectLayout
|
||||
// owns it (the project name), so skip those here.
|
||||
useEffect(() => {
|
||||
if (deployment.loading) return
|
||||
if (!location.pathname.startsWith('/p/')) {
|
||||
document.title = brandTitle(deployment.name)
|
||||
}
|
||||
}, [location.pathname, deployment.loading, deployment.name])
|
||||
|
||||
// v0.15.0 + #21 Part C — bind the authenticated user id AND
|
||||
// durable user properties to the analytics session when sign-in
|
||||
// lands; reset on sign-out (viewer flips to null). The wrapper
|
||||
@@ -153,12 +191,16 @@ export default function App() {
|
||||
// Churn never toasts; structural toasts only when it lands on
|
||||
// a slug the user is currently viewing (URL match).
|
||||
const isPersonal = payload.category === 'personal-direct'
|
||||
const onCurrentSlug = payload.rfc_slug && window.location.pathname.includes(`/rfc/${payload.rfc_slug}`)
|
||||
// §22.10: entry URLs are now /p/<project>/e/<slug>; match + link on the
|
||||
// generic /e/<slug> segment (default project is the served corpus).
|
||||
const onCurrentSlug = payload.rfc_slug && window.location.pathname.includes(`/e/${payload.rfc_slug}`)
|
||||
if (isPersonal || onCurrentSlug) {
|
||||
showToast({
|
||||
summary: payload.summary,
|
||||
category: payload.category,
|
||||
link: payload.rfc_slug ? `/rfc/${payload.rfc_slug}` : null,
|
||||
link: payload.rfc_slug && deployment.defaultProjectId
|
||||
? entryPath(deployment.defaultProjectId, payload.rfc_slug)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -168,7 +210,7 @@ export default function App() {
|
||||
},
|
||||
})
|
||||
return close
|
||||
}, [me?.authenticated])
|
||||
}, [me?.authenticated, deployment.defaultProjectId])
|
||||
|
||||
if (loading) {
|
||||
return <div className="boot">Loading…</div>
|
||||
@@ -191,7 +233,13 @@ export default function App() {
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<div className="app-brand">
|
||||
<Link to="/">{import.meta.env.VITE_APP_NAME}</Link>
|
||||
{/* §22.9: deployment name from runtime config (replaces the
|
||||
build-time VITE_APP_NAME); neutral 'RFC' during the pre-fetch
|
||||
paint via brandTitle(). */}
|
||||
<Link to="/">{brandTitle(deployment.name)}</Link>
|
||||
{/* §22.10: project switcher — deployment chrome; renders only when
|
||||
2+ projects are visible to the caller. */}
|
||||
<ProjectSwitcher />
|
||||
</div>
|
||||
<div className="header-right">
|
||||
{/* §14.3: the persistent About link. One word, no badge, no
|
||||
@@ -199,7 +247,7 @@ export default function App() {
|
||||
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)">
|
||||
Philosophy
|
||||
About
|
||||
</Link>
|
||||
<Link to="/docs" className="header-about" title="User guide">
|
||||
Docs
|
||||
@@ -294,8 +342,17 @@ export default function App() {
|
||||
{isAdmin && (
|
||||
<Route path="/admin/*" element={<AdminWithSidebar viewer={viewer} />} />
|
||||
)}
|
||||
<Route path="*" element={
|
||||
<>
|
||||
{/* §22.10: the deployment landing at `/` — redirect into the single
|
||||
visible project (N=1, OHM's "land in the corpus" UX) or show the
|
||||
directory when 2+ are visible. */}
|
||||
<Route path="/" element={<DeploymentLanding />} />
|
||||
{/* §22.10: per-project subtree. ProjectLayout fetches the project,
|
||||
applies its theme, provides ProjectContext, and guards the corpus
|
||||
(served only for the corpus-served default; others get a
|
||||
placeholder). The generic `/e/` segment carries every entry type;
|
||||
the noun ("RFC"/"Spec"/"Feature") is a type-driven label. */}
|
||||
<Route path="/p/:projectId/*" element={
|
||||
<ProjectLayout>
|
||||
<Catalog
|
||||
viewer={viewer}
|
||||
onProposeRFC={() => setProposeOpen(true)}
|
||||
@@ -303,27 +360,40 @@ export default function App() {
|
||||
/>
|
||||
<main className="main-pane">
|
||||
<Routes>
|
||||
<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)} />} />
|
||||
<Route path="" element={<Welcome viewer={viewer} />} />
|
||||
<Route path="e/:slug" element={<RFCView viewer={viewer} />} />
|
||||
<Route path="e/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} />
|
||||
<Route path="proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</>
|
||||
</ProjectLayout>
|
||||
} />
|
||||
{/* Any other path (incl. the retired bare-slug corpus URLs that
|
||||
somehow reach the SPA) lands on the deployment landing. */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
{proposeOpen && viewer && (
|
||||
{(proposeOpen || proposeParam != null) && viewer && (
|
||||
<ProposeModal
|
||||
viewer={viewer}
|
||||
onClose={() => setProposeOpen(false)}
|
||||
initialTitle={proposeParam || ''}
|
||||
projectId={currentProjectId}
|
||||
onClose={() => { setProposeOpen(false); clearParams('propose') }}
|
||||
onSubmitted={({ pr_number }) => {
|
||||
setProposeOpen(false)
|
||||
clearParams('propose')
|
||||
setCatalogVersion(v => v + 1)
|
||||
navigate(`/proposals/${pr_number}`)
|
||||
navigate(proposalPath(currentProjectId, pr_number))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{contributeSlug && viewer && (
|
||||
<ContributeRequestForm
|
||||
slug={contributeSlug}
|
||||
term={contributeTerm || ''}
|
||||
onClose={() => clearParams('contribute', 'term')}
|
||||
/>
|
||||
)}
|
||||
{inboxOpen && viewer && (
|
||||
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
|
||||
)}
|
||||
@@ -333,6 +403,26 @@ export default function App() {
|
||||
)
|
||||
}
|
||||
|
||||
function DeploymentLanding() {
|
||||
// §22.10 + design decision 2 — N=1 lands in the single visible project so
|
||||
// OHM's "land in the corpus" UX is preserved; the directory appears only
|
||||
// when 2+ projects are visible to the caller. Visibility is per-caller
|
||||
// (§22.5), so the unlisted projects never count toward the directory.
|
||||
const { projects, defaultProjectId, loading } = useDeployment()
|
||||
if (loading) {
|
||||
return <main className="chrome-pane"><div className="boot">Loading…</div></main>
|
||||
}
|
||||
if (projects.length === 1) {
|
||||
return <Navigate to={`/p/${projects[0].id}/`} replace />
|
||||
}
|
||||
if (projects.length === 0 && defaultProjectId) {
|
||||
// No enumerable projects but a default exists (e.g. an anon hitting a
|
||||
// deployment whose only project is unlisted-but-default) — land there.
|
||||
return <Navigate to={`/p/${defaultProjectId}/`} replace />
|
||||
}
|
||||
return <Directory />
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user