"""§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//…` 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//…` 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