§22 M3-backend Plan B (2/2, read path): per-project RFC serving (v0.37.0)
A second project's corpus now renders under /p/<id>/, 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) <noreply@anthropic.com>
This commit is contained in:
+12
-4
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 (
|
||||
<main className="chrome-pane">
|
||||
<div className="welcome">
|
||||
<h1>{name}</h1>
|
||||
<p>
|
||||
This project is registered, but its content isn't being served
|
||||
here yet. Per-project content arrives in a later release.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<ProjectContext.Provider value={{ project, projectId, served, noun: entryNoun(project?.type) }}>
|
||||
{served ? children : <NotServedPlaceholder project={project} />}
|
||||
<ProjectContext.Provider value={{ project, projectId, noun: entryNoun(project?.type) }}>
|
||||
{children}
|
||||
</ProjectContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(() => {})
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user