feat(api): GET /api/deployment + /api/projects/:id (§22.9 runtime config)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ from . import (
|
|||||||
api_admin,
|
api_admin,
|
||||||
api_branches,
|
api_branches,
|
||||||
api_contributions,
|
api_contributions,
|
||||||
|
api_deployment,
|
||||||
api_discussion,
|
api_discussion,
|
||||||
api_graduation,
|
api_graduation,
|
||||||
api_invitations,
|
api_invitations,
|
||||||
@@ -147,6 +148,8 @@ def make_router(
|
|||||||
# (super-draft) RFC. Reuses the #12 invite flow (api_invitations above)
|
# (super-draft) RFC. Reuses the #12 invite flow (api_invitations above)
|
||||||
# on accept; lands the request + owner notifications via §15 notify.
|
# on accept; lands the request + owner notifications via §15 notify.
|
||||||
router.include_router(api_contributions.make_router())
|
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.
|
# §17: /api/health — unauthenticated post-flight probe.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user