999c4b65ef
Implements the M3-frontend slice of the §22 multi-project track, per docs/superpowers/specs/2026-06-03-m3-frontend-design.md (design merged in #10). Completes the runtime-config cut 0.33.0 (M3-backend Plan A) began. Frontend: - DeploymentProvider boots GET /api/deployment → {name, tagline, defaultProjectId, projects}; brandTitle() neutral 'RFC' pre-fetch fallback. - /p/:projectId/* routing with generic /e/<slug> segment. ProjectLayout fetches /api/projects/:id, applies per-project theme (reset on switch), provides ProjectContext, guards the corpus (served only for the default; others get NotServedPlaceholder — decouples this slice from Plan B). - Directory at / (2+ projects) with N=1 redirect into the single project; ProjectSwitcher in deployment chrome; entry-noun by project type. - VITE_APP_NAME hard cut: removed from vite.config + index.html; the 6 brand reads now use deployment.name via context; static <title>RFC</title> + JS document.title. Internal /rfc·/proposals links → /p/<project>/e|proposals via lib/entryPaths. Backend: - GET /api/deployment returns default_project_id (the guard contract). - Server-side 308s: /rfc/<slug>, /rfc/<slug>/pr/<n>, /proposals/<n> → /p/<default>/… . nginx (testing + prod) routes /rfc/ and /proposals/ to the backend. Tests: 3 new backend redirect/deployment tests (438 pass); Vitest unit for DeploymentProvider, ProjectLayout (theme/guard/404), Directory (11 pass); clean build with no VITE_APP_NAME. Playwright e2e deferred until Tier-1 seeds a registry (see CHANGELOG 0.35.0 step 5). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
107 lines
4.7 KiB
Python
107 lines
4.7 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, 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
|