Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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/
|
||||
+340
-19
@@ -23,29 +23,350 @@ 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.31.5 — 2026-06-03
|
||||
## 0.38.0 — 2026-06-04
|
||||
|
||||
**Patch — supply-chain hygiene: pin security floors on two unbounded
|
||||
*transitive* dependencies so vulnerability scanners stay anchored to the
|
||||
patched range. No direct-import, schema, API, config, overlay, or secret
|
||||
change; deployed installs already satisfy these floors, so no rebuild or
|
||||
redeploy is required to become compliant. No upgrade steps.**
|
||||
**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.**
|
||||
|
||||
`backend/requirements.txt` declares only loose (`>=`) direct constraints
|
||||
and ships no compiled lockfile. osv-scanner v2 resolves the *transitive*
|
||||
graph and, for any unpinned transitive package, pins it to its **minimum**
|
||||
published version — surfacing CVEs in ancient releases that `pip install`
|
||||
would never actually choose. Two such phantoms were firing:
|
||||
Added:
|
||||
|
||||
- **`tqdm`** (pulled by `google-generativeai`) resolved to `4.9.0` (2016),
|
||||
flagging `GHSA-r7q7-xcjw-qx8q` (HIGH) and friends — fixed in 4.66.3.
|
||||
- **`idna`** (pulled by `httpx` / `anthropic`) resolved to `3.9.0`,
|
||||
flagging `GHSA-65pc-fj4g-8rjx` — fixed in 3.15.
|
||||
- **`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.
|
||||
|
||||
Adding `tqdm>=4.66.3` and `idna>=3.15` anchors the resolver to the patched
|
||||
range. The live deployment already runs patched versions (e.g. tqdm 4.67.x);
|
||||
this only corrects what the scanner *sees*. The proper long-term fix is a
|
||||
fully-compiled lockfile — tracked separately.
|
||||
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
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@~/.claude/wiggleverse.md
|
||||
|
||||
# Working in rfc-app
|
||||
|
||||
This is the framework — the software that hosts an RFC standardization
|
||||
|
||||
+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
|
||||
@@ -160,19 +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. 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 an integer
|
||||
`id` (`RFC-NNNN`) and `graduated_at`/`graduated_by`. 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
|
||||
- **`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
|
||||
@@ -192,12 +204,28 @@ 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
|
||||
|
||||
Changing state in the meta repo entry is the *only* required operation
|
||||
@@ -2034,9 +2062,14 @@ broadening rather than a precondition for the proposer's own RFC.)
|
||||
|
||||
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.
|
||||
- **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.
|
||||
|
||||
@@ -2044,17 +2077,20 @@ Clicking "Graduate" opens a small dialog with two editable fields:
|
||||
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, the at-least-one-owner constraint on
|
||||
the picker. 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. 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.
|
||||
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 entry has no
|
||||
owners. The disabled button opens a small popover on hover or click
|
||||
@@ -2070,14 +2106,16 @@ kept, so they coexist with graduation.
|
||||
|
||||
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`, `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.
|
||||
`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).
|
||||
|
||||
```
|
||||
super-draft entry ──[graduate]──▶ same entry, state: active, id assigned
|
||||
super-draft entry ──[graduate]──▶ same entry, state: active, id assigned OR null
|
||||
(body unchanged, repo: null, lives in rfcs/<slug>.md throughout)
|
||||
```
|
||||
|
||||
@@ -2144,6 +2182,41 @@ 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).
|
||||
|
||||
---
|
||||
|
||||
## 14. Outside the RFC view: landing and the philosophy surface
|
||||
|
||||
+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.
|
||||
|
||||
+238
-26
@@ -22,12 +22,14 @@ 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,
|
||||
@@ -146,6 +148,9 @@ def make_router(
|
||||
# (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.
|
||||
@@ -607,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()
|
||||
@@ -657,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
|
||||
@@ -679,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
|
||||
# ---------------------------------------------------------------
|
||||
@@ -694,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": [
|
||||
@@ -736,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
|
||||
@@ -768,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")
|
||||
@@ -780,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,
|
||||
@@ -809,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}"
|
||||
@@ -823,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,
|
||||
@@ -847,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
|
||||
@@ -899,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"],
|
||||
)
|
||||
@@ -919,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,
|
||||
@@ -943,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"],
|
||||
)
|
||||
@@ -995,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")
|
||||
|
||||
+64
-42
@@ -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']}")
|
||||
|
||||
@@ -289,7 +291,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)
|
||||
owner, repo = _repo_for(rfc)
|
||||
new_branch = (body.branch_name or "").strip()
|
||||
if not new_branch:
|
||||
@@ -363,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:
|
||||
@@ -408,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):
|
||||
@@ -481,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":
|
||||
@@ -568,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":
|
||||
@@ -586,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":
|
||||
@@ -653,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)
|
||||
@@ -730,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)
|
||||
@@ -740,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
|
||||
""",
|
||||
@@ -751,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(
|
||||
@@ -772,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(
|
||||
@@ -792,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(
|
||||
@@ -810,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(
|
||||
@@ -840,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)
|
||||
@@ -865,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")
|
||||
@@ -886,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
|
||||
@@ -894,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
|
||||
""",
|
||||
@@ -909,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):
|
||||
@@ -932,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")
|
||||
@@ -1018,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")
|
||||
|
||||
@@ -1089,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):
|
||||
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)
|
||||
row = _require_rfc(slug, viewer)
|
||||
if row["state"] == "withdrawn":
|
||||
raise HTTPException(409, "RFC is withdrawn")
|
||||
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
|
||||
@@ -1148,7 +1153,7 @@ def make_router(
|
||||
|
||||
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
|
||||
|
||||
@@ -1259,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)
|
||||
@@ -1266,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:
|
||||
@@ -1299,7 +1310,12 @@ def make_router(
|
||||
# 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 "[]")
|
||||
@@ -1310,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(
|
||||
"""
|
||||
@@ -1331,7 +1350,9 @@ def make_router(
|
||||
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 "[]")
|
||||
@@ -1342,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 "[]")
|
||||
@@ -1359,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)
|
||||
),
|
||||
@@ -1405,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 "[]")
|
||||
|
||||
@@ -55,16 +55,19 @@ class ContributionRequestBody(BaseModel):
|
||||
use_case: str | None = Field(default=None, max_length=_USE_CASE_MAX)
|
||||
|
||||
|
||||
def _require_super_draft(slug: str):
|
||||
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)."""
|
||||
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 FROM cached_rfcs WHERE slug = ?",
|
||||
"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
|
||||
@@ -88,7 +91,7 @@ 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 viewer.role in ("owner", "admin"):
|
||||
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."
|
||||
@@ -105,16 +108,18 @@ def make_router() -> APIRouter:
|
||||
@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 FROM cached_rfcs WHERE slug = ?",
|
||||
"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"])
|
||||
viewer = auth.current_user(request)
|
||||
|
||||
eligible = True
|
||||
reason: str | None = None
|
||||
@@ -159,7 +164,7 @@ def make_router() -> APIRouter:
|
||||
slug: str, body: ContributionRequestBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_super_draft(slug)
|
||||
_require_super_draft(slug, viewer)
|
||||
|
||||
reason = _viewer_relationship(viewer, slug)
|
||||
if reason is not None:
|
||||
@@ -214,7 +219,7 @@ def make_router() -> APIRouter:
|
||||
slug: str, request_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_super_draft(slug)
|
||||
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")
|
||||
|
||||
@@ -282,7 +287,7 @@ def make_router() -> APIRouter:
|
||||
slug: str, request_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_super_draft(slug)
|
||||
_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")
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
"""
|
||||
@@ -193,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(
|
||||
@@ -218,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(
|
||||
@@ -245,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
|
||||
|
||||
|
||||
@@ -305,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 "[]")
|
||||
|
||||
+251
-28
@@ -19,7 +19,10 @@ Routes (§17):
|
||||
- GET /api/rfcs/<slug>/blocking-prs (informational; no longer a
|
||||
graduation precondition)
|
||||
|
||||
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim).
|
||||
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim) and the
|
||||
§13.7 retire / un-retire endpoints (POST /api/rfcs/<slug>/retire,
|
||||
POST /api/rfcs/<slug>/unretire) — soft-delete and its site-owner-only
|
||||
reversal, each a single in-place frontmatter flip via an auto-merged PR.
|
||||
|
||||
The orchestrator runs in-process — each in-flight graduation lives in a
|
||||
small `GraduationState` keyed by slug, with an asyncio.Queue feeding the
|
||||
@@ -39,7 +42,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, cache, db, entry as entry_mod
|
||||
from . import auth, cache, db, entry as entry_mod, projects as projects_mod
|
||||
from .bot import Actor, Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
@@ -74,7 +77,7 @@ class StepState:
|
||||
@dataclass
|
||||
class GraduationState:
|
||||
slug: str
|
||||
rfc_id: str
|
||||
rfc_id: str | None # null when graduating without a number (§13.2)
|
||||
owners: list[str]
|
||||
arbiters: list[str]
|
||||
steps: list[StepState]
|
||||
@@ -113,7 +116,7 @@ def _get_active(slug: str) -> GraduationState | None:
|
||||
return _active.get(slug)
|
||||
|
||||
|
||||
def _new_active(slug: str, *, rfc_id: str,
|
||||
def _new_active(slug: str, *, rfc_id: str | None,
|
||||
owners: list[str], arbiters: list[str]) -> GraduationState:
|
||||
state = GraduationState(
|
||||
slug=slug, rfc_id=rfc_id,
|
||||
@@ -164,7 +167,12 @@ def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool:
|
||||
|
||||
|
||||
class GraduateBody(BaseModel):
|
||||
rfc_id: str = Field(min_length=5, max_length=40)
|
||||
# §13.2: the integer id is OPTIONAL. Blank/absent → graduate with no
|
||||
# number (id stays null, the slug is the canonical identifier per
|
||||
# §2.3). When supplied it must match ^RFC-\d{4,}$ and be free — both
|
||||
# are checked in the handler, not by the field bound (an empty string
|
||||
# is a legitimate value here).
|
||||
rfc_id: str | None = Field(default=None, max_length=40)
|
||||
owners: list[str] = Field(min_length=1)
|
||||
|
||||
|
||||
@@ -191,7 +199,7 @@ def make_router(
|
||||
@router.get("/api/rfcs/{slug}/blocking-prs")
|
||||
async def list_blocking_prs(slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
rfc = _require_super_draft(slug)
|
||||
rfc = _require_super_draft(slug, viewer)
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT pr_number, title, opened_by, opened_at, head_branch, pr_kind
|
||||
@@ -210,7 +218,7 @@ def make_router(
|
||||
can_merge = (
|
||||
viewer is not None
|
||||
and (
|
||||
viewer.role in ("owner", "admin")
|
||||
auth.is_project_superuser(viewer, rfc["project_id"])
|
||||
or viewer.gitea_login in owners
|
||||
or viewer.gitea_login in arbiters
|
||||
)
|
||||
@@ -250,21 +258,26 @@ def make_router(
|
||||
slug: str, request: Request,
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
rfc = _require_super_draft(slug)
|
||||
rfc = _require_super_draft(slug, viewer)
|
||||
del viewer # no permission gate — the dialog only shows up for
|
||||
# admins/owners, but the check itself is read-only.
|
||||
|
||||
candidate_id = (request.query_params.get("id") or "").strip()
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
|
||||
# ID field
|
||||
# ID field — §13.2: the integer id is OPTIONAL. A blank id is
|
||||
# valid and means "graduate without a number" (id stays null, the
|
||||
# slug is canonical per §2.3). Only a *non-blank* id is held to the
|
||||
# RFC-NNNN regex and the collision check.
|
||||
id_payload: dict[str, Any] = {"value": candidate_id, "ok": True, "error": None}
|
||||
if not candidate_id:
|
||||
id_payload["ok"] = False
|
||||
id_payload["error"] = "Integer ID is required"
|
||||
pass # blank is the default-accepted case, not an error
|
||||
elif not _is_valid_rfc_id(candidate_id):
|
||||
id_payload["ok"] = False
|
||||
id_payload["error"] = "ID must look like RFC-NNNN (at least four digits)"
|
||||
id_payload["error"] = (
|
||||
"ID must look like RFC-NNNN (at least four digits), "
|
||||
"or leave blank to graduate without a number"
|
||||
)
|
||||
elif _rfc_id_taken(candidate_id, excluding_slug=slug):
|
||||
id_payload["ok"] = False
|
||||
id_payload["error"] = f"Integer ID {candidate_id} is already taken"
|
||||
@@ -300,7 +313,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/graduate")
|
||||
async def graduate(slug: str, body: GraduateBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_super_draft(slug)
|
||||
rfc = _require_super_draft(slug, viewer)
|
||||
# §13: only owners/arbiters of the RFC and app admins/owners may
|
||||
# graduate. Until §13.1's claim runs the entry has no owners, so
|
||||
# the set collapses to app admins/owners for unclaimed entries.
|
||||
@@ -315,20 +328,24 @@ def make_router(
|
||||
# §13.2 atomic re-validation. The dialog's debounced check runs
|
||||
# client-side as the admin types; this is the authoritative check
|
||||
# that closes the dialog-open-to-confirm race on the integer ID.
|
||||
rfc_id = body.rfc_id.strip()
|
||||
# The id is OPTIONAL: a blank/absent id graduates with no number
|
||||
# (id stays null, slug is canonical per §2.3). Only a supplied id
|
||||
# is held to the regex and the collision re-check.
|
||||
rfc_id = (body.rfc_id or "").strip() or None
|
||||
owners = [o.strip() for o in body.owners if o.strip()]
|
||||
if not owners:
|
||||
raise HTTPException(422, "Add at least one initial owner")
|
||||
if not _is_valid_rfc_id(rfc_id):
|
||||
raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)")
|
||||
if _rfc_id_taken(rfc_id, excluding_slug=slug):
|
||||
raise HTTPException(409, f"Integer ID {rfc_id} is already taken")
|
||||
if rfc_id is not None:
|
||||
if not _is_valid_rfc_id(rfc_id):
|
||||
raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)")
|
||||
if _rfc_id_taken(rfc_id, excluding_slug=slug):
|
||||
raise HTTPException(409, f"Integer ID {rfc_id} is already taken")
|
||||
|
||||
# Read the meta-repo entry once — we need the file's sha for the
|
||||
# graduation PR's update_file call and the body to carry through
|
||||
# unchanged (meta-only keeps the body in the entry, §13.3).
|
||||
fetched = await gitea.read_file(
|
||||
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
|
||||
config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main",
|
||||
)
|
||||
if fetched is None:
|
||||
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
|
||||
@@ -441,7 +458,13 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/claim")
|
||||
async def claim_ownership(slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_super_draft(slug)
|
||||
rfc = _require_super_draft(slug, viewer)
|
||||
# §22.6/§22.7: claiming an unclaimed super-draft is a contribute action.
|
||||
# On a public project this is the pre-M2 baseline (an unclaimed entry
|
||||
# has no owners, so any granted contributor qualifies); on a gated
|
||||
# project it requires a project_contributor/admin grant.
|
||||
if not auth.can_contribute_to_rfc(viewer, slug):
|
||||
raise HTTPException(403, "You do not have contribute access to this project")
|
||||
existing_owners = json.loads(rfc["owners_json"] or "[]")
|
||||
if viewer.gitea_login in existing_owners:
|
||||
return {"ok": True, "noop": True}
|
||||
@@ -456,7 +479,7 @@ def make_router(
|
||||
raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}")
|
||||
|
||||
fetched = await gitea.read_file(
|
||||
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
|
||||
config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main",
|
||||
)
|
||||
if fetched is None:
|
||||
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
|
||||
@@ -472,7 +495,7 @@ def make_router(
|
||||
try:
|
||||
pr = await bot.open_claim_pr(
|
||||
viewer.as_actor(),
|
||||
org=config.gitea_org, meta_repo=config.meta_repo,
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
slug=slug,
|
||||
new_file_contents=new_contents, prior_sha=meta_sha,
|
||||
)
|
||||
@@ -482,18 +505,123 @@ def make_router(
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
return {"pr_number": pr["number"], "slug": slug, "branch_name": pr["head"]["ref"]}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §13.7: POST /api/rfcs/<slug>/retire
|
||||
# Soft-delete an entry — flip its frontmatter `state` to `retired` via
|
||||
# an auto-merged meta-repo PR (§13.3 machinery reused). RFC owners and
|
||||
# site owners only — NOT app admins (§3.1). Allowed from super-draft or
|
||||
# active; the body and every other field (including the integer id) are
|
||||
# kept, so the entry stays recoverable in git and un-retire restores it
|
||||
# exactly.
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/retire")
|
||||
async def retire_rfc(slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_retirable(slug)
|
||||
if not _can_retire(rfc, viewer):
|
||||
raise HTTPException(
|
||||
403, "Only this RFC's owners or a site owner may retire it"
|
||||
)
|
||||
prior_state = rfc["state"]
|
||||
entry, sha = await _read_meta_entry(slug)
|
||||
entry.state = "retired"
|
||||
await _run_state_flip(
|
||||
config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(),
|
||||
slug=slug, new_contents=entry_mod.serialize(entry), prior_sha=sha,
|
||||
verb="retire", target_state="retired",
|
||||
)
|
||||
_audit(
|
||||
viewer.user_id, viewer.gitea_login, "retire",
|
||||
rfc_slug=slug,
|
||||
details={"prior_state": prior_state, "rfc_id": entry.id},
|
||||
)
|
||||
await _refresh_catalog()
|
||||
return {"ok": True, "slug": slug, "state": "retired"}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §13.7: POST /api/rfcs/<slug>/unretire
|
||||
# Bring a retired entry back to the state it held before retirement.
|
||||
# Site owners only (§3.1) — tighter than retire itself, so a soft-delete
|
||||
# is always recoverable by the operator but an RFC owner cannot reverse
|
||||
# their own retirement.
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/unretire")
|
||||
async def unretire_rfc(slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
if viewer.role != "owner":
|
||||
raise HTTPException(403, "Only a site owner may un-retire an RFC")
|
||||
_require_retired(slug)
|
||||
restored = _prior_state_before_retire(slug)
|
||||
entry, sha = await _read_meta_entry(slug)
|
||||
entry.state = restored
|
||||
await _run_state_flip(
|
||||
config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(),
|
||||
slug=slug, new_contents=entry_mod.serialize(entry), prior_sha=sha,
|
||||
verb="unretire", target_state=restored,
|
||||
)
|
||||
_audit(
|
||||
viewer.user_id, viewer.gitea_login, "unretire",
|
||||
rfc_slug=slug,
|
||||
details={"restored_state": restored},
|
||||
)
|
||||
await _refresh_catalog()
|
||||
return {"ok": True, "slug": slug, "state": restored}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def _require_super_draft(slug: str):
|
||||
def _require_super_draft(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): gated → 404 to non-members.
|
||||
auth.require_project_readable(viewer, row["project_id"])
|
||||
if row["state"] != "super-draft":
|
||||
raise HTTPException(409, f"RFC is {row['state']}, not super-draft")
|
||||
return row
|
||||
|
||||
def _require_retirable(slug: str):
|
||||
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
if row["state"] not in ("super-draft", "active"):
|
||||
raise HTTPException(409, f"RFC is {row['state']}, cannot be retired")
|
||||
return row
|
||||
|
||||
def _require_retired(slug: str):
|
||||
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
if row["state"] != "retired":
|
||||
raise HTTPException(409, f"RFC is {row['state']}, not retired")
|
||||
return row
|
||||
|
||||
async def _read_meta_entry(slug: str) -> tuple[entry_mod.Entry, str]:
|
||||
fetched = await gitea.read_file(
|
||||
config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref="main",
|
||||
)
|
||||
if fetched is None:
|
||||
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
|
||||
text, file_sha = fetched
|
||||
try:
|
||||
return entry_mod.parse(text), file_sha
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Meta entry malformed: {e}")
|
||||
|
||||
async def _refresh_catalog() -> None:
|
||||
# Inline refresh so the catalog reflects the flip immediately; the
|
||||
# webhook/reconciler path is the steady-state per §4.1. A refresh
|
||||
# failure does not unwind the merge — the reconciler catches up.
|
||||
try:
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
await cache.refresh_meta_branches(config, gitea)
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
except Exception as e:
|
||||
log.warning("retire/unretire cache refresh failed for slug: %s", e)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@@ -528,7 +656,7 @@ async def _orchestrate(
|
||||
try:
|
||||
pr = await bot.open_graduation_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=config.meta_repo,
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
slug=state.slug,
|
||||
new_file_contents=graduated_contents,
|
||||
prior_sha=meta_file_sha,
|
||||
@@ -548,7 +676,7 @@ async def _orchestrate(
|
||||
try:
|
||||
await bot.merge_graduation_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=config.meta_repo,
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
pr_number=state.new_pr_number,
|
||||
head_branch=state.graduation_branch or "",
|
||||
slug=state.slug, rfc_id=state.rfc_id,
|
||||
@@ -610,7 +738,7 @@ async def _cleanup_unmerged(
|
||||
try:
|
||||
await bot.close_graduation_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=config.meta_repo,
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
pr_number=state.new_pr_number,
|
||||
head_branch=state.graduation_branch or "",
|
||||
slug=state.slug, reason="graduation merge failed",
|
||||
@@ -623,7 +751,7 @@ async def _cleanup_unmerged(
|
||||
await bot.delete_branch(
|
||||
actor,
|
||||
owner=config.gitea_org,
|
||||
repo=config.meta_repo,
|
||||
repo=(projects_mod.default_content_repo(config) or ""),
|
||||
branch=branch_name,
|
||||
slug=state.slug,
|
||||
action_kind="delete_post_merge_branch",
|
||||
@@ -666,13 +794,108 @@ async def _finish_failed(state: GraduationState, *, failed_at: str, on_behalf_of
|
||||
def _can_graduate(rfc, viewer) -> bool:
|
||||
if viewer is None:
|
||||
return False
|
||||
if viewer.role in ("owner", "admin"):
|
||||
# §6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters.
|
||||
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 "[]")
|
||||
return viewer.gitea_login in owners or viewer.gitea_login in arbiters
|
||||
|
||||
|
||||
def _can_retire(rfc, viewer) -> bool:
|
||||
"""§3.1: retire is narrower than graduate/withdraw — RFC owners
|
||||
(frontmatter) and site `owner`-role holders only, NOT app admins."""
|
||||
if viewer is None:
|
||||
return False
|
||||
if viewer.role == "owner": # site owner
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
return viewer.gitea_login in owners
|
||||
|
||||
|
||||
def _prior_state_before_retire(slug: str) -> str:
|
||||
"""§13.7: the state to restore on un-retire. Read it from the most
|
||||
recent `retire` audit row's `prior_state`; if that's missing (e.g. the
|
||||
entry was retired out of band), fall back to inferring from the cached
|
||||
row — an assigned integer id implies it was `active`, else `super-draft`.
|
||||
"""
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT details FROM actions
|
||||
WHERE rfc_slug = ? AND action_kind = 'retire'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if row and row["details"]:
|
||||
try:
|
||||
prior = json.loads(row["details"]).get("prior_state")
|
||||
if prior in ("super-draft", "active"):
|
||||
return prior
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
cached = db.conn().execute(
|
||||
"SELECT rfc_id FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
return "active" if (cached and cached["rfc_id"]) else "super-draft"
|
||||
|
||||
|
||||
async def _run_state_flip(
|
||||
*,
|
||||
config: Config,
|
||||
gitea: Gitea,
|
||||
bot: Bot,
|
||||
actor: Actor,
|
||||
slug: str,
|
||||
new_contents: str,
|
||||
prior_sha: str,
|
||||
verb: str,
|
||||
target_state: str,
|
||||
) -> None:
|
||||
"""§13.7: open + merge a retire / un-retire frontmatter flip PR. Runs
|
||||
inline (no SSE — the flip is a single quick state change, unlike the
|
||||
multi-step graduation that streams progress). On an open failure
|
||||
nothing was created; on a merge failure the half-open PR/branch is
|
||||
cleaned up. Either failure raises 502 so the caller does not record the
|
||||
transition as having happened."""
|
||||
try:
|
||||
pr = await bot.open_retire_flip_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
slug=slug, new_file_contents=new_contents, prior_sha=prior_sha,
|
||||
verb=verb, target_state=target_state,
|
||||
)
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
pr_number = pr["number"]
|
||||
head_branch = pr["head"]["ref"]
|
||||
try:
|
||||
await bot.merge_retire_flip_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
pr_number=pr_number, head_branch=head_branch,
|
||||
slug=slug, verb=verb,
|
||||
)
|
||||
except GiteaError as e:
|
||||
# Clean up the unmerged flip PR/branch so failed attempts don't
|
||||
# accumulate (mirrors graduation's `_cleanup_unmerged`).
|
||||
try:
|
||||
await bot.close_graduation_pr(
|
||||
actor, org=config.gitea_org, meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
pr_number=pr_number, head_branch=head_branch,
|
||||
slug=slug, reason=f"{verb} merge failed",
|
||||
)
|
||||
await bot.delete_branch(
|
||||
actor, owner=config.gitea_org, repo=(projects_mod.default_content_repo(config) or ""),
|
||||
branch=head_branch, slug=slug,
|
||||
action_kind="delete_post_merge_branch",
|
||||
reason=f"{verb} merge failed",
|
||||
)
|
||||
except Exception:
|
||||
log.exception("%s cleanup failed for %s", verb, slug)
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
|
||||
|
||||
def _audit(
|
||||
actor_user_id: int | None,
|
||||
on_behalf_of: str | None,
|
||||
|
||||
@@ -126,7 +126,7 @@ 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,
|
||||
@@ -151,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,
|
||||
@@ -207,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,
|
||||
@@ -373,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
|
||||
|
||||
@@ -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
|
||||
|
||||
+22
-19
@@ -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, rfc_links
|
||||
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,7 +207,7 @@ 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)
|
||||
@@ -373,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
|
||||
@@ -398,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
|
||||
@@ -420,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(
|
||||
@@ -447,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")
|
||||
@@ -479,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")
|
||||
@@ -510,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")
|
||||
@@ -535,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")
|
||||
@@ -661,20 +661,23 @@ 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. 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)
|
||||
row = _require_rfc(slug, viewer)
|
||||
if row["state"] not in ("active", "super-draft"):
|
||||
raise HTTPException(409, f"RFC is {row['state']}")
|
||||
return row
|
||||
@@ -688,7 +691,7 @@ def make_router(
|
||||
|
||||
def _owner_repo(rfc) -> tuple[str, str]:
|
||||
if _is_meta_resident(rfc):
|
||||
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
|
||||
|
||||
@@ -780,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)
|
||||
|
||||
|
||||
+162
-12
@@ -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__)
|
||||
@@ -706,22 +706,24 @@ class Bot:
|
||||
slug: str,
|
||||
new_file_contents: str,
|
||||
prior_sha: str,
|
||||
rfc_id: str,
|
||||
rfc_id: str | None,
|
||||
owners: list[str],
|
||||
) -> dict:
|
||||
"""§13.3 (meta-only): open a PR against the meta repo that flips the
|
||||
entry's frontmatter to `state: active` with the integer `id` and
|
||||
graduation stamps — **keeping the body unchanged** (§1 meta-only
|
||||
topology; no repo is created and no body is stripped). Branch name
|
||||
uses the `graduate-<slug>-<6hex>` shape — dash-separated like the
|
||||
other meta-repo branches per the §19.2 path-routing candidate.
|
||||
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.
|
||||
"""
|
||||
import secrets
|
||||
|
||||
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",
|
||||
@@ -736,11 +738,12 @@ 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"{id_line}"
|
||||
f"- Owners: {owners_str}\n\n"
|
||||
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"
|
||||
@@ -772,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
|
||||
@@ -788,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:
|
||||
@@ -809,6 +812,105 @@ class Bot:
|
||||
details={"rfc_id": rfc_id},
|
||||
)
|
||||
|
||||
# ----- §13.7 retire / un-retire: open + merge a state-flip PR -----
|
||||
|
||||
async def open_retire_flip_pr(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
meta_repo: str,
|
||||
slug: str,
|
||||
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,
|
||||
f"{verb}_pr_open",
|
||||
rfc_slug=slug,
|
||||
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(
|
||||
@@ -967,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(
|
||||
|
||||
+76
-29
@@ -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
|
||||
@@ -320,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:
|
||||
@@ -355,7 +385,7 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> 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
|
||||
@@ -377,7 +407,7 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
|
||||
"""
|
||||
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
|
||||
""",
|
||||
@@ -437,17 +467,29 @@ 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
|
||||
@@ -502,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,
|
||||
@@ -530,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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -663,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()
|
||||
|
||||
+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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -299,7 +299,14 @@ async def _delete_branch_via_bot(
|
||||
log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch)
|
||||
return False
|
||||
if not rfc["repo"]:
|
||||
owner, repo = config.gitea_org, config.meta_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:
|
||||
|
||||
+19
-1
@@ -28,8 +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,
|
||||
)
|
||||
@@ -100,6 +102,22 @@ 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
|
||||
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()
|
||||
@@ -128,7 +146,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:
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""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
|
||||
|
||||
from . import db
|
||||
from .config import Config
|
||||
|
||||
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 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,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)
|
||||
+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,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);
|
||||
@@ -9,12 +9,3 @@ google-generativeai>=0.8
|
||||
openai>=1.50
|
||||
PyYAML>=6.0
|
||||
bcrypt>=4.2
|
||||
|
||||
# Security floors on unbounded *transitive* deps (not direct imports). osv-scanner
|
||||
# resolves an unpinned transitive package to its MINIMUM PyPI version, which surfaces
|
||||
# CVEs in ancient releases pip would never actually install. These floors keep the
|
||||
# Patchwatch radar (and any scan) anchored to the patched range.
|
||||
# tqdm <- google-generativeai ; GHSA-r7q7-xcjw-qx8q et al., fixed in 4.66.3
|
||||
# idna <- httpx / anthropic ; GHSA-65pc-fj4g-8rjx, fixed in 3.15
|
||||
tqdm>=4.66.3
|
||||
idna>=3.15
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -514,6 +514,120 @@ def test_edit_branch_surfaces_normally_after_graduation(app_with_fake_gitea):
|
||||
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):
|
||||
"""§13.1: any signed-in contributor can claim ownership of an
|
||||
unclaimed super-draft; the result is a meta-repo PR
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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)
|
||||
@@ -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,24 +8,53 @@
|
||||
# /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: certbot promotes THIS server block to the HTTPS listener
|
||||
# (`listen 443 ssl`) and adds a separate port-80 → 443 redirect
|
||||
# block (see the install comment above). These response headers
|
||||
# therefore ride into the HTTPS server block on the VM. They use
|
||||
# `add_header ... always` so they also apply to nginx-generated
|
||||
# error responses (4xx/5xx), not just 200s.
|
||||
# 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
|
||||
@@ -99,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');
|
||||
@@ -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
+2547
-4
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.31.5",
|
||||
"version": "0.38.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",
|
||||
@@ -27,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"
|
||||
}
|
||||
}
|
||||
|
||||
+79
-12
@@ -3,6 +3,12 @@ import { Routes, Route, Link, Navigate, useLocation, useNavigate, useSearchParam
|
||||
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'
|
||||
@@ -52,6 +58,14 @@ 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;
|
||||
@@ -77,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
|
||||
@@ -167,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,
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -182,7 +210,7 @@ export default function App() {
|
||||
},
|
||||
})
|
||||
return close
|
||||
}, [me?.authenticated])
|
||||
}, [me?.authenticated, deployment.defaultProjectId])
|
||||
|
||||
if (loading) {
|
||||
return <div className="boot">Loading…</div>
|
||||
@@ -205,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
|
||||
@@ -308,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)}
|
||||
@@ -317,26 +360,30 @@ 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 || proposeParam != null) && viewer && (
|
||||
<ProposeModal
|
||||
viewer={viewer}
|
||||
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))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -356,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
|
||||
|
||||
+49
-8
@@ -169,24 +169,49 @@ export async function clearPasscode() {
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function listRFCs() {
|
||||
return jsonOrThrow(await fetch('/api/rfcs'))
|
||||
// ── §22.9/§22.10 (M3): runtime deployment + per-project config ────────────
|
||||
//
|
||||
// Replaces the build-time VITE_APP_NAME. `getDeployment` is fetched once on
|
||||
// boot (DeploymentProvider): { name, tagline, default_project_id, projects[] }.
|
||||
// `getProject` drives ProjectLayout's per-project chrome + theme.
|
||||
export async function getDeployment() {
|
||||
return jsonOrThrow(await fetch('/api/deployment'))
|
||||
}
|
||||
|
||||
export async function getRFC(slug) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}`))
|
||||
export async function getProject(projectId) {
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}`))
|
||||
}
|
||||
|
||||
export async function listProposals() {
|
||||
return jsonOrThrow(await fetch('/api/proposals'))
|
||||
// §22.4 (Plan B): per-project serving. Given a projectId, read the
|
||||
// project-scoped routes so a non-default project's corpus renders; without
|
||||
// one, fall back to the default-project compat path.
|
||||
export async function listRFCs(projectId) {
|
||||
const url = projectId ? `/api/projects/${projectId}/rfcs` : '/api/rfcs'
|
||||
return jsonOrThrow(await fetch(url))
|
||||
}
|
||||
|
||||
export async function getRFC(projectId, slug) {
|
||||
// Back-compat: getRFC(slug) (one arg) still hits the unscoped default path.
|
||||
if (slug === undefined) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${projectId}`))
|
||||
}
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`))
|
||||
}
|
||||
|
||||
export async function listProposals(projectId) {
|
||||
const url = projectId ? `/api/projects/${projectId}/proposals` : '/api/proposals'
|
||||
return jsonOrThrow(await fetch(url))
|
||||
}
|
||||
|
||||
export async function getProposal(prNumber) {
|
||||
return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`))
|
||||
}
|
||||
|
||||
export async function proposeRFC({ title, slug, pitch, tags, proposedUseCase }) {
|
||||
const res = await fetch('/api/rfcs/propose', {
|
||||
// §22.4 (Plan B write): propose into a specific project when projectId is
|
||||
// given; else the default-project compat path.
|
||||
export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase }) {
|
||||
const url = projectId ? `/api/projects/${projectId}/rfcs/propose` : '/api/rfcs/propose'
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// #26: proposed_use_case is optional; send null when blank so the
|
||||
@@ -530,6 +555,22 @@ export async function listBlockingPRs(slug) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/blocking-prs`))
|
||||
}
|
||||
|
||||
// §13.7 retire (soft delete). RFC owners + site owners may retire; only
|
||||
// site owners may un-retire. The backend gates both regardless of UI.
|
||||
export async function retireRFC(slug) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/retire`, { method: 'POST' }))
|
||||
}
|
||||
|
||||
export async function unretireRFC(slug) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/unretire`, { method: 'POST' }))
|
||||
}
|
||||
|
||||
// §13.7: the site-owner-only list of retired entries (for the admin
|
||||
// "Retired" surface, where un-retire lives).
|
||||
export async function listRetiredRFCs() {
|
||||
return jsonOrThrow(await fetch('/api/admin/retired-rfcs'))
|
||||
}
|
||||
|
||||
export async function graduateCheck(slug, { id }) {
|
||||
// Meta-only topology (§13.2): two fields — integer id + owners. No
|
||||
// repo name to validate.
|
||||
|
||||
@@ -23,10 +23,12 @@ import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { acceptInvitation, previewInvitation } from '../api'
|
||||
import { EVENTS, identify, track } from '../lib/analytics'
|
||||
import { entryPath, useProjectId } from '../lib/entryPaths'
|
||||
|
||||
export default function AcceptInvitation({ viewer }) {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const pid = useProjectId()
|
||||
const token = searchParams.get('token') || ''
|
||||
|
||||
const [preview, setPreview] = useState(null)
|
||||
@@ -75,7 +77,7 @@ export default function AcceptInvitation({ viewer }) {
|
||||
rfc_slug: result.rfc_slug,
|
||||
role_in_rfc: result.role_in_rfc || preview?.role_in_rfc,
|
||||
})
|
||||
navigate(`/rfc/${result.rfc_slug}`)
|
||||
navigate(entryPath(pid, result.rfc_slug))
|
||||
} catch (err) {
|
||||
setAcceptError(err.message || 'Could not accept invitation.')
|
||||
} finally {
|
||||
@@ -134,7 +136,7 @@ export default function AcceptInvitation({ viewer }) {
|
||||
The owner of <strong>{rfc_title}</strong> revoked this invitation.
|
||||
Ask them to re-issue it if you should still have access.
|
||||
</p>
|
||||
<p><Link to={`/rfc/${rfc_slug}`}>Read the RFC anyway</Link></p>
|
||||
<p><Link to={entryPath(pid, rfc_slug)}>Read the RFC anyway</Link></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -146,7 +148,7 @@ export default function AcceptInvitation({ viewer }) {
|
||||
This invitation to <strong>{rfc_title}</strong> has expired. Ask
|
||||
the RFC's owner to issue a fresh one.
|
||||
</p>
|
||||
<p><Link to={`/rfc/${rfc_slug}`}>Read the RFC anyway</Link></p>
|
||||
<p><Link to={entryPath(pid, rfc_slug)}>Read the RFC anyway</Link></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -156,7 +158,7 @@ export default function AcceptInvitation({ viewer }) {
|
||||
<h1>Already accepted</h1>
|
||||
<p>
|
||||
You've already accepted this invitation. You can{' '}
|
||||
<Link to={`/rfc/${rfc_slug}`}>open {rfc_title}</Link> now.
|
||||
<Link to={entryPath(pid, rfc_slug)}>open {rfc_title}</Link> now.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -200,7 +202,7 @@ export default function AcceptInvitation({ viewer }) {
|
||||
</button>
|
||||
</p>
|
||||
<p>
|
||||
<Link to={`/rfc/${rfc_slug}`}>or just read the RFC without accepting</Link>
|
||||
<Link to={entryPath(pid, rfc_slug)}>or just read the RFC without accepting</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -24,8 +24,11 @@ import {
|
||||
addAllowlistEmail,
|
||||
removeAllowlistEmail,
|
||||
createUserInvite,
|
||||
listRetiredRFCs,
|
||||
unretireRFC,
|
||||
} from '../api.js'
|
||||
import { EVENTS, track } from '../lib/analytics.js'
|
||||
import { entryPath, useProjectId } from '../lib/entryPaths'
|
||||
|
||||
// v0.17.0 — roadmap item #16. The max length the backend enforces
|
||||
// (Pydantic body bound + `invites.CUSTOM_MESSAGE_MAX_LENGTH`); kept
|
||||
@@ -42,12 +45,18 @@ const TABS = [
|
||||
]
|
||||
|
||||
export default function Admin({ viewer }) {
|
||||
// §13.7: the "Retired" surface (un-retire) is site-owner-only. The
|
||||
// backend gates /api/admin/retired-rfcs and /unretire on the owner role
|
||||
// regardless; we only show the link to owners so admins aren't offered
|
||||
// a tab that would 403.
|
||||
const isSiteOwner = viewer.role === 'owner'
|
||||
const tabs = isSiteOwner ? [...TABS, { path: 'retired', label: 'Retired' }] : TABS
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<nav className="admin-rail">
|
||||
<h2>Admin</h2>
|
||||
<ul>
|
||||
{TABS.map(t => (
|
||||
{tabs.map(t => (
|
||||
<li key={t.path}>
|
||||
<NavLink
|
||||
to={t.path}
|
||||
@@ -69,6 +78,7 @@ export default function Admin({ viewer }) {
|
||||
<Route path="users" element={<UsersTab />} />
|
||||
<Route path="allowlist" element={<AllowlistTab />} />
|
||||
<Route path="graduation" element={<GraduationTab />} />
|
||||
{isSiteOwner && <Route path="retired" element={<RetiredTab />} />}
|
||||
<Route path="audit" element={<AuditTab />} />
|
||||
<Route path="permissions" element={<PermissionsTab />} />
|
||||
</Routes>
|
||||
@@ -707,6 +717,7 @@ function AllowlistTab() {
|
||||
function GraduationTab() {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const pid = useProjectId()
|
||||
|
||||
useEffect(() => {
|
||||
listGraduationQueue()
|
||||
@@ -734,7 +745,7 @@ function GraduationTab() {
|
||||
<ul className="grad-queue">
|
||||
{data.ready.map(item => (
|
||||
<li key={item.slug}>
|
||||
<Link to={`/rfc/${item.slug}`} className="grad-queue-link">
|
||||
<Link to={entryPath(pid, item.slug)} className="grad-queue-link">
|
||||
<strong>{item.title}</strong>
|
||||
<span className="muted"> — owners: {item.owners.join(', ')}</span>
|
||||
</Link>
|
||||
@@ -749,7 +760,7 @@ function GraduationTab() {
|
||||
<ul className="grad-queue">
|
||||
{data.blocked.map(item => (
|
||||
<li key={item.slug}>
|
||||
<Link to={`/rfc/${item.slug}`} className="grad-queue-link">
|
||||
<Link to={entryPath(pid, item.slug)} className="grad-queue-link">
|
||||
<strong>{item.title}</strong>
|
||||
<span className="muted">
|
||||
{' — '}
|
||||
@@ -765,6 +776,76 @@ function GraduationTab() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Retired (soft-deleted) entries — site-owner-only un-retire (§13.7) ─────
|
||||
|
||||
function RetiredTab() {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState({})
|
||||
|
||||
const load = () => {
|
||||
listRetiredRFCs()
|
||||
.then(setData)
|
||||
.catch(e => setError(e.message))
|
||||
}
|
||||
useEffect(load, [])
|
||||
|
||||
const onUnretire = async (slug) => {
|
||||
setBusy(b => ({ ...b, [slug]: true }))
|
||||
setError(null)
|
||||
try {
|
||||
await unretireRFC(slug)
|
||||
load()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setBusy(b => ({ ...b, [slug]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <p className="settings-note warning">{error}</p>
|
||||
if (data == null) return <p className="muted">Loading retired entries…</p>
|
||||
|
||||
return (
|
||||
<div className="admin-tab">
|
||||
<header className="admin-tab-header">
|
||||
<h2>Retired</h2>
|
||||
<p className="muted">
|
||||
Soft-deleted RFCs (§13.7). They are hidden from the catalog and
|
||||
every view; the entry stays in the meta repo. Un-retiring restores
|
||||
an entry to the state it held before. Site owners only.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<h3 className="admin-section-h">Retired ({data.items.length})</h3>
|
||||
{data.items.length === 0 && (
|
||||
<p className="muted">No retired entries.</p>
|
||||
)}
|
||||
<ul className="grad-queue">
|
||||
{data.items.map(item => (
|
||||
<li key={item.slug}>
|
||||
<span className="grad-queue-link">
|
||||
<strong>{item.title}</strong>
|
||||
<span className="muted">
|
||||
{' — '}{item.id || item.slug}
|
||||
{`; restores to ${item.restores_to}`}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
disabled={!!busy[item.slug]}
|
||||
onClick={() => onUnretire(item.slug)}
|
||||
>
|
||||
{busy[item.slug] ? 'Un-retiring…' : 'Un-retire'}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Audit log (`actions`) — filter chips + paging ──────────────────────────
|
||||
|
||||
function AuditTab() {
|
||||
|
||||
@@ -12,17 +12,21 @@
|
||||
// ask-the-operator line.
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useDeployment } from '../context/DeploymentProvider'
|
||||
import { brandTitle } from '../lib/brand'
|
||||
|
||||
export default function BetaPending({ viewer }) {
|
||||
const contact = import.meta.env.VITE_BETA_CONTACT || ''
|
||||
const isPending = viewer?.permission_state === 'pending'
|
||||
// §22.9: deployment name from runtime config (was VITE_APP_NAME).
|
||||
const { name } = useDeployment()
|
||||
return (
|
||||
<div className="beta-pending">
|
||||
<div className="beta-pending-inner">
|
||||
<h1>
|
||||
{isPending
|
||||
? 'Your request is in review.'
|
||||
: `${import.meta.env.VITE_APP_NAME} is in private Beta.`}
|
||||
: `${brandTitle(name)} is in private Beta.`}
|
||||
</h1>
|
||||
{isPending ? (
|
||||
<>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { listRFCs, listProposals } from '../api'
|
||||
import { entryPath, proposalPath, useProjectId } from '../lib/entryPaths'
|
||||
|
||||
const STATE_CHIPS = [
|
||||
{ id: 'super-draft', label: 'Super-draft' },
|
||||
@@ -30,11 +31,12 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
const [activeChips, setActiveChips] = useState(new Set())
|
||||
const [pendingOpen, setPendingOpen] = useState(true)
|
||||
const { slug, prNumber } = useParams()
|
||||
const pid = useProjectId()
|
||||
|
||||
useEffect(() => {
|
||||
listRFCs().then(d => setRfcs(d.items)).catch(() => setRfcs([]))
|
||||
listProposals().then(d => setProposals(d.items)).catch(() => setProposals([]))
|
||||
}, [version])
|
||||
listRFCs(pid).then(d => setRfcs(d.items)).catch(() => setRfcs([]))
|
||||
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
|
||||
}, [version, pid])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase()
|
||||
@@ -103,7 +105,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
return (
|
||||
<Link
|
||||
key={r.slug}
|
||||
to={`/rfc/${r.slug}`}
|
||||
to={entryPath(pid, r.slug)}
|
||||
className={`catalog-row ${isActive ? 'active' : ''} ${isSuper ? 'is-super' : ''}`}
|
||||
>
|
||||
<div className="row-top">
|
||||
@@ -131,7 +133,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
{proposals.map(p => (
|
||||
<Link
|
||||
key={p.pr_number}
|
||||
to={`/proposals/${p.pr_number}`}
|
||||
to={proposalPath(pid, p.pr_number)}
|
||||
className={`pending-row ${String(prNumber) === String(p.pr_number) ? 'active' : ''}`}
|
||||
>
|
||||
<div>{p.title.replace(/^Propose:\s*/, '')}</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// §22.10 (M3) — the deployment directory at `/`. Renders the caller-visible
|
||||
// projects (from /api/deployment) as cards linking into each project's
|
||||
// `/p/<id>/` home. App's DeploymentLanding only mounts this when 2+ projects
|
||||
// are visible; the N=1 case redirects straight into the single project so
|
||||
// OHM's "land in the corpus" UX is preserved.
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useDeployment } from '../context/DeploymentProvider'
|
||||
import { entryNoun } from './ProjectLayout.jsx'
|
||||
|
||||
export default function Directory() {
|
||||
const { name, tagline, projects } = useDeployment()
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
<div className="directory">
|
||||
<h1>{name}</h1>
|
||||
{tagline && <p className="directory-tagline">{tagline}</p>}
|
||||
<ul className="directory-list">
|
||||
{projects.map(p => (
|
||||
<li key={p.id} className="directory-card">
|
||||
<Link to={`/p/${p.id}/`}>
|
||||
<span className="directory-card-name">{p.name}</span>
|
||||
<span className="directory-card-type">{entryNoun(p.type)}s</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
|
||||
// Mocked first so Directory (and ProjectLayout, which it imports entryNoun
|
||||
// from) resolve the deployment hook and api without a live fetch.
|
||||
let mockProjects = []
|
||||
vi.mock('../api', () => ({ getProject: vi.fn() }))
|
||||
vi.mock('../context/DeploymentProvider', () => ({
|
||||
useDeployment: () => ({ name: 'Wiggleverse', tagline: 'a directory of projects', projects: mockProjects, defaultProjectId: null }),
|
||||
}))
|
||||
import Directory from './Directory.jsx'
|
||||
|
||||
function renderDir(projects) {
|
||||
mockProjects = projects
|
||||
return render(<MemoryRouter><Directory /></MemoryRouter>)
|
||||
}
|
||||
|
||||
describe('Directory', () => {
|
||||
it('renders a card per visible project with the type-driven noun + link', () => {
|
||||
renderDir([
|
||||
{ id: 'ohm', name: 'Open Human Model', type: 'document', visibility: 'public' },
|
||||
{ id: 'ecomm', name: 'Ecomm', type: 'bdd', visibility: 'public' },
|
||||
])
|
||||
const ohm = screen.getByText('Open Human Model').closest('a')
|
||||
expect(ohm).toHaveAttribute('href', '/p/ohm/')
|
||||
const ecomm = screen.getByText('Ecomm').closest('a')
|
||||
expect(ecomm).toHaveAttribute('href', '/p/ecomm/')
|
||||
// bdd → "Features", document → "RFCs"
|
||||
expect(screen.getByText('RFCs')).toBeInTheDocument()
|
||||
expect(screen.getByText('Features')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the deployment name and tagline', () => {
|
||||
renderDir([{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }])
|
||||
expect(screen.getByText('Wiggleverse')).toBeInTheDocument()
|
||||
expect(screen.getByText('a directory of projects')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an empty list without crashing when no projects are visible', () => {
|
||||
renderDir([])
|
||||
expect(screen.getByText('Wiggleverse')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -109,20 +109,25 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
<p className="modal-intro">
|
||||
§13: graduate the super-draft to active. This is an in-place
|
||||
state flip — the entry keeps its body and stays in the meta
|
||||
repo; only the frontmatter changes (state, integer ID, and the
|
||||
graduation stamps). No new repository is created.
|
||||
repo; only the frontmatter changes (state, the optional
|
||||
integer ID, and the graduation stamps). No new repository is
|
||||
created.
|
||||
</p>
|
||||
|
||||
<div className="form-row">
|
||||
<label>Integer ID</label>
|
||||
<label>Integer ID <span className="field-optional">(optional)</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={rfcId}
|
||||
onChange={(e) => setRfcId(e.target.value.trim())}
|
||||
placeholder="RFC-NNNN"
|
||||
placeholder="RFC-NNNN — or leave blank"
|
||||
disabled={phase !== 'idle'}
|
||||
/>
|
||||
<p className="field-help">Pre-filled as the next free integer; editable to reserve gaps.</p>
|
||||
<p className="field-help">
|
||||
Pre-filled as the next free integer; editable to reserve gaps,
|
||||
or <strong>clear it to graduate without a number</strong> — the
|
||||
slug stays the canonical identifier.
|
||||
</p>
|
||||
{idError && <p className="field-error">{idError}</p>}
|
||||
</div>
|
||||
|
||||
@@ -186,8 +191,10 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
|
||||
<div className="graduation-complete">
|
||||
<h3>Graduation complete</h3>
|
||||
<p>
|
||||
`{slug}` is now active as <strong>{streamState?.rfc_id}</strong>.
|
||||
The catalog and the RFC view reflect the new state.
|
||||
{streamState?.rfc_id
|
||||
? <>`{slug}` is now active as <strong>{streamState.rfc_id}</strong>.</>
|
||||
: <>`{slug}` is now active (no number — identified by its slug).</>}
|
||||
{' '}The catalog and the RFC view reflect the new state.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
markNotificationRead,
|
||||
markNotificationsReadByFilter,
|
||||
} from '../api.js'
|
||||
import { entryPath, entryPrPath, useProjectId } from '../lib/entryPaths'
|
||||
import './Inbox.css'
|
||||
|
||||
const CATEGORIES = [
|
||||
@@ -228,11 +229,12 @@ function ContributionRequestRow({ item, onMarkRead }) {
|
||||
}
|
||||
|
||||
function InboxRow({ item, onClick, onMarkRead, onClose }) {
|
||||
const pid = useProjectId()
|
||||
if (item.event_kind === 'contribution_request_on_pending_rfc') {
|
||||
return <ContributionRequestRow item={item} onMarkRead={onMarkRead} />
|
||||
}
|
||||
const unread = !item.read_at
|
||||
const target = deepLink(item)
|
||||
const target = deepLink(item, pid)
|
||||
const handle = async () => {
|
||||
await onClick(item)
|
||||
if (target) onClose?.()
|
||||
@@ -277,10 +279,10 @@ function InboxRow({ item, onClick, onMarkRead, onClose }) {
|
||||
)
|
||||
}
|
||||
|
||||
function deepLink(item) {
|
||||
if (item.rfc_slug && item.pr_number) return `/rfc/${item.rfc_slug}/pr/${item.pr_number}`
|
||||
if (item.rfc_slug && item.branch_name) return `/rfc/${item.rfc_slug}?branch=${item.branch_name}`
|
||||
if (item.rfc_slug) return `/rfc/${item.rfc_slug}`
|
||||
function deepLink(item, pid) {
|
||||
if (item.rfc_slug && item.pr_number) return entryPrPath(pid, item.rfc_slug, item.pr_number)
|
||||
if (item.rfc_slug && item.branch_name) return `${entryPath(pid, item.rfc_slug)}?branch=${item.branch_name}`
|
||||
if (item.rfc_slug) return entryPath(pid, item.rfc_slug)
|
||||
return ''
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,18 @@
|
||||
// the framework source stays brand-neutral.
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useDeployment } from '../context/DeploymentProvider'
|
||||
import { brandTitle } from '../lib/brand'
|
||||
|
||||
export default function Landing() {
|
||||
// §22.9: deployment name from runtime config (was VITE_APP_NAME). The
|
||||
// subtitle/pitch/deck/attribution below are still OHM-specific copy — lifting
|
||||
// those into deployment config is the separate §19.2/landing.json slice.
|
||||
const { name } = useDeployment()
|
||||
return (
|
||||
<div className="landing">
|
||||
<div className="landing-inner">
|
||||
<h1>{import.meta.env.VITE_APP_NAME}</h1>
|
||||
<h1>{brandTitle(name)}</h1>
|
||||
<p className="subtitle">
|
||||
An open dictionary of the words humans and machines need to agree on.
|
||||
</p>
|
||||
|
||||
@@ -27,8 +27,10 @@
|
||||
// pass segments).
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
import { entryPath, useProjectId } from '../lib/entryPaths'
|
||||
|
||||
export default function LinkedText({ segments, text, viewer, canCreate }) {
|
||||
const pid = useProjectId()
|
||||
if (!Array.isArray(segments) || segments.length === 0) {
|
||||
return <>{text ?? ''}</>
|
||||
}
|
||||
@@ -43,7 +45,7 @@ export default function LinkedText({ segments, text, viewer, canCreate }) {
|
||||
<a
|
||||
key={i}
|
||||
className="rfc-autolink"
|
||||
href={`/rfc/${seg.slug}`}
|
||||
href={entryPath(pid, seg.slug)}
|
||||
title={seg.title ? `RFC: ${seg.title}` : undefined}
|
||||
>
|
||||
{seg.label}
|
||||
|
||||
@@ -86,8 +86,12 @@ import {
|
||||
} from '../api'
|
||||
import TurnstileWidget, { turnstileEnabled } from './TurnstileWidget'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
import { useDeployment } from '../context/DeploymentProvider'
|
||||
import { brandTitle } from '../lib/brand'
|
||||
|
||||
export default function Login() {
|
||||
// §22.9: deployment name from runtime config (was VITE_APP_NAME).
|
||||
const { name: deploymentName } = useDeployment()
|
||||
// Steps: 'email' → 'passcode' or 'code' → (on the OTC path, after
|
||||
// verify) one of: 'capture-profile' (pending user), 'offer-passcode'
|
||||
// (no passcode yet), or straight to "/". 'set-passcode' is reached
|
||||
@@ -591,7 +595,7 @@ export default function Login() {
|
||||
{step === 'capture-profile' && (
|
||||
<form onSubmit={submitProfile}>
|
||||
<p className="otc-hint">
|
||||
You're signed in. {import.meta.env.VITE_APP_NAME} is in private
|
||||
You're signed in. {brandTitle(deploymentName)} is in private
|
||||
beta — tell us a bit about yourself and an admin will review
|
||||
your request.
|
||||
</p>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { entryPath, useProjectId } from '../lib/entryPaths'
|
||||
import {
|
||||
getNotificationPreferences,
|
||||
setNotificationPreferences,
|
||||
@@ -611,6 +612,7 @@ function QuietHoursSection() {
|
||||
function WatchesSection() {
|
||||
const [watches, setWatches] = useState(null)
|
||||
const [updating, setUpdating] = useState({})
|
||||
const pid = useProjectId()
|
||||
|
||||
useEffect(() => { listWatches().then(r => setWatches(r.items || [])) }, [])
|
||||
|
||||
@@ -651,7 +653,7 @@ function WatchesSection() {
|
||||
{watches.map(w => (
|
||||
<tr key={w.rfc_slug}>
|
||||
<td>
|
||||
<Link to={`/rfc/${w.rfc_slug}`}>{w.rfc_title || w.rfc_slug}</Link>
|
||||
<Link to={entryPath(pid, w.rfc_slug)}>{w.rfc_title || w.rfc_slug}</Link>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
|
||||
@@ -23,11 +23,13 @@ import {
|
||||
withdrawPR,
|
||||
} from '../api'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
import { entryPath, entryPrPath, useProjectId } from '../lib/entryPaths'
|
||||
import LinkedText from './LinkedText'
|
||||
|
||||
export default function PRView({ viewer }) {
|
||||
const { slug, prNumber: prNumberParam } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const pid = useProjectId()
|
||||
const prNumber = Number(prNumberParam)
|
||||
|
||||
const [pr, setPR] = useState(null)
|
||||
@@ -101,13 +103,13 @@ export default function PRView({ viewer }) {
|
||||
setError(null)
|
||||
try {
|
||||
const { resolution_branch } = await startResolutionBranch(slug, prNumber)
|
||||
navigate(`/rfc/${slug}?branch=${encodeURIComponent(resolution_branch)}`)
|
||||
navigate(`${entryPath(pid, slug)}?branch=${encodeURIComponent(resolution_branch)}`)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setActing(null)
|
||||
}
|
||||
}, [slug, prNumber, navigate])
|
||||
}, [slug, prNumber, navigate, pid])
|
||||
|
||||
const startHeaderEdit = useCallback(() => {
|
||||
setDraftTitle(pr?.title || '')
|
||||
@@ -188,9 +190,9 @@ export default function PRView({ viewer }) {
|
||||
<div className="pr-header">
|
||||
<div className="pr-header-left">
|
||||
<div className="pr-breadcrumb">
|
||||
<a href={`/rfc/${slug}`}>{pr.rfc_id || pr.slug}</a>
|
||||
<a href={entryPath(pid, slug)}>{pr.rfc_id || pr.slug}</a>
|
||||
<span className="breadcrumb-sep">›</span>
|
||||
<a href={`/rfc/${slug}?branch=${encodeURIComponent(pr.head_branch)}`}>{pr.head_branch}</a>
|
||||
<a href={`${entryPath(pid, slug)}?branch=${encodeURIComponent(pr.head_branch)}`}>{pr.head_branch}</a>
|
||||
<span className="breadcrumb-sep">›</span>
|
||||
<strong>PR #{pr.pr_number}</strong>
|
||||
</div>
|
||||
@@ -244,8 +246,8 @@ export default function PRView({ viewer }) {
|
||||
<StateBanner state={pr.state} mergedAt={pr.merged_at} closedAt={pr.closed_at} />
|
||||
{(supersedes || supersededBy) && (
|
||||
<div className="pr-supersedes">
|
||||
{supersedes && <>Supersedes <a href={`/rfc/${slug}/pr/${supersedes}`}>#{supersedes}</a></>}
|
||||
{supersededBy && <>Closed by <a href={`/rfc/${slug}/pr/${supersededBy}`}>#{supersededBy}</a></>}
|
||||
{supersedes && <>Supersedes <a href={entryPrPath(pid, slug, supersedes)}>#{supersedes}</a></>}
|
||||
{supersededBy && <>Closed by <a href={entryPrPath(pid, slug, supersededBy)}>#{supersededBy}</a></>}
|
||||
</div>
|
||||
)}
|
||||
<div className="pr-counts">
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// §22.10 (M3) — the per-project layout mounted at /p/:projectId/*.
|
||||
//
|
||||
// Responsibilities:
|
||||
// - fetch GET /api/projects/:id (404 → "not found / no access")
|
||||
// - apply the project's theme as :root CSS custom-property overrides,
|
||||
// reset on unmount/switch so one project's accent never bleeds into
|
||||
// deployment chrome or another project (§22.9)
|
||||
// - set the tab title to the project name
|
||||
// - provide ProjectContext { project, projectId, served, noun }
|
||||
// - the §4 guard: render the served corpus (children) only for the
|
||||
// corpus-served (default) project; any other id gets a "content not yet
|
||||
// served" placeholder (the per-project RFC serving is Plan B, a later
|
||||
// slice). This decouples M3-frontend from Plan B without ever showing
|
||||
// default-project content under the wrong project's chrome.
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { getProject } from '../api'
|
||||
import { brandTitle } from '../lib/brand'
|
||||
|
||||
const ProjectContext = createContext(null)
|
||||
|
||||
export function useProject() {
|
||||
return useContext(ProjectContext)
|
||||
}
|
||||
|
||||
// §22.4a — the entry noun the chrome shows around a slug, driven by project
|
||||
// type. The route segment stays the generic /e/; only the label varies.
|
||||
export function entryNoun(type) {
|
||||
if (type === 'specification') return 'Spec'
|
||||
if (type === 'bdd') return 'Feature'
|
||||
return 'RFC'
|
||||
}
|
||||
|
||||
// Which theme keys map onto which tokens.css custom properties. Kept small and
|
||||
// explicit (§22.9 ships `accent`); grows as the per-project theme surface does.
|
||||
const THEME_TOKENS = {
|
||||
accent: '--c-accent',
|
||||
accentStrong: '--c-accent-strong',
|
||||
}
|
||||
|
||||
export default function ProjectLayout({ children }) {
|
||||
const { projectId } = useParams()
|
||||
const [project, setProject] = useState(null)
|
||||
const [status, setStatus] = useState('loading') // loading | ready | notfound
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setStatus('loading')
|
||||
getProject(projectId)
|
||||
.then(p => { if (!cancelled) { setProject(p); setStatus('ready') } })
|
||||
.catch(() => { if (!cancelled) { setProject(null); setStatus('notfound') } })
|
||||
return () => { cancelled = true }
|
||||
}, [projectId])
|
||||
|
||||
// Theme overlay — apply then reset (cleanup runs on project change/unmount).
|
||||
useEffect(() => {
|
||||
const theme = project?.theme
|
||||
if (!theme) return undefined
|
||||
const root = document.documentElement
|
||||
const applied = []
|
||||
for (const [key, value] of Object.entries(theme)) {
|
||||
const prop = THEME_TOKENS[key]
|
||||
if (prop && value) {
|
||||
root.style.setProperty(prop, value)
|
||||
applied.push(prop)
|
||||
}
|
||||
}
|
||||
return () => { applied.forEach(prop => root.style.removeProperty(prop)) }
|
||||
}, [project])
|
||||
|
||||
// Tab title — project name (deployment chrome owns the deployment name).
|
||||
useEffect(() => {
|
||||
if (project?.name) document.title = brandTitle(project.name)
|
||||
}, [project])
|
||||
|
||||
if (status === 'loading') {
|
||||
return <main className="chrome-pane"><div className="boot">Loading…</div></main>
|
||||
}
|
||||
if (status === 'notfound') {
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
<div className="welcome">
|
||||
<h1>Not found.</h1>
|
||||
<p>There is no such project here, or you don't have access to it.</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
// §22.4 (Plan B): every registry project now serves its own corpus, so
|
||||
// there is no "not served" guard — a readable project renders its children.
|
||||
return (
|
||||
<ProjectContext.Provider value={{ project, projectId, noun: entryNoun(project?.type) }}>
|
||||
{children}
|
||||
</ProjectContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
import ProjectLayout from './ProjectLayout.jsx'
|
||||
|
||||
vi.mock('../api', () => ({ getProject: vi.fn() }))
|
||||
vi.mock('../context/DeploymentProvider', () => ({
|
||||
useDeployment: () => ({ defaultProjectId: 'default', name: 'Dep', projects: [] }),
|
||||
}))
|
||||
import { getProject } from '../api'
|
||||
|
||||
function renderAt(path) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path="/p/:projectId/*" element={
|
||||
<ProjectLayout><div data-testid="corpus">CORPUS</div></ProjectLayout>
|
||||
} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ProjectLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.documentElement.style.removeProperty('--c-accent')
|
||||
})
|
||||
|
||||
it('renders the corpus children for the corpus-served default project', async () => {
|
||||
getProject.mockResolvedValue({ id: 'default', name: 'OHM', type: 'document', theme: {} })
|
||||
renderAt('/p/default/')
|
||||
expect(await screen.findByTestId('corpus')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies the project theme to :root and resets it on unmount', async () => {
|
||||
getProject.mockResolvedValue({ id: 'default', name: 'OHM', type: 'document', theme: { accent: '#123456' } })
|
||||
const { unmount } = renderAt('/p/default/')
|
||||
await waitFor(() =>
|
||||
expect(document.documentElement.style.getPropertyValue('--c-accent')).toBe('#123456'))
|
||||
unmount()
|
||||
expect(document.documentElement.style.getPropertyValue('--c-accent')).toBe('')
|
||||
})
|
||||
|
||||
it('renders the corpus for a non-default project too (Plan B: every project serves)', async () => {
|
||||
getProject.mockResolvedValue({ id: 'other', name: 'Ecomm', type: 'bdd', theme: {} })
|
||||
renderAt('/p/other/')
|
||||
expect(await screen.findByTestId('corpus')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a not-found message when the project 404s', async () => {
|
||||
getProject.mockRejectedValue(Object.assign(new Error('nope'), { status: 404 }))
|
||||
renderAt('/p/ghost/')
|
||||
expect(await screen.findByText(/Not found/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
// §22.10 (M3) — the project switcher in deployment chrome (header). A plain
|
||||
// <select> of the caller-visible projects; choosing one navigates to its
|
||||
// `/p/<id>/` home. Rendered only when 2+ projects are visible (a single
|
||||
// project needs no switcher). The current project is read from the URL.
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useDeployment } from '../context/DeploymentProvider'
|
||||
|
||||
export default function ProjectSwitcher() {
|
||||
const { projects } = useDeployment()
|
||||
const navigate = useNavigate()
|
||||
// The switcher renders in deployment chrome (the header), above the route
|
||||
// tree, so it reads the active project from the path rather than useParams.
|
||||
const { pathname } = useLocation()
|
||||
const match = pathname.match(/^\/p\/([^/]+)/)
|
||||
const projectId = match ? match[1] : ''
|
||||
|
||||
if (!projects || projects.length < 2) return null
|
||||
|
||||
return (
|
||||
<select
|
||||
className="project-switcher"
|
||||
aria-label="Switch project"
|
||||
value={projectId || ''}
|
||||
onChange={e => navigate(`/p/${e.target.value}/`)}
|
||||
>
|
||||
{/* When not inside a project (e.g. the directory), show a neutral head. */}
|
||||
{!projectId && <option value="" disabled>Projects…</option>}
|
||||
{projects.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { renderMarkdown } from '../lib/sanitizeHtml'
|
||||
import { getProposal, mergeProposal, declineProposal, withdrawProposal } from '../api'
|
||||
import { entryPath, useProjectId } from '../lib/entryPaths'
|
||||
|
||||
export default function ProposalView({ viewer, onChange }) {
|
||||
const { prNumber } = useParams()
|
||||
@@ -21,6 +22,7 @@ export default function ProposalView({ viewer, onChange }) {
|
||||
const [declineOpen, setDeclineOpen] = useState(false)
|
||||
const [declineComment, setDeclineComment] = useState('')
|
||||
const navigate = useNavigate()
|
||||
const pid = useProjectId()
|
||||
|
||||
function refresh() {
|
||||
setData(null); setError(null)
|
||||
@@ -39,7 +41,7 @@ export default function ProposalView({ viewer, onChange }) {
|
||||
try {
|
||||
const { slug } = await mergeProposal(prNumber)
|
||||
onChange?.()
|
||||
navigate(`/rfc/${slug}`)
|
||||
navigate(entryPath(pid, slug))
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
setActing(false)
|
||||
|
||||
@@ -28,7 +28,7 @@ function slugify(title) {
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '' }) {
|
||||
export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '', projectId }) {
|
||||
// #28 Part 2: a "create RFC for '<term>'" affordance pre-fills the title
|
||||
// (App passes the `?propose=<term>` value here); the slug derives from it
|
||||
// via the same effect that drives manual typing.
|
||||
@@ -92,7 +92,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await proposeRFC({
|
||||
const result = await proposeRFC(projectId, {
|
||||
title: title.trim(),
|
||||
slug,
|
||||
pitch: pitch.trim(),
|
||||
|
||||
@@ -44,8 +44,9 @@ import ChangePanel, { diffWords } from './ChangePanel.jsx'
|
||||
import PRModal from './PRModal.jsx'
|
||||
import GraduateDialog from './GraduateDialog.jsx'
|
||||
import InvitationsModal from './InvitationsModal.jsx'
|
||||
import { claimOwnership } from '../api'
|
||||
import { claimOwnership, retireRFC, unretireRFC } from '../api'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
import { entryPath, entryPrPath, useProjectId } from '../lib/entryPaths'
|
||||
|
||||
const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
|
||||
const MANUAL_DEBOUNCE_MS = 800
|
||||
@@ -68,6 +69,7 @@ export default function RFCView({ viewer }) {
|
||||
const { slug } = useParams()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const pid = useProjectId()
|
||||
|
||||
const branchParam = searchParams.get('branch') || 'main'
|
||||
|
||||
@@ -123,7 +125,7 @@ export default function RFCView({ viewer }) {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
getRFC(slug).then(entry => {
|
||||
getRFC(pid, slug).then(entry => {
|
||||
setEntry(entry)
|
||||
// v0.15.0 — analytics: fire RFC Viewed once per slug load.
|
||||
// We key on the slug param rather than the loaded entry so a
|
||||
@@ -138,7 +140,7 @@ export default function RFCView({ viewer }) {
|
||||
setSelectedModel(def || models?.[0]?.id || '')
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [slug])
|
||||
}, [slug, pid])
|
||||
|
||||
// Per §9.4 / §17's routing-collapse rule, super-drafts render through
|
||||
// the same surface as active RFCs — the bot, the chat, the change
|
||||
@@ -154,6 +156,35 @@ export default function RFCView({ viewer }) {
|
||||
// gates the underlying endpoints regardless, so a leaked toggle
|
||||
// can't actually leak anything.
|
||||
const [showInvitationsModal, setShowInvitationsModal] = useState(false)
|
||||
// §13.7 retire / un-retire. RFC owners + site owners may retire; only
|
||||
// site owners may un-retire. The backend gates both regardless.
|
||||
const [actionError, setActionError] = useState(null)
|
||||
const canRetire = !!viewer && (entry?.state === 'super-draft' || entry?.state === 'active') && (
|
||||
viewer.role === 'owner' || (entry?.owners || []).includes(viewer.gitea_login)
|
||||
)
|
||||
const handleRetire = useCallback(async () => {
|
||||
if (!window.confirm(
|
||||
`Retire “${entry?.title || slug}”? It will be soft-deleted — removed `
|
||||
+ `from the catalog and every view. A site owner can un-retire it later.`
|
||||
)) return
|
||||
setActionError(null)
|
||||
try {
|
||||
await retireRFC(slug)
|
||||
navigate('/') // the entry is now hidden; return to the catalog
|
||||
} catch (err) {
|
||||
setActionError(err.message)
|
||||
}
|
||||
}, [slug, entry, navigate])
|
||||
const handleUnretire = useCallback(async () => {
|
||||
setActionError(null)
|
||||
try {
|
||||
const res = await unretireRFC(slug)
|
||||
getRFC(pid, slug).then(setEntry).catch(() => {})
|
||||
if (res?.state) navigate(entryPath(pid, slug))
|
||||
} catch (err) {
|
||||
setActionError(err.message)
|
||||
}
|
||||
}, [slug, navigate, pid])
|
||||
|
||||
// Load main view + branch view whenever slug/branch changes.
|
||||
useEffect(() => {
|
||||
@@ -493,7 +524,27 @@ export default function RFCView({ viewer }) {
|
||||
if (error) return <article className="entry-view"><p>Error: {error}</p></article>
|
||||
if (!entry) return <article className="entry-view">Loading…</article>
|
||||
if (entry.state !== 'active' && entry.state !== 'super-draft') {
|
||||
return <article className="entry-view"><p>This RFC is {entry.state}.</p></article>
|
||||
// §13.7: a retired entry is only reachable here by a site owner (the
|
||||
// backend 404s everyone else), so this doubles as the direct-nav
|
||||
// un-retire surface alongside the admin "Retired" list.
|
||||
const canUnretire = entry.state === 'retired' && viewer?.role === 'owner'
|
||||
return (
|
||||
<article className="entry-view">
|
||||
<p>This RFC is {entry.state}.</p>
|
||||
{canUnretire && (
|
||||
<>
|
||||
<p className="muted">
|
||||
Retired entries are hidden from the catalog and every view.
|
||||
Un-retiring restores it to its prior state.
|
||||
</p>
|
||||
<button type="button" className="btn-primary" onClick={handleUnretire}>
|
||||
Un-retire
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{actionError && <p className="rfc-error-banner">{actionError}</p>}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
if (!branchView) return <article className="entry-view">Loading branch…</article>
|
||||
|
||||
@@ -585,7 +636,7 @@ export default function RFCView({ viewer }) {
|
||||
{openPRForBranch && (
|
||||
<a
|
||||
className="btn-link"
|
||||
href={`/rfc/${slug}/pr/${openPRForBranch.pr_number}`}
|
||||
href={entryPrPath(pid, slug, openPRForBranch.pr_number)}
|
||||
title="View the open PR for this branch"
|
||||
>
|
||||
PR #{openPRForBranch.pr_number} →
|
||||
@@ -618,7 +669,7 @@ export default function RFCView({ viewer }) {
|
||||
const result = await claimOwnership(slug)
|
||||
if (result?.noop) return
|
||||
if (result?.pr_number) {
|
||||
navigate(`/rfc/${slug}/pr/${result.pr_number}`)
|
||||
navigate(entryPrPath(pid, slug, result.pr_number))
|
||||
}
|
||||
} catch (err) {
|
||||
setClaimError(err.message)
|
||||
@@ -653,8 +704,22 @@ export default function RFCView({ viewer }) {
|
||||
Invitations
|
||||
</button>
|
||||
)}
|
||||
{/* §13.7: Retire (soft delete). RFC owners + site owners only. */}
|
||||
{canRetire && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link btn-retire"
|
||||
onClick={handleRetire}
|
||||
title="§13.7 — soft-delete this RFC (removes it from every view; a site owner can un-retire it)"
|
||||
>
|
||||
Retire
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{actionError && (
|
||||
<div className="rfc-error-banner">Retire failed: {actionError}</div>
|
||||
)}
|
||||
{claimError && (
|
||||
<div className="rfc-error-banner">Claim failed: {claimError}</div>
|
||||
)}
|
||||
@@ -865,7 +930,7 @@ export default function RFCView({ viewer }) {
|
||||
onClose={() => setShowPRModal(false)}
|
||||
onOpened={(prNumber) => {
|
||||
setShowPRModal(false)
|
||||
navigate(`/rfc/${slug}/pr/${prNumber}`)
|
||||
navigate(entryPrPath(pid, slug, prNumber))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -892,7 +957,7 @@ export default function RFCView({ viewer }) {
|
||||
onCompleted={() => {
|
||||
setShowGraduateDialog(false)
|
||||
// The catalog row and the RFC view now reflect `active`.
|
||||
getRFC(slug).then(setEntry).catch(() => {})
|
||||
getRFC(pid, slug).then(setEntry).catch(() => {})
|
||||
getRFCMain(slug).then(setMainView).catch(() => {})
|
||||
}}
|
||||
/>
|
||||
@@ -917,7 +982,7 @@ export default function RFCView({ viewer }) {
|
||||
// Refresh main view so the new open PR surfaces in the
|
||||
// breadcrumb meta count immediately.
|
||||
getRFCMain(slug).then(setMainView).catch(() => {})
|
||||
navigate(`/rfc/${slug}/pr/${prNumber}`)
|
||||
navigate(entryPrPath(pid, slug, prNumber))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user