From 9f548a340d679f71d95b032a566b27ff3cad202d Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 4 Jun 2026 06:45:42 -0700 Subject: [PATCH] =?UTF-8?q?=C2=A722=20M3-backend=20Plan=20B=20(2/2,=20read?= =?UTF-8?q?=20path):=20per-project=20RFC=20serving=20(v0.37.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second project's corpus now renders under /p//, isolated by its own slug namespace. Completes step (1) "RFC app supports multiple projects" for the read path. - api.py: GET /api/projects/{pid}/rfcs (scoped catalog) + /{slug} (entry by (project_id,slug)), behind the §22.5 read gate. Unscoped /api/rfcs stay as default-project compat. - cache.py: refresh_meta_repo iterates every projects row, mirroring each content_repo into cached_rfcs stamped with its project_id (_upsert_cached_rfc gains a project_id arg). - frontend: api.listRFCs(projectId)/getRFC(projectId,slug); Catalog + RFCView pass useProjectId(); ProjectLayout's NotServedPlaceholder guard removed (every project serves), 404/not-readable branch kept; NotServedPlaceholder deleted. - tests: test_project_scoped_serving.py (catalog scoping, entry isolation, gated 404); ProjectLayout.test updated (non-default renders). 445 backend + 11 Vitest green; clean build. Known limitation (next slice): write path + default-id re-stamp not yet scoped (non-default project read-only); no live impact (no 2nd project in prod yet). Per docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 39 +++++++++ VERSION | 2 +- backend/app/api.py | 82 +++++++++++++++++++ backend/app/cache.py | 52 +++++++----- backend/tests/test_project_scoped_serving.py | 67 +++++++++++++++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/api.js | 16 +++- frontend/src/components/Catalog.jsx | 4 +- .../src/components/NotServedPlaceholder.jsx | 18 ---- frontend/src/components/ProjectLayout.jsx | 10 +-- .../src/components/ProjectLayout.test.jsx | 5 +- frontend/src/components/RFCView.jsx | 8 +- 13 files changed, 249 insertions(+), 60 deletions(-) create mode 100644 backend/tests/test_project_scoped_serving.py delete mode 100644 frontend/src/components/NotServedPlaceholder.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index a97f506..75ffc05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,45 @@ 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.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//`, 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 diff --git a/VERSION b/VERSION index 93d4c1e..0f1a7df 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.36.0 +0.37.0 diff --git a/backend/app/api.py b/backend/app/api.py index 5aacc7c..06966dc 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -708,6 +708,88 @@ 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//. + # --------------------------------------------------------------- + + @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 # --------------------------------------------------------------- diff --git a/backend/app/cache.py b/backend/app/cache.py index 869e212..57b1683 100644 --- a/backend/app/cache.py +++ b/backend/app/cache.py @@ -35,20 +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//. Idempotent; + safe on every content-repo webhook and reconciler sweep. """ org = config.gitea_org - repo = projects_mod.default_content_repo(config) - if not repo: - log.warning("refresh_meta_repo: default project has no content_repo yet; skipping") + 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() @@ -62,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 @@ -92,9 +105,9 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None: (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, - unreviewed, reviewed_at, reviewed_by, + unreviewed, reviewed_at, reviewed_by, project_id, last_entry_commit_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) ON CONFLICT(project_id, slug) DO UPDATE SET title = excluded.title, state = excluded.state, @@ -137,6 +150,7 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None: 1 if entry.unreviewed else 0, entry.reviewed_at, entry.reviewed_by, + project_id, ), ) diff --git a/backend/tests/test_project_scoped_serving.py b/backend/tests/test_project_scoped_serving.py new file mode 100644 index 0000000..a455ed1 --- /dev/null +++ b/backend/tests/test_project_scoped_serving.py @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 38a071e..c773f6d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "rfc-app-frontend", - "version": "0.34.0", + "version": "0.36.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rfc-app-frontend", - "version": "0.34.0", + "version": "0.36.0", "dependencies": { "@amplitude/unified": "^1.1.9", "@codemirror/commands": "^6.10.3", diff --git a/frontend/package.json b/frontend/package.json index 9e119b0..4312740 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.36.0", + "version": "0.37.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api.js b/frontend/src/api.js index bebdc97..19cc2cc 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -182,12 +182,20 @@ export async function getProject(projectId) { return jsonOrThrow(await fetch(`/api/projects/${projectId}`)) } -export async function listRFCs() { - return jsonOrThrow(await fetch('/api/rfcs')) +// §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(slug) { - return jsonOrThrow(await fetch(`/api/rfcs/${slug}`)) +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() { diff --git a/frontend/src/components/Catalog.jsx b/frontend/src/components/Catalog.jsx index bd3613d..daad784 100644 --- a/frontend/src/components/Catalog.jsx +++ b/frontend/src/components/Catalog.jsx @@ -34,9 +34,9 @@ export default function Catalog({ viewer, onProposeRFC, version }) { const pid = useProjectId() useEffect(() => { - listRFCs().then(d => setRfcs(d.items)).catch(() => setRfcs([])) + listRFCs(pid).then(d => setRfcs(d.items)).catch(() => setRfcs([])) listProposals().then(d => setProposals(d.items)).catch(() => setProposals([])) - }, [version]) + }, [version, pid]) const filtered = useMemo(() => { const needle = search.trim().toLowerCase() diff --git a/frontend/src/components/NotServedPlaceholder.jsx b/frontend/src/components/NotServedPlaceholder.jsx deleted file mode 100644 index 8b7ba7d..0000000 --- a/frontend/src/components/NotServedPlaceholder.jsx +++ /dev/null @@ -1,18 +0,0 @@ -// §22.10 (M3) — the guard placeholder. A project that exists in the registry -// but whose corpus the backend does not yet serve (everything except the -// corpus-served default, until Plan B lands per-project RFC serving) renders -// this instead of mislabeled default-project content. -export default function NotServedPlaceholder({ project }) { - const name = project?.name || 'This project' - return ( -
-
-

{name}

-

- This project is registered, but its content isn't being served - here yet. Per-project content arrives in a later release. -

-
-
- ) -} diff --git a/frontend/src/components/ProjectLayout.jsx b/frontend/src/components/ProjectLayout.jsx index a5bdeb2..11643c1 100644 --- a/frontend/src/components/ProjectLayout.jsx +++ b/frontend/src/components/ProjectLayout.jsx @@ -16,8 +16,6 @@ import { createContext, useContext, useEffect, useState } from 'react' import { useParams } from 'react-router-dom' import { getProject } from '../api' import { brandTitle } from '../lib/brand' -import { useDeployment } from '../context/DeploymentProvider' -import NotServedPlaceholder from './NotServedPlaceholder.jsx' const ProjectContext = createContext(null) @@ -42,7 +40,6 @@ const THEME_TOKENS = { export default function ProjectLayout({ children }) { const { projectId } = useParams() - const deployment = useDeployment() const [project, setProject] = useState(null) const [status, setStatus] = useState('loading') // loading | ready | notfound @@ -90,10 +87,11 @@ export default function ProjectLayout({ children }) { ) } - const served = projectId === deployment.defaultProjectId + // §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 ( - - {served ? children : } + + {children} ) } diff --git a/frontend/src/components/ProjectLayout.test.jsx b/frontend/src/components/ProjectLayout.test.jsx index 59987ca..9fd951d 100644 --- a/frontend/src/components/ProjectLayout.test.jsx +++ b/frontend/src/components/ProjectLayout.test.jsx @@ -43,11 +43,10 @@ describe('ProjectLayout', () => { expect(document.documentElement.style.getPropertyValue('--c-accent')).toBe('') }) - it('shows the not-served placeholder for a non-default (Plan-B) project', async () => { + 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.findByText(/isn.t being served here yet/i)).toBeInTheDocument() - expect(screen.queryByTestId('corpus')).not.toBeInTheDocument() + expect(await screen.findByTestId('corpus')).toBeInTheDocument() }) it('shows a not-found message when the project 404s', async () => { diff --git a/frontend/src/components/RFCView.jsx b/frontend/src/components/RFCView.jsx index 5788d23..8c6eabc 100644 --- a/frontend/src/components/RFCView.jsx +++ b/frontend/src/components/RFCView.jsx @@ -125,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 @@ -140,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 @@ -179,7 +179,7 @@ export default function RFCView({ viewer }) { setActionError(null) try { const res = await unretireRFC(slug) - getRFC(slug).then(setEntry).catch(() => {}) + getRFC(pid, slug).then(setEntry).catch(() => {}) if (res?.state) navigate(entryPath(pid, slug)) } catch (err) { setActionError(err.message) @@ -957,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(() => {}) }} />