Files
rfc-app/backend/app/api_deployment.py
T
Ben Stull 9ca07a3f81 §22 S1: thread collection_id through backend + update tests (N=1 unchanged, 454 green)
- collections.py resolution helpers (default_collection_id, type, initial_state)
- registry mirror writes project grouping fields + default-collection corpus fields
- auth.project_of_rfc joins collections; project_member_role reads memberships
- cache/api_*/funder writers+readers re-keyed to collection_id (cached_prs + denormalised
  project_id tags unchanged); api_deployment reads type/initial_state from the default collection
- projects.py restamp detects bootstrap via collections; initial_state via collection
- tests updated to the three-tier schema; test_migration_028 retired (superseded by 029)
- add @S1 acceptance test (collection grain + N=1 serving)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:26:14 -07:00

124 lines
5.6 KiB
Python

"""§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, collections as collections_mod, 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))
# §22 three-tier: `type` is a per-corpus field on the (default) collection
# now; surface the default collection's type for each project.
rows = db.conn().execute(
"SELECT id, name, visibility FROM projects "
"WHERE visibility != 'unlisted' ORDER BY name"
).fetchall()
projects = [
{
"id": r["id"],
"name": r["name"],
"type": collections_mod.collection_type(
collections_mod.default_collection_id(r["id"])
),
"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, visibility, 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()
# §22 three-tier: type + initial_state moved down to the (default)
# collection in migration 029.
cid = collections_mod.default_collection_id(row["id"])
return {
"id": row["id"],
"name": row["name"],
"tagline": (dep["tagline"] if dep else "") or "",
"type": collections_mod.collection_type(cid),
"visibility": row["visibility"],
"initial_state": collections_mod.collection_initial_state(cid),
"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.
# §22 three-tier (S1): the canonical entry route now carries the collection
# segment /p/<project>/c/<collection>/…. The legacy roots redirect through
# the default project's default collection.
@router.get("/rfc/{slug}")
async def redirect_old_rfc(slug: str) -> RedirectResponse:
default_id = projects_mod.resolved_default_id(config)
cid = collections_mod.default_collection_id(default_id)
return RedirectResponse(url=f"/p/{default_id}/c/{cid}/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)
cid = collections_mod.default_collection_id(default_id)
return RedirectResponse(
url=f"/p/{default_id}/c/{cid}/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)
cid = collections_mod.default_collection_id(default_id)
return RedirectResponse(url=f"/p/{default_id}/c/{cid}/proposals/{pr_number}", status_code=308)
return router