33212c71e4
Adds the global-Owner create-project action and the role-aware deployment directory, completing slice S5 of the three-tier refactor (release v0.44.0, minor/non-breaking — no migration). Per docs/design/2026-06-05-three-tier-projects-collections.md Part E (S5), Part C.3 (C3.1–C3.2). Backend: - auth.can_create_project — global-Owner gate (deployment owner/admin or an explicit scope_type='global' Owner grant). - bot.create_project — provision the Gitea content repo (seed README so main exists), read+append+commit projects.yaml in the registry repo, audit-log (create_project). The bot stays the only git writer (§1). - POST /api/projects (api_deployment) — global-Owner gated; validates the id (slug, not 'default'), name, type, visibility, content_repo; commits via the bot, then re-mirrors the registry so projects + default collection rows flow from git (§22.2). GET /api/deployment gains viewer.can_create_project and default_project_readable. make_router now takes gitea + bot. Frontend: - api.createProject; DeploymentProvider surfaces viewer + defaultProjectReadable + refresh; DeploymentLanding redirects into the default only when readable (gated/absent default falls through to the directory, no 404 bounce). - Directory.jsx role-aware empty states (C3.1 "Create your first project" CTA; C3.2 "Nothing has been shared with you yet") + Owner-only "New project" control + CreateProjectModal. Tests: backend test_create_project_vertical.py (vertical + gates + the deployment empty-state signals); frontend Directory.test.jsx empty-state cases. Also: ignore the session-local .superpowers/ tooling dir. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
258 lines
12 KiB
Python
258 lines
12 KiB
Python
"""§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/<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
|
|
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/<id>/` 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"])
|
|
),
|
|
"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),
|
|
"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/<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.
|
|
# §22 three-tier (S1): the canonical entry route now carries the collection
|
|
# segment /p/<project>/c/<collection>/…. 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
|