"""§22.9 runtime deployment/project config (replaces VITE_APP_NAME) + §22.10 old-URL 308 redirects + §22 S5 in-app create-project. 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, the `viewer` capability block (S5: `can_create_project`), and `default_project_readable` (whether the N=1 redirect target is reachable by this viewer — drives the deployment-directory empty state vs the land-in-corpus redirect). POST /api/projects — §22 S5 create-project (global-Owner only). The bot provisions a Gitea content repo and commits a project entry to `projects.yaml`; the registry mirror then upserts the `projects` + default `collections` rows (§22.2 keeps the registry the source of truth). 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 import re from typing import Any import yaml from fastapi import APIRouter, HTTPException, Request from fastapi.responses import RedirectResponse from pydantic import BaseModel from . import ( auth, collections as collections_mod, db, projects as projects_mod, registry as registry_mod, ) from .bot import Bot from .config import Config from .gitea import Gitea, GiteaError # A project id is a slug (the §22.2 registry key + the `/p//` path segment). _SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") # A Gitea repo name: alphanumeric start, then alphanumerics / `-` / `_` / `.`. _REPO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") class CreateProjectBody(BaseModel): project_id: str name: str type: str visibility: str | None = None content_repo: str | None = None def make_router(config: Config, gitea: Gitea, bot: Bot) -> 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"]) ), "entry_noun": collections_mod.entry_noun( collections_mod.collection_type( collections_mod.default_collection_id(r["id"]) ) ), "visibility": r["visibility"], } for r in rows if r["id"] in visible ] # §22 S5: the N=1 land-in-corpus redirect targets the default project, but # only when this viewer can actually read it. A `gated` default (C3.2) or # an absent default (C3.1, a deployment with no projects) is *not* a valid # redirect target — the frontend then falls through to the deployment # directory's role-aware empty state instead of bouncing into a 404. default_id = projects_mod.resolved_default_id(config) default_exists = db.conn().execute( "SELECT 1 FROM projects WHERE id = ?", (default_id,) ).fetchone() is not None default_readable = default_exists and auth.can_read_project(viewer, default_id) 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": default_id, "default_project_readable": default_readable, "projects": projects, # §22 S5 (C3.1/C3.2): role-aware deployment-directory affordances. "viewer": {"can_create_project": auth.can_create_project(viewer)}, } @router.post("/api/projects") async def create_project(body: CreateProjectBody, request: Request) -> dict[str, Any]: # §22 S5 / §A.2 / §B.1: "+ New project" is a global-Owner action. user = auth.require_contributor(request) if not auth.can_create_project(user): raise HTTPException(403, "Only a global Owner may create projects") pid = body.project_id.strip().lower() if not _SLUG_RE.match(pid) or pid == "default": raise HTTPException(422, "project id must be a slug and not 'default'") name = (body.name or "").strip() if not name: raise HTTPException(422, "project name is required") if body.type not in registry_mod.VALID_TYPES: raise HTTPException(422, f"invalid type {body.type!r}") # A project is created visible by default — the point of standing one up # is for it to be seen; an Owner narrows it afterwards (or picks gated). visibility = (body.visibility or "public").strip() if visibility not in registry_mod.VALID_VISIBILITY: raise HTTPException(422, f"invalid visibility {visibility!r}") if db.conn().execute("SELECT 1 FROM projects WHERE id = ?", (pid,)).fetchone(): raise HTTPException(409, f"project `{pid}` already exists") content_repo = (body.content_repo or f"{pid}-content").strip() if not _REPO_RE.match(content_repo): raise HTTPException(422, f"invalid content repo name {content_repo!r}") if await gitea.get_repo(config.gitea_org, content_repo) is not None: raise HTTPException(409, f"repo `{content_repo}` already exists") # Read the current registry, append the project, recompose. Reads live in # gitea.py and may be called anywhere; the bot owns the write back. read = await gitea.read_file( config.gitea_org, config.registry_repo, "projects.yaml", ref="main" ) if read is None: raise HTTPException(409, "registry projects.yaml not found") text, sha = read try: doc = yaml.safe_load(text) or {} except yaml.YAMLError as e: raise HTTPException(500, f"registry projects.yaml is not valid YAML: {e}") if not isinstance(doc, dict): raise HTTPException(500, "registry projects.yaml is malformed") projects = doc.get("projects") if not isinstance(projects, list): projects = [] if any(isinstance(p, dict) and str(p.get("id") or "") == pid for p in projects): raise HTTPException(409, f"project `{pid}` already in the registry") projects.append( { "id": pid, "name": name, "type": body.type, "content_repo": content_repo, "visibility": visibility, } ) doc["projects"] = projects new_text = yaml.safe_dump(doc, sort_keys=False) readme_text = f"# {name}\n\nContent repository for project `{pid}`.\n" try: await bot.create_project( user.as_actor(), org=config.gitea_org, registry_repo=config.registry_repo, content_repo=content_repo, project_id=pid, projects_yaml_new=new_text, projects_yaml_sha=sha, readme_text=readme_text, ) except GiteaError as e: raise HTTPException(502, f"Gitea: {e.detail}") # §22.2: re-read the registry so the new entry becomes projects + # default-collection rows. await registry_mod.refresh_registry(config, gitea) row = db.conn().execute( "SELECT id, name, visibility FROM projects WHERE id = ?", (pid,) ).fetchone() if row is None: raise HTTPException(500, "project committed but not mirrored") cid = collections_mod.default_collection_id(pid) return { "id": row["id"], "name": row["name"], "visibility": row["visibility"], "type": collections_mod.collection_type(cid), } @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), "entry_noun": collections_mod.entry_noun(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//…` 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//c//…. 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