§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/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
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
**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())
# §22.9/§22.10 (M3): runtime deployment + per-project config (replaces
# 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))
# §22 S4 (C.2): the scope-role invitation surface — Owners grant
# {owner, contributor} at project/collection scope to existing accounts.
+142 -8
View File
@@ -1,28 +1,60 @@
"""§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),
plus the corpus-served `default_project_id` the M3-frontend guard keys on.
GET /api/projects/:id one project's runtime config + optional theme overlay,
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
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
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._-]*$")
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.get("/api/deployment")
@@ -51,6 +83,16 @@ def make_router(config: Config) -> APIRouter:
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 "",
@@ -58,8 +100,100 @@ def make_router(config: Config) -> APIRouter:
# 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": projects_mod.resolved_default_id(config),
"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}")
+19
View File
@@ -682,6 +682,25 @@ def can_create_collection(user: SessionUser | None, project_id: str) -> bool:
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:
"""§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
+76
View File
@@ -198,6 +198,82 @@ class Bot:
)
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) -----
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
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
`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:**
`@S5` (the global-directory empty states C3.1C3.2).
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.43.0",
"version": "0.44.0",
"type": "module",
"scripts": {
"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
// when 2+ projects are visible to the caller. Visibility is per-caller
// (§22.5), so the unlisted projects never count toward the directory.
const { projects, defaultProjectId, loading } = useDeployment()
const { projects, defaultProjectId, defaultProjectReadable, loading } = useDeployment()
if (loading) {
return <main className="chrome-pane"><div className="boot">Loading</div></main>
}
if (projects.length === 1) {
return <Navigate to={`/p/${projects[0].id}/`} replace />
}
if (projects.length === 0 && defaultProjectId) {
// No enumerable projects but a default exists (e.g. an anon hitting a
// deployment whose only project is unlisted-but-default) land there.
if (projects.length === 0 && defaultProjectReadable && defaultProjectId) {
// No enumerable projects but the default is readable by this viewer (e.g. an
// 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 <Directory />
+18
View File
@@ -182,6 +182,24 @@ export async function getProject(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
// collectionId, read the collection-scoped routes; with only a projectId, the
// 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>
)
}
+56 -15
View File
@@ -1,30 +1,71 @@
// §22.10 (M3) the deployment directory at `/`. Renders the caller-visible
// projects (from /api/deployment) as cards linking into each project's
// `/p/<id>/` home. App's DeploymentLanding only mounts this when 2+ projects
// are visible; the N=1 case redirects straight into the single project so
// OHM's "land in the corpus" UX is preserved.
// `/p/<id>/` home. App's DeploymentLanding only mounts this when the N=1
// land-in-corpus redirect does not apply (2+ visible projects, or no readable
// 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 { useDeployment } from '../context/DeploymentProvider'
import { entryNoun } from './ProjectLayout.jsx'
import CreateProjectModal from './CreateProjectModal.jsx'
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 (
<main className="chrome-pane">
<div className="directory">
<h1>{name}</h1>
<div className="directory-head">
<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>}
<ul className="directory-list">
{projects.map(p => (
<li key={p.id} className="directory-card">
<Link to={`/p/${p.id}/`}>
<span className="directory-card-name">{p.name}</span>
<span className="directory-card-type">{entryNoun(p.type)}s</span>
</Link>
</li>
))}
</ul>
{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">
{projects.map(p => (
<li key={p.id} className="directory-card">
<Link to={`/p/${p.id}/`}>
<span className="directory-card-name">{p.name}</span>
<span className="directory-card-type">{entryNoun(p.type)}s</span>
</Link>
</li>
))}
</ul>
)}
</div>
{createOpen && (
<CreateProjectModal
onClose={() => setCreateOpen(false)}
onCreated={() => { setCreateOpen(false); refresh?.() }}
/>
)}
</main>
)
}
+50 -12
View File
@@ -5,24 +5,34 @@ import { MemoryRouter } from 'react-router-dom'
// Mocked first so Directory (and ProjectLayout, which it imports entryNoun
// from) resolve the deployment hook and api without a live fetch.
let mockProjects = []
vi.mock('../api', () => ({ getProject: vi.fn() }))
let mockDeployment = {}
vi.mock('../api', () => ({ getProject: vi.fn(), createProject: vi.fn() }))
vi.mock('../context/DeploymentProvider', () => ({
useDeployment: () => ({ name: 'Wiggleverse', tagline: 'a directory of projects', projects: mockProjects, defaultProjectId: null }),
useDeployment: () => mockDeployment,
}))
import Directory from './Directory.jsx'
function renderDir(projects) {
mockProjects = projects
function renderDir(overrides = {}) {
mockDeployment = {
name: 'Wiggleverse',
tagline: 'a directory of projects',
projects: [],
viewer: null,
defaultProjectId: null,
refresh: vi.fn(),
...overrides,
}
return render(<MemoryRouter><Directory /></MemoryRouter>)
}
describe('Directory', () => {
it('renders a card per visible project with the type-driven noun + link', () => {
renderDir([
{ id: 'ohm', name: 'Open Human Model', type: 'document', visibility: 'public' },
{ id: 'ecomm', name: 'Ecomm', type: 'bdd', visibility: 'public' },
])
renderDir({
projects: [
{ id: 'ohm', name: 'Open Human Model', type: 'document', visibility: 'public' },
{ id: 'ecomm', name: 'Ecomm', type: 'bdd', visibility: 'public' },
],
})
const ohm = screen.getByText('Open Human Model').closest('a')
expect(ohm).toHaveAttribute('href', '/p/ohm/')
const ecomm = screen.getByText('Ecomm').closest('a')
@@ -33,13 +43,41 @@ describe('Directory', () => {
})
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('a directory of projects')).toBeInTheDocument()
})
it('renders an empty list without crashing when no projects are visible', () => {
renderDir([])
renderDir({ projects: [] })
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
// 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;
// unlisted omitted). `defaultProjectId` is the corpus-served project the
// M3-frontend guard keys on. During the pre-fetch paint, consumers fall back
// to the neutral brandTitle() default ('RFC') never a hardcoded deployment
// name.
import { createContext, useContext, useEffect, useState } from 'react'
// M3-frontend guard keys on. `defaultProjectReadable` (§22 S5) is whether that
// redirect target is reachable by this viewer the deployment landing only
// bounces into it when true, else it renders the role-aware directory empty
// 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'
const DeploymentContext = createContext({
name: '',
tagline: '',
defaultProjectId: null,
defaultProjectReadable: false,
projects: [],
viewer: null,
loading: true,
refresh: () => {},
})
export function useDeployment() {
@@ -29,33 +37,43 @@ export function DeploymentProvider({ children }) {
name: '',
tagline: '',
defaultProjectId: null,
defaultProjectReadable: false,
projects: [],
viewer: null,
loading: true,
})
useEffect(() => {
let cancelled = false
getDeployment()
const load = useCallback((cancelledRef) => {
return getDeployment()
.then(d => {
if (cancelled) return
if (cancelledRef?.cancelled) return
setState({
name: d.name || '',
tagline: d.tagline || '',
defaultProjectId: d.default_project_id || null,
defaultProjectReadable: !!d.default_project_readable,
projects: Array.isArray(d.projects) ? d.projects : [],
viewer: d.viewer || null,
loading: false,
})
})
.catch(() => {
// A failed deployment fetch must not wedge the app: fall back to the
// 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 (
<DeploymentContext.Provider value={state}>
<DeploymentContext.Provider value={{ ...state, refresh }}>
{children}
</DeploymentContext.Provider>
)