§22 S5: in-app create-project + global-directory empty states (@S5 C3.1–C3.2)

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>
This commit is contained in:
Ben Stull
2026-06-06 01:02:16 -07:00
parent 2696e64ff5
commit 33212c71e4
16 changed files with 779 additions and 56 deletions
+1
View File
@@ -26,3 +26,4 @@ data/
# Claude Code (per-machine settings only; shared config under .claude/ is committed) # Claude Code (per-machine settings only; shared config under .claude/ is committed)
.claude/settings.local.json .claude/settings.local.json
.superpowers/
+53
View File
@@ -23,6 +23,59 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond release's steps in order — no A-to-B path is pre-computed beyond
that. that.
## 0.44.0 — 2026-06-06
**Minor (non-breaking) — §22 three-tier refactor, slice S5: *in-app
create-project + the global directory.* A global Owner can now stand up a new
project end-to-end from the UI — the bot provisions a Gitea content repo and
commits the project to `projects.yaml`, and the registry mirror picks it up — and
the deployment directory shows a role-aware empty state. Purely additive over S4:
one new endpoint and UI, no schema migration, and no change to existing read or
write semantics. Completes acceptance scenarios `@S5` (C3.1C3.2: the
global-directory empty states).**
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
(Part E slice S5, Part C.3 scenarios C3.1C3.2). With S5 the role-and-empty-state
focus of Parts B/C is complete; type modules, membership lifecycle, hardening,
and the SPEC merge remain S6.
Added:
- **In-app create-project (§22 S5 / §A.2)** — `POST /api/projects` (global-Owner
only) provisions a Gitea content repo under the deployment org (seeding a
`README.md` so `main` exists), commits a new project entry to the registry's
`projects.yaml`, then re-runs the registry mirror so the `projects` + default
`collections` rows flow from the registry (§22.2 keeps the registry the source
of truth). The bot remains the only Git writer (§1); the action is logged
(`create_project`) for the §6.5 trail. The body takes `project_id` (a slug, not
`default`), `name`, `type` (the initial/default collection's), optional
`visibility` (defaults to `public`), and an optional `content_repo` name
(defaults to `<id>-content`).
- **Create-project gate** — `auth.can_create_project`: "+ New project" is a
global-Owner action — a deployment owner/admin, or a holder of an explicit
`scope_type='global'` Owner grant. A project- or collection-scope grant, or a
global RFC Contributor, cannot create projects (that role creates collections,
not projects).
- **Deployment-directory capability + redirect signals** — `GET /api/deployment`
now carries a `viewer` block (`can_create_project`) and
`default_project_readable` (whether the N=1 land-in-corpus redirect target is
reachable by this viewer). The frontend bounces into the default project only
when it is readable; a gated default (C3.2) or an absent default (C3.1, a
deployment with no projects) falls through to the directory's empty state
instead of a 404.
- **Role-aware global directory (§22 C3.1C3.2)** — a global Owner landing on an
empty deployment directory sees a "Create your first project" CTA (opening the
create-project modal: id, name, type, visibility, optional content-repo); a
non-owner sees "Nothing has been shared with you yet" and no create action. The
directory also gains an Owner-only "New project" control when it is non-empty.
Upgrade steps:
1. No operator action is required. S5 adds one endpoint and UI only — there is no
migration and no change to existing authorization outcomes. A deployment that
was declaring projects directly in `projects.yaml` keeps working unchanged;
the new UI is an additional, equivalent way to commit the same registry entry.
## 0.43.0 — 2026-06-06 ## 0.43.0 — 2026-06-06
**Minor (non-breaking) — §22 three-tier refactor, slice S4: *invitation **Minor (non-breaking) — §22 three-tier refactor, slice S4: *invitation
+1 -1
View File
@@ -1 +1 @@
0.43.0 0.44.0
+1 -1
View File
@@ -153,7 +153,7 @@ def make_router(
router.include_router(api_contributions.make_router()) router.include_router(api_contributions.make_router())
# §22.9/§22.10 (M3): runtime deployment + per-project config (replaces # §22.9/§22.10 (M3): runtime deployment + per-project config (replaces
# VITE_APP_NAME) + the old-URL 308 redirects. # VITE_APP_NAME) + the old-URL 308 redirects.
router.include_router(api_deployment.make_router(config)) router.include_router(api_deployment.make_router(config, gitea, bot))
router.include_router(api_collections.make_router(config, gitea, bot)) router.include_router(api_collections.make_router(config, gitea, bot))
# §22 S4 (C.2): the scope-role invitation surface — Owners grant # §22 S4 (C.2): the scope-role invitation surface — Owners grant
# {owner, contributor} at project/collection scope to existing accounts. # {owner, contributor} at project/collection scope to existing accounts.
+139 -5
View File
@@ -1,9 +1,17 @@
"""§22.9 runtime deployment/project config (replaces VITE_APP_NAME) + §22.10 """§22.9 runtime deployment/project config (replaces VITE_APP_NAME) + §22.10
old-URL 308 redirects. old-URL 308 redirects + §22 S5 in-app create-project.
GET /api/deployment the deployment name/tagline + the projects the caller can GET /api/deployment the deployment name/tagline + the projects the caller can
see (§22.5: gated filtered by membership, unlisted omitted from enumeration), see (§22.5: gated filtered by membership, unlisted omitted from enumeration),
plus the corpus-served `default_project_id` the M3-frontend guard keys on. 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, 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). 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 GET /rfc/{slug}, /proposals/{n} §22.10 server-side 308s onto the new
@@ -13,16 +21,40 @@ to the backend instead of serving index.html).
from __future__ import annotations from __future__ import annotations
import json import json
import re
from typing import Any from typing import Any
import yaml
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from . import auth, collections as collections_mod, db, projects as projects_mod 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 .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._-]*$")
def make_router(config: Config) -> APIRouter: 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 = APIRouter()
@router.get("/api/deployment") @router.get("/api/deployment")
@@ -51,6 +83,16 @@ def make_router(config: Config) -> APIRouter:
for r in rows for r in rows
if r["id"] in visible 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 { return {
"name": (dep["name"] if dep else "") or "", "name": (dep["name"] if dep else "") or "",
"tagline": (dep["tagline"] if dep else "") or "", "tagline": (dep["tagline"] if dep else "") or "",
@@ -58,8 +100,100 @@ def make_router(config: Config) -> APIRouter:
# serves the corpus for (the default, until Plan B serves per # serves the corpus for (the default, until Plan B serves per
# project). The frontend renders corpus routes only for this id and # project). The frontend renders corpus routes only for this id and
# shows a "content not yet served" placeholder for any other. # shows a "content not yet served" placeholder for any other.
"default_project_id": projects_mod.resolved_default_id(config), "default_project_id": default_id,
"default_project_readable": default_readable,
"projects": projects, "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}") @router.get("/api/projects/{project_id}")
+19
View File
@@ -682,6 +682,25 @@ def can_create_collection(user: SessionUser | None, project_id: str) -> bool:
return row is not None return row is not None
def can_create_project(user: SessionUser | None) -> bool:
"""§22 S5 (§A.2 / §B.1): may the user create a new project? "+ New project"
is a **global-Owner** action a deployment owner/admin (a global Owner per
§B.1) or a holder of an explicit `scope_type='global'` Owner grant. Creating
a project is deployment-level, so it is not reachable by a project- or
collection-scope grant nor by a global RFC Contributor (that role creates
collections, not projects). Subject to the §6 admission floor."""
if user is None or user.permission_state != "granted":
return False
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
return True
row = db.conn().execute(
"SELECT 1 FROM memberships "
"WHERE user_id = ? AND scope_type = 'global' AND role = 'owner' LIMIT 1",
(user.user_id,),
).fetchone()
return row is not None
def can_invite_at_project(user: SessionUser | None, project_id: str) -> bool: def can_invite_at_project(user: SessionUser | None, project_id: str) -> bool:
"""§22 S4 (C.2): may the user grant scope roles at this project (or at any """§22 S4 (C.2): may the user grant scope roles at this project (or at any
collection within it)? Managing membership is an *Owner* capability whose collection within it)? Managing membership is an *Owner* capability whose
+76
View File
@@ -198,6 +198,82 @@ class Bot:
) )
return created return created
async def create_project(
self,
actor: Actor,
*,
org: str,
registry_repo: str,
content_repo: str,
project_id: str,
projects_yaml_new: str,
projects_yaml_sha: str,
readme_text: str,
) -> dict:
"""§22 S5 (§A.2): stand up a new project. A global-Owner action wrapping
a bot write at two git sources:
1. **provision the content repo** create `org/content_repo` if it
doesn't exist, then seed a `README.md` on `main` so the branch
exists (the contents API initialises the repo with that commit; the
corpus mirror and the propose path both need a `main` to write to).
2. **register the project** commit the caller-composed
`projects.yaml` (the existing doc with the new project appended) to
the registry repo's `main`.
Like `create_collection`, this is a structural admin action committed
straight to main (no PR), like the registry config it feeds; the caller
then re-runs the registry mirror so the new `projects` + default
`collections` rows flow from the registry (§22.2 keeps the registry the
source of truth). Logs a `create_project` audit row for the §6.5 trail.
Returns the registry update_file result (carries the new commit sha)."""
ae = actor.email or f"{actor.gitea_login}@users.noreply"
existing = await self._gitea.get_repo(org, content_repo)
if existing is None:
await self._gitea.create_org_repo(
org, content_repo, description=f"Content repo for project {project_id}"
)
# Seed a README if absent, which also establishes `main` on a freshly
# created (auto_init=False) repo — the contents API initialises the repo
# with that commit. Keyed on the README rather than the branch so it is
# idempotent and behaves identically whether the repo has a bare `main`
# or no branch at all. Mirrors `ensure_rfc_repo_seed`'s empty-repo seed.
readme = await self._gitea.get_contents(org, content_repo, "README.md", ref="main")
if readme is None:
await self._gitea.create_file(
org,
content_repo,
"README.md",
content=readme_text,
message=_stamp_single(f"chore: initialise content repo for {project_id}", actor),
branch="main",
author_name=actor.display_name,
author_email=ae,
)
result = await self._gitea.update_file(
org,
registry_repo,
"projects.yaml",
content=projects_yaml_new,
sha=projects_yaml_sha,
message=_stamp_single(f"chore: create project {project_id}", actor),
branch="main",
author_name=actor.display_name,
author_email=ae,
)
commit_sha = (
result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha")
or ""
)
_log(
actor,
"create_project",
bot_commit_sha=commit_sha,
details={"project_id": project_id, "content_repo": content_repo},
)
return result
# ----- Meta repo: idea PRs (§9.1 / §9.2) ----- # ----- Meta repo: idea PRs (§9.1 / §9.2) -----
async def open_idea_pr( async def open_idea_pr(
@@ -0,0 +1,202 @@
"""§22 S5 — create-project vertical: a global Owner POSTs `/api/projects`, the
bot provisions a Gitea content repo + commits the project to `projects.yaml`, and
the registry mirror upserts the `projects` + default `collections` rows (registry
stays the source of truth). Plus the deployment-directory empty-state signals
(`viewer.can_create_project`, `default_project_readable`) that drive C3.1/C3.2.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from app import db
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
)
def test_create_project_provisions_repo_commits_registry_and_mirrors(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
role="owner", email="ben@test")
r = client.post("/api/projects",
json={"project_id": "acme", "name": "Acme", "type": "bdd",
"visibility": "public"})
assert r.status_code == 200, r.text
body = r.json()
assert body["id"] == "acme"
assert body["type"] == "bdd"
assert body["visibility"] == "public"
# The bot provisioned the content repo (default name <id>-content) and
# seeded a README so `main` exists.
assert ("wiggleverse", "acme-content") in fake.repos
readme = fake.files.get(("wiggleverse", "acme-content", "main", "README.md"))
assert readme is not None and "acme" in readme["content"]
# The bot committed the new project into projects.yaml.
reg = fake.files.get(("wiggleverse", "registry", "main", "projects.yaml"))
assert reg is not None
assert "id: acme" in reg["content"]
assert "content_repo: acme-content" in reg["content"]
# The registry refresh mirrored a projects row + its default collection.
prow = db.conn().execute(
"SELECT name, content_repo, visibility FROM projects WHERE id='acme'"
).fetchone()
assert (prow["name"], prow["content_repo"], prow["visibility"]) == (
"Acme", "acme-content", "public")
crow = db.conn().execute(
"SELECT type, project_id, subfolder FROM collections WHERE id='acme'"
).fetchone()
assert (crow["type"], crow["project_id"], crow["subfolder"]) == ("bdd", "acme", "")
# It is now visible in the deployment directory and readable.
ids = {p["id"] for p in client.get("/api/deployment").json()["projects"]}
assert "acme" in ids
assert client.get("/api/projects/acme").status_code == 200
# An audit row records the structural action with the global Owner actor.
act = db.conn().execute(
"SELECT actor_user_id FROM actions WHERE action_kind='create_project'"
).fetchone()
assert act is not None and act["actor_user_id"] == 1
def test_create_project_custom_content_repo_name(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
role="owner", email="ben@test")
r = client.post("/api/projects",
json={"project_id": "beta", "name": "Beta", "type": "document",
"content_repo": "beta-corpus"})
assert r.status_code == 200, r.text
assert ("wiggleverse", "beta-corpus") in fake.repos
prow = db.conn().execute(
"SELECT content_repo FROM projects WHERE id='beta'"
).fetchone()
assert prow["content_repo"] == "beta-corpus"
def test_create_project_requires_global_owner(app_with_fake_gitea):
# A plain deployment contributor is not a global Owner (C: + New project is a
# global-Owner action), even though they may create collections.
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
role="contributor", email="alice@test")
r = client.post("/api/projects",
json={"project_id": "x", "name": "X", "type": "bdd"})
assert r.status_code == 403
def test_create_project_global_owner_grant_permitted(app_with_fake_gitea):
# An explicit global-scope Owner grant (not a deployment owner/admin) may
# create projects.
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=3, login="gina", role="contributor")
db.conn().execute(
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
"VALUES ('global', '*', 3, 'owner')"
)
sign_in_as(client, user_id=3, gitea_login="gina", display_name="Gina",
role="contributor", email="gina@test")
r = client.post("/api/projects",
json={"project_id": "gproj", "name": "G", "type": "document"})
assert r.status_code == 200, r.text
def test_create_project_anonymous_rejected(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.post("/api/projects",
json={"project_id": "x", "name": "X", "type": "bdd"})
assert r.status_code in (401, 403)
def test_create_project_rejects_duplicate(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
role="owner", email="ben@test")
ok = client.post("/api/projects",
json={"project_id": "acme", "name": "Acme", "type": "bdd"})
assert ok.status_code == 200, ok.text
dup = client.post("/api/projects",
json={"project_id": "acme", "name": "Acme 2", "type": "bdd"})
assert dup.status_code == 409
def test_create_project_rejects_reserved_default_id(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
role="owner", email="ben@test")
r = client.post("/api/projects",
json={"project_id": "default", "name": "X", "type": "bdd"})
assert r.status_code == 422
def test_create_project_rejects_bad_type(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
role="owner", email="ben@test")
r = client.post("/api/projects",
json={"project_id": "x", "name": "X", "type": "nonsense"})
assert r.status_code == 422
# --- C3.1 / C3.2: deployment-directory empty-state signals ------------------
def test_deployment_owner_sees_create_project_capability(app_with_fake_gitea):
# C3.1: a global Owner is offered the create-project action.
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
body = client.get("/api/deployment").json()
assert body["viewer"]["can_create_project"] is True
def test_deployment_non_owner_no_create_capability(app_with_fake_gitea):
# C3.2: a granted account with no roles is not offered create-project.
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="vee", role="contributor")
sign_in_as(client, user_id=2, gitea_login="vee", display_name="Vee", role="contributor")
body = client.get("/api/deployment").json()
assert body["viewer"]["can_create_project"] is False
def test_deployment_default_readable_when_default_is_public(app_with_fake_gitea):
# The seeded default project is public → readable → the N=1 redirect target
# is valid (land-in-corpus preserved).
app, _ = app_with_fake_gitea
with TestClient(app) as client:
body = client.get("/api/deployment").json()
assert body["default_project_readable"] is True
def test_deployment_default_not_readable_when_only_gated(app_with_fake_gitea):
# C3.2: the only project is gated; a granted non-member sees no visible
# projects AND default_project_readable False → the frontend renders the
# empty directory (no 404 bounce).
app, _ = app_with_fake_gitea
with TestClient(app) as client:
db.conn().execute("UPDATE projects SET visibility='gated' WHERE id='default'")
db.conn().execute("UPDATE collections SET visibility='gated' WHERE id='default'")
provision_user_row(user_id=2, login="vee", role="contributor")
sign_in_as(client, user_id=2, gitea_login="vee", display_name="Vee", role="contributor")
body = client.get("/api/deployment").json()
assert body["projects"] == []
assert body["default_project_readable"] is False
@@ -593,9 +593,16 @@ means the deployment runs and either gains a capability or provably loses none
invites collaborators at the right scope from the UI. **Completes:** `@S4` (all invites collaborators at the right scope from the UI. **Completes:** `@S4` (all
of C.2 — invitation; plus the project/collection empty states C3.3C3.5). of C.2 — invitation; plus the project/collection empty states C3.3C3.5).
- **S5 — In-app create-project + the global directory.** The global-Owner - **S5 — In-app create-project + the global directory.** *(Shipped v0.44.0.)*
The global-Owner
**create-project** action (bot provisions a Gitea content repo + commits to **create-project** action (bot provisions a Gitea content repo + commits to
`projects.yaml`); the deployment directory empty states. **Usable end-state:** `projects.yaml`); the deployment directory empty states. Modelled as a
global-Owner gate (`auth.can_create_project`: a deployment owner/admin or an
explicit `scope_type='global'` Owner grant) over `POST /api/projects`; the
content repo defaults to `<id>-content` and is seeded with a `README.md` so
`main` exists. The deployment payload gains `viewer.can_create_project` +
`default_project_readable` so the directory renders the role-aware empty state
rather than bouncing into an unreadable/absent default. **Usable end-state:**
a global Owner stands up a new project end-to-end from the UI. **Completes:** a global Owner stands up a new project end-to-end from the UI. **Completes:**
`@S5` (the global-directory empty states C3.1C3.2). `@S5` (the global-directory empty states C3.1C3.2).
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "rfc-app-frontend", "name": "rfc-app-frontend",
"private": true, "private": true,
"version": "0.43.0", "version": "0.44.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+7 -4
View File
@@ -448,16 +448,19 @@ function DeploymentLanding() {
// OHM's "land in the corpus" UX is preserved; the directory appears only // OHM's "land in the corpus" UX is preserved; the directory appears only
// when 2+ projects are visible to the caller. Visibility is per-caller // when 2+ projects are visible to the caller. Visibility is per-caller
// (§22.5), so the unlisted projects never count toward the directory. // (§22.5), so the unlisted projects never count toward the directory.
const { projects, defaultProjectId, loading } = useDeployment() const { projects, defaultProjectId, defaultProjectReadable, loading } = useDeployment()
if (loading) { if (loading) {
return <main className="chrome-pane"><div className="boot">Loading</div></main> return <main className="chrome-pane"><div className="boot">Loading</div></main>
} }
if (projects.length === 1) { if (projects.length === 1) {
return <Navigate to={`/p/${projects[0].id}/`} replace /> return <Navigate to={`/p/${projects[0].id}/`} replace />
} }
if (projects.length === 0 && defaultProjectId) { if (projects.length === 0 && defaultProjectReadable && defaultProjectId) {
// No enumerable projects but a default exists (e.g. an anon hitting a // No enumerable projects but the default is readable by this viewer (e.g. an
// deployment whose only project is unlisted-but-default) land there. // anon hitting a deployment whose only project is unlisted-but-default)
// land there. §22 S5: when the default is gated to this viewer (C3.2) or
// absent (C3.1, no projects), we do NOT bounce into a 404 we fall through
// to the directory's role-aware empty state below.
return <Navigate to={`/p/${defaultProjectId}/`} replace /> return <Navigate to={`/p/${defaultProjectId}/`} replace />
} }
return <Directory /> return <Directory />
+18
View File
@@ -182,6 +182,24 @@ export async function getProject(projectId) {
return jsonOrThrow(await fetch(`/api/projects/${projectId}`)) return jsonOrThrow(await fetch(`/api/projects/${projectId}`))
} }
// §22 S5: create-project (global Owner). The backend provisions a Gitea content
// repo, commits the project to projects.yaml, re-mirrors the registry, and
// returns the new project { id, name, type, visibility }.
export async function createProject({ projectId, name, type, visibility, contentRepo }) {
const res = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project_id: projectId,
name,
type,
visibility: visibility || null,
content_repo: contentRepo || null,
}),
})
return jsonOrThrow(res)
}
// §22.4 (Plan B) / §22 S2: per-collection serving. With a projectId + a // §22.4 (Plan B) / §22 S2: per-collection serving. With a projectId + a
// collectionId, read the collection-scoped routes; with only a projectId, the // collectionId, read the collection-scoped routes; with only a projectId, the
// project default-collection compat path; with neither, the unscoped path. // project default-collection compat path; with neither, the unscoped path.
@@ -0,0 +1,113 @@
// CreateProjectModal.jsx §22 S5. The create-project form the deployment
// directory's "Create your first project" / "New project" control opens, gated
// on the viewer's `can_create_project` capability (a global Owner action).
//
// The form lets the Owner choose a project id (a slug, not "default"), a display
// name, the type of its initial (default) collection, a visibility, and an
// optional content-repo name (defaulting to `<id>-content`). The backend
// provisions the Gitea content repo, commits the project to projects.yaml,
// re-mirrors the registry, and returns the new project; on success the caller
// refreshes the directory (and the N=1 redirect then lands in the new project).
import { useState } from 'react'
import { createProject } from '../api'
const TYPE_OPTIONS = [
{ value: 'document', label: 'Document — prose RFCs' },
{ value: 'specification', label: 'Specification' },
{ value: 'bdd', label: 'BDD — behaviour scenarios' },
]
const VISIBILITY_OPTIONS = [
{ value: 'public', label: 'Public' },
{ value: 'unlisted', label: 'Unlisted (link-only)' },
{ value: 'gated', label: 'Gated (hidden from the public)' },
]
export default function CreateProjectModal({ onClose, onCreated }) {
const [projectId, setProjectId] = useState('')
const [name, setName] = useState('')
const [type, setType] = useState('document')
const [visibility, setVisibility] = useState('public')
const [contentRepo, setContentRepo] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
async function handleCreate(e) {
e.preventDefault()
const pid = projectId.trim().toLowerCase()
if (!pid || !name.trim()) return
setSubmitting(true)
setError(null)
try {
const project = await createProject({
projectId: pid,
name: name.trim(),
type,
visibility,
contentRepo: contentRepo.trim() || null,
})
onCreated?.(project)
} catch (err) {
setError(err.message || 'Failed to create the project.')
} finally {
setSubmitting(false)
}
}
return (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal" style={{ maxWidth: 560 }}>
<div className="modal-header">
<h2>New project</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<form onSubmit={handleCreate} className="invitations-form">
<label htmlFor="proj-id">Project id</label>
<input
id="proj-id"
value={projectId}
onChange={e => setProjectId(e.target.value)}
placeholder="e.g. ohm, acme"
autoFocus
required
/>
<label htmlFor="proj-name" style={{ marginTop: 10 }}>Display name</label>
<input
id="proj-name"
value={name}
onChange={e => setName(e.target.value)}
placeholder="e.g. Open Human Model"
required
/>
<label htmlFor="proj-type" style={{ marginTop: 10 }}>Initial collection type</label>
<select id="proj-type" value={type} onChange={e => setType(e.target.value)}>
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<label htmlFor="proj-vis" style={{ marginTop: 10 }}>Visibility</label>
<select id="proj-vis" value={visibility} onChange={e => setVisibility(e.target.value)}>
{VISIBILITY_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<label htmlFor="proj-repo" style={{ marginTop: 10 }}>Content repo (optional)</label>
<input
id="proj-repo"
value={contentRepo}
onChange={e => setContentRepo(e.target.value)}
placeholder="defaults to <id>-content"
/>
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Creating…' : 'Create project'}
</button>
{error && <span style={{ color: '#c33' }}>{error}</span>}
</div>
</form>
</div>
<div className="modal-footer">
<button type="button" className="btn-link" onClick={onClose}>Close</button>
</div>
</div>
</div>
)
}
+45 -4
View File
@@ -1,19 +1,53 @@
// §22.10 (M3) the deployment directory at `/`. Renders the caller-visible // §22.10 (M3) the deployment directory at `/`. Renders the caller-visible
// projects (from /api/deployment) as cards linking into each project's // projects (from /api/deployment) as cards linking into each project's
// `/p/<id>/` home. App's DeploymentLanding only mounts this when 2+ projects // `/p/<id>/` home. App's DeploymentLanding only mounts this when the N=1
// are visible; the N=1 case redirects straight into the single project so // land-in-corpus redirect does not apply (2+ visible projects, or no readable
// OHM's "land in the corpus" UX is preserved. // default) so OHM's "land in the corpus" UX is preserved.
//
// §22 S5 role-aware empty states + the global-Owner create-project action.
// The deployment payload carries a `viewer` block; the directory reads it to
// render:
// * C3.1: a global Owner sees a "Create your first project" CTA (and a "New
// project" control when the directory is non-empty), opening the
// create-project modal (choose id, name, type, visibility).
// * C3.2: a non-owner (a granted account with no roles) sees the empty
// directory with no create action and a note that nothing is shared yet.
import { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useDeployment } from '../context/DeploymentProvider' import { useDeployment } from '../context/DeploymentProvider'
import { entryNoun } from './ProjectLayout.jsx' import { entryNoun } from './ProjectLayout.jsx'
import CreateProjectModal from './CreateProjectModal.jsx'
export default function Directory() { export default function Directory() {
const { name, tagline, projects } = useDeployment() const { name, tagline, projects, viewer, refresh } = useDeployment()
const [createOpen, setCreateOpen] = useState(false)
const canCreate = !!viewer?.can_create_project
return ( return (
<main className="chrome-pane"> <main className="chrome-pane">
<div className="directory"> <div className="directory">
<div className="directory-head">
<h1>{name}</h1> <h1>{name}</h1>
<div className="directory-actions">
{canCreate && projects.length > 0 && (
<button className="btn-primary" onClick={() => setCreateOpen(true)}>New project</button>
)}
</div>
</div>
{tagline && <p className="directory-tagline">{tagline}</p>} {tagline && <p className="directory-tagline">{tagline}</p>}
{projects.length === 0 ? (
// C3.1 / C3.2: role-keyed empty state.
canCreate ? (
<div className="directory-empty">
<p className="directory-tagline">No projects yet.</p>
<button className="btn-primary" onClick={() => setCreateOpen(true)}>
Create your first project
</button>
</div>
) : (
<p className="directory-tagline">Nothing has been shared with you yet.</p>
)
) : (
<ul className="directory-list"> <ul className="directory-list">
{projects.map(p => ( {projects.map(p => (
<li key={p.id} className="directory-card"> <li key={p.id} className="directory-card">
@@ -24,7 +58,14 @@ export default function Directory() {
</li> </li>
))} ))}
</ul> </ul>
)}
</div> </div>
{createOpen && (
<CreateProjectModal
onClose={() => setCreateOpen(false)}
onCreated={() => { setCreateOpen(false); refresh?.() }}
/>
)}
</main> </main>
) )
} }
+47 -9
View File
@@ -5,24 +5,34 @@ import { MemoryRouter } from 'react-router-dom'
// Mocked first so Directory (and ProjectLayout, which it imports entryNoun // Mocked first so Directory (and ProjectLayout, which it imports entryNoun
// from) resolve the deployment hook and api without a live fetch. // from) resolve the deployment hook and api without a live fetch.
let mockProjects = [] let mockDeployment = {}
vi.mock('../api', () => ({ getProject: vi.fn() })) vi.mock('../api', () => ({ getProject: vi.fn(), createProject: vi.fn() }))
vi.mock('../context/DeploymentProvider', () => ({ vi.mock('../context/DeploymentProvider', () => ({
useDeployment: () => ({ name: 'Wiggleverse', tagline: 'a directory of projects', projects: mockProjects, defaultProjectId: null }), useDeployment: () => mockDeployment,
})) }))
import Directory from './Directory.jsx' import Directory from './Directory.jsx'
function renderDir(projects) { function renderDir(overrides = {}) {
mockProjects = projects mockDeployment = {
name: 'Wiggleverse',
tagline: 'a directory of projects',
projects: [],
viewer: null,
defaultProjectId: null,
refresh: vi.fn(),
...overrides,
}
return render(<MemoryRouter><Directory /></MemoryRouter>) return render(<MemoryRouter><Directory /></MemoryRouter>)
} }
describe('Directory', () => { describe('Directory', () => {
it('renders a card per visible project with the type-driven noun + link', () => { it('renders a card per visible project with the type-driven noun + link', () => {
renderDir([ renderDir({
projects: [
{ id: 'ohm', name: 'Open Human Model', type: 'document', visibility: 'public' }, { id: 'ohm', name: 'Open Human Model', type: 'document', visibility: 'public' },
{ id: 'ecomm', name: 'Ecomm', type: 'bdd', visibility: 'public' }, { id: 'ecomm', name: 'Ecomm', type: 'bdd', visibility: 'public' },
]) ],
})
const ohm = screen.getByText('Open Human Model').closest('a') const ohm = screen.getByText('Open Human Model').closest('a')
expect(ohm).toHaveAttribute('href', '/p/ohm/') expect(ohm).toHaveAttribute('href', '/p/ohm/')
const ecomm = screen.getByText('Ecomm').closest('a') const ecomm = screen.getByText('Ecomm').closest('a')
@@ -33,13 +43,41 @@ describe('Directory', () => {
}) })
it('renders the deployment name and tagline', () => { it('renders the deployment name and tagline', () => {
renderDir([{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }]) renderDir({ projects: [{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }] })
expect(screen.getByText('Wiggleverse')).toBeInTheDocument() expect(screen.getByText('Wiggleverse')).toBeInTheDocument()
expect(screen.getByText('a directory of projects')).toBeInTheDocument() expect(screen.getByText('a directory of projects')).toBeInTheDocument()
}) })
it('renders an empty list without crashing when no projects are visible', () => { it('renders an empty list without crashing when no projects are visible', () => {
renderDir([]) renderDir({ projects: [] })
expect(screen.getByText('Wiggleverse')).toBeInTheDocument() expect(screen.getByText('Wiggleverse')).toBeInTheDocument()
}) })
// §22 S5 role-aware empty states (C3.1 / C3.2).
it('shows a "Create your first project" CTA to a global Owner on an empty directory', () => {
renderDir({ projects: [], viewer: { can_create_project: true } })
expect(screen.getByText('Create your first project')).toBeInTheDocument()
})
it('shows a "nothing shared with you yet" note to a non-owner on an empty directory', () => {
renderDir({ projects: [], viewer: { can_create_project: false } })
expect(screen.getByText(/Nothing has been shared with you yet/i)).toBeInTheDocument()
expect(screen.queryByText('Create your first project')).not.toBeInTheDocument()
})
it('offers a "New project" control to an Owner when the directory is non-empty', () => {
renderDir({
projects: [{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }],
viewer: { can_create_project: true },
})
expect(screen.getByText('New project')).toBeInTheDocument()
})
it('does not offer a create control to a non-owner when the directory is non-empty', () => {
renderDir({
projects: [{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }],
viewer: { can_create_project: false },
})
expect(screen.queryByText('New project')).not.toBeInTheDocument()
})
}) })
+30 -12
View File
@@ -2,22 +2,30 @@
// VITE_APP_NAME. Fetches GET /api/deployment once on boot and provides it to // VITE_APP_NAME. Fetches GET /api/deployment once on boot and provides it to
// the whole tree: // the whole tree:
// //
// { name, tagline, defaultProjectId, projects[], loading } // { name, tagline, defaultProjectId, defaultProjectReadable, projects[],
// viewer, loading, refresh }
// //
// `projects` is the per-caller-visible set (§22.5: public + member-gated; // `projects` is the per-caller-visible set (§22.5: public + member-gated;
// unlisted omitted). `defaultProjectId` is the corpus-served project the // unlisted omitted). `defaultProjectId` is the corpus-served project the
// M3-frontend guard keys on. During the pre-fetch paint, consumers fall back // M3-frontend guard keys on. `defaultProjectReadable` (§22 S5) is whether that
// to the neutral brandTitle() default ('RFC') never a hardcoded deployment // redirect target is reachable by this viewer the deployment landing only
// name. // bounces into it when true, else it renders the role-aware directory empty
import { createContext, useContext, useEffect, useState } from 'react' // state. `viewer` carries the §22 S5 capability flags (`can_create_project`).
// `refresh` re-fetches the payload (e.g. after creating a project). During the
// pre-fetch paint, consumers fall back to the neutral brandTitle() default
// ('RFC') never a hardcoded deployment name.
import { createContext, useContext, useCallback, useEffect, useState } from 'react'
import { getDeployment } from '../api' import { getDeployment } from '../api'
const DeploymentContext = createContext({ const DeploymentContext = createContext({
name: '', name: '',
tagline: '', tagline: '',
defaultProjectId: null, defaultProjectId: null,
defaultProjectReadable: false,
projects: [], projects: [],
viewer: null,
loading: true, loading: true,
refresh: () => {},
}) })
export function useDeployment() { export function useDeployment() {
@@ -29,33 +37,43 @@ export function DeploymentProvider({ children }) {
name: '', name: '',
tagline: '', tagline: '',
defaultProjectId: null, defaultProjectId: null,
defaultProjectReadable: false,
projects: [], projects: [],
viewer: null,
loading: true, loading: true,
}) })
useEffect(() => { const load = useCallback((cancelledRef) => {
let cancelled = false return getDeployment()
getDeployment()
.then(d => { .then(d => {
if (cancelled) return if (cancelledRef?.cancelled) return
setState({ setState({
name: d.name || '', name: d.name || '',
tagline: d.tagline || '', tagline: d.tagline || '',
defaultProjectId: d.default_project_id || null, defaultProjectId: d.default_project_id || null,
defaultProjectReadable: !!d.default_project_readable,
projects: Array.isArray(d.projects) ? d.projects : [], projects: Array.isArray(d.projects) ? d.projects : [],
viewer: d.viewer || null,
loading: false, loading: false,
}) })
}) })
.catch(() => { .catch(() => {
// A failed deployment fetch must not wedge the app: fall back to the // A failed deployment fetch must not wedge the app: fall back to the
// neutral brand and an empty directory so the shell still paints. // neutral brand and an empty directory so the shell still paints.
if (!cancelled) setState(s => ({ ...s, loading: false })) if (!cancelledRef?.cancelled) setState(s => ({ ...s, loading: false }))
}) })
return () => { cancelled = true }
}, []) }, [])
useEffect(() => {
const ref = { cancelled: false }
load(ref)
return () => { ref.cancelled = true }
}, [load])
const refresh = useCallback(() => load(), [load])
return ( return (
<DeploymentContext.Provider value={state}> <DeploymentContext.Provider value={{ ...state, refresh }}>
{children} {children}
</DeploymentContext.Provider> </DeploymentContext.Provider>
) )