From c386b0596045ec489f16069f8bc3014c2fd7a71a Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 3 Jun 2026 23:34:08 -0700 Subject: [PATCH] =?UTF-8?q?feat(api):=20GET=20/api/deployment=20+=20/api/p?= =?UTF-8?q?rojects/:id=20(=C2=A722.9=20runtime=20config)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two read endpoints that replace the build-time VITE_APP_NAME with a runtime-config surface: /api/deployment returns deployment name/tagline plus the projects visible to the caller (gated filtered by membership, unlisted omitted from enumeration per §22.5); /api/projects/:id returns one project's config and optional theme overlay, gated behind the §22.5 read gate. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api.py | 3 ++ backend/app/api_deployment.py | 69 ++++++++++++++++++++++++++++ backend/tests/test_api_deployment.py | 64 ++++++++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 backend/app/api_deployment.py create mode 100644 backend/tests/test_api_deployment.py diff --git a/backend/app/api.py b/backend/app/api.py index c472c57..332a8bb 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -22,6 +22,7 @@ from . import ( api_admin, api_branches, api_contributions, + api_deployment, api_discussion, api_graduation, api_invitations, @@ -147,6 +148,8 @@ def make_router( # (super-draft) RFC. Reuses the #12 invite flow (api_invitations above) # on accept; lands the request + owner notifications via §15 notify. router.include_router(api_contributions.make_router()) + # §22.9 (M3): runtime deployment + per-project config (replaces VITE_APP_NAME). + router.include_router(api_deployment.make_router()) # --------------------------------------------------------------- # §17: /api/health — unauthenticated post-flight probe. diff --git a/backend/app/api_deployment.py b/backend/app/api_deployment.py new file mode 100644 index 0000000..3ef0b18 --- /dev/null +++ b/backend/app/api_deployment.py @@ -0,0 +1,69 @@ +"""§22.9 runtime deployment/project config (replaces VITE_APP_NAME). + +GET /api/deployment — the deployment name/tagline + the projects the caller can +see (§22.5: gated filtered by membership, unlisted omitted from enumeration). +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). +""" +from __future__ import annotations + +import json +from typing import Any + +from fastapi import APIRouter, Request + +from . import auth, db + + +def make_router() -> 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 "", + "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: + auth.require_project_readable(viewer, project_id) # raises 404 + cfg = json.loads(row["config_json"] or "{}") + 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 {}, + } + + return router diff --git a/backend/tests/test_api_deployment.py b/backend/tests/test_api_deployment.py new file mode 100644 index 0000000..48dbea9 --- /dev/null +++ b/backend/tests/test_api_deployment.py @@ -0,0 +1,64 @@ +"""§22.9/§22.5 — GET /api/deployment + GET /api/projects/:id with visibility.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from test_propose_vertical import ( # noqa: F401 + app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as, +) + + +def _add_project(pid, name, vis, typ="document"): + from app import db + db.conn().execute( + "INSERT OR REPLACE INTO projects (id, name, type, content_repo, visibility, initial_state) " + "VALUES (?, ?, ?, ?, ?, 'super-draft')", + (pid, name, typ, pid, vis), + ) + + +def test_deployment_lists_public_omits_gated_and_unlisted_for_anon(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + _add_project("pub", "Public", "public") + _add_project("gat", "Gated", "gated") + _add_project("unl", "Unlisted", "unlisted") + r = client.get("/api/deployment") + assert r.status_code == 200 + body = r.json() + assert body["name"] == "Test Deployment" + ids = {p["id"] for p in body["projects"]} + assert "pub" in ids and "default" in ids # both public + assert "gat" not in ids # gated, anon not a member + assert "unl" not in ids # unlisted never enumerated + + +def test_projects_id_404_for_gated_non_member(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + _add_project("gat", "Gated", "gated") + assert client.get("/api/projects/gat").status_code == 404 + + +def test_projects_id_returns_config_for_public(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + from app import db + db.conn().execute( + "UPDATE projects SET config_json = ? WHERE id = 'default'", + ('{"theme": {"accent": "#5b5bd6"}}',), + ) + r = client.get("/api/projects/default") + assert r.status_code == 200 + body = r.json() + assert body["id"] == "default" + assert body["type"] == "document" + assert body["visibility"] == "public" + assert body["theme"] == {"accent": "#5b5bd6"} + + +def test_projects_id_unlisted_readable_by_direct_id(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + _add_project("unl", "Unlisted", "unlisted") + assert client.get("/api/projects/unl").status_code == 200