Compare commits
10 Commits
868391870c
...
c2f566512a
| Author | SHA1 | Date | |
|---|---|---|---|
| c2f566512a | |||
| 39ce54fbcc | |||
| 55d04ce4ca | |||
| bd6dc6524a | |||
| 17bdd5fd9a | |||
| 2b32e124ab | |||
| 98eea3e2d6 | |||
| 0c654b173d | |||
| f57d4080dc | |||
| 91b0fb358c |
+137
@@ -23,6 +23,143 @@ 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.42.0 — 2026-06-05
|
||||
|
||||
**Minor (breaking — upgrade steps below) — §22 three-tier refactor, slice S3:
|
||||
*scope-role enforcement + collection-grain visibility.* Authorization is now
|
||||
resolved by the four-layer most-permissive union of §B.2 — global → project →
|
||||
collection → per-entry — over the unified `{owner, contributor}` roles, with the
|
||||
§22.5 visibility gate enforced at the **collection** grain. A collection can be
|
||||
"hidden from public existence" (`gated`): invisible to anonymous viewers and
|
||||
omitted from the project directory, yet readable and listed for any contributor
|
||||
holding a scope role that reaches it (collection / project / global). A
|
||||
collection's visibility may be set only as strict or stricter than its project's.
|
||||
Completes acceptance scenarios `@S3` (C1.1–C1.8: role usage, inheritance, the
|
||||
most-permissive union, and no-negative-override).**
|
||||
|
||||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||||
(Part B roles, Part C.1 scenarios, Part E slice S3). The invitation UI and
|
||||
role-keyed empty states remain S4; grants in S3 are applied administratively
|
||||
(via the `memberships` table / an Owner-authorized create surface).
|
||||
|
||||
Added:
|
||||
|
||||
- **Four-layer scope-role resolver** — `effective_scope_role(user, collection)`
|
||||
folds a deployment owner/admin (global Owner), an explicit global-scope grant,
|
||||
a project-scope grant, and a collection-scope grant into the most-permissive
|
||||
unified role over a collection. Owner outranks RFC Contributor; there is no
|
||||
negative override (a child scope can never subtract a parent grant).
|
||||
- **Global-scope grants** — migration 030 extends `memberships.scope_type` to
|
||||
`{global, project, collection}`. A "global RFC Contributor" (writes in every
|
||||
collection of every project, distinct from a deployment owner) is a
|
||||
`scope_type='global'` row (sentinel `scope_id='*'`).
|
||||
- **Collection-grain visibility enforcement** — `can_read_collection` /
|
||||
`require_collection_readable` gate the collection-scoped read, entry, and
|
||||
propose routes; the project collection-directory (`GET
|
||||
/api/projects/<id>/collections`) is now viewer-aware (a hidden/gated collection
|
||||
is listed only for a scope-role holder; `unlisted` stays omitted from
|
||||
enumeration). The effective read gate is the stricter of the project's and the
|
||||
collection's visibility.
|
||||
- **Collection visibility strictness** — a collection may narrow but never widen
|
||||
its project's visibility (`public` < `unlisted` < `gated`). Enforced at
|
||||
create-collection (422 on a looser request) and clamped at the registry mirror.
|
||||
- **create-collection authority widened (§B.1)** — `POST
|
||||
/api/projects/<id>/collections` now admits a project-scope or global-scope
|
||||
grant holder (Owner **or** RFC Contributor — the project-level "create a
|
||||
collection" affordance), not only a deployment owner/admin. A collection-scope
|
||||
grant cannot create sibling collections.
|
||||
|
||||
Changed (breaking):
|
||||
|
||||
- **Write standing now requires an explicit scope grant outside the default
|
||||
collection.** The pre-three-tier implicit-on-public write baseline (a granted
|
||||
deployment `contributor` may propose on any public project with no membership
|
||||
row) is **narrowed to the migration-seeded `default` collection only** (the N=1
|
||||
case, §22.13). On every *explicitly-created* collection — and on every project
|
||||
beyond the default — proposing, discussing, branching, and contributing now
|
||||
require an explicit `{owner, contributor}` grant at the collection, its
|
||||
project, or global scope. Reads of public collections are unchanged.
|
||||
- Entry-scoped authority checks (mark-reviewed, graduate, branch read/contribute,
|
||||
PR/discussion/contribution moderation) are re-pointed from the project grain to
|
||||
the entry's **collection** grain, so a collection Owner administers exactly
|
||||
their collection's subtree and no more.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. The schema migration (`030_global_scope.sql`) runs automatically on deploy and
|
||||
is backward-data-compatible — existing `memberships` rows are preserved. No
|
||||
operator action is required for the migration itself.
|
||||
2. **A single-collection (N=1) deployment needs no further action.** The `default`
|
||||
collection keeps the implicit-on-public write baseline, so existing granted
|
||||
contributors keep proposing exactly as before.
|
||||
3. **A deployment that has created additional collections (S2) MUST grant scope
|
||||
roles to its contributors.** Any contributor who should write in a non-default
|
||||
collection (or in a second project) now needs an explicit grant: a
|
||||
`memberships` row at `scope_type` `collection` (that collection), `project`
|
||||
(its project — covers every collection within), or `global` (`scope_id='*'` —
|
||||
every project). A deployment owner/admin is unaffected (global Owner by role).
|
||||
4. To make a collection **hidden from the public**, set `visibility: gated` in its
|
||||
`.collection.yaml` (or the project's `visibility` in `projects.yaml`); the
|
||||
value MUST be as strict or stricter than the parent project's. The mirror
|
||||
clamps a looser value and logs a warning.
|
||||
|
||||
## 0.41.0 — 2026-06-05
|
||||
|
||||
**Minor (non-breaking) — §22 three-tier refactor, slice S2: *create & navigate a
|
||||
second collection.* A deployment can now host more than one RFC collection per
|
||||
project: a second collection is created, navigated, and proposed into beside the
|
||||
default one. Purely additive — a single-collection deployment is unchanged (its
|
||||
`/p/<project>/` still redirects into the sole collection, C3.7/C3.8). Completes
|
||||
acceptance scenario `@S2` (C3.6: an anonymous reader of an empty public
|
||||
collection sees an empty catalog with no propose action and a sign-in prompt).**
|
||||
|
||||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||||
(Part E slice S2) and the slice plan
|
||||
[`docs/design/plans/2026-06-05-s2-second-collection.md`](./docs/design/plans/2026-06-05-s2-second-collection.md).
|
||||
Scoped {owner, contributor} roles at the collection axis remain S3; the
|
||||
role-keyed create/propose-first empty states remain S4.
|
||||
|
||||
Added:
|
||||
|
||||
- **Named collections via `.collection.yaml`** — the registry mirror walks each
|
||||
project's content repo and upserts a collection per
|
||||
`<subfolder>/.collection.yaml` manifest (`type`, optional `visibility` /
|
||||
`initial_state` / `name`; `type` immutable per §22.4a, visibility inherits the
|
||||
project's when omitted). The default collection still flows from
|
||||
`projects.yaml`.
|
||||
- **create-collection** — `POST /api/projects/<id>/collections` (deployment
|
||||
owner/admin). The bot commits a `.collection.yaml` to the content repo's
|
||||
`main`, then the registry re-mirrors so the `collections` row appears (the
|
||||
registry stays the source of truth, §22.2). New reads
|
||||
`GET /api/projects/<id>/collections` and `…/collections/<cid>`.
|
||||
- **Collection-scoped serve + propose** —
|
||||
`GET /api/projects/<id>/collections/<cid>/rfcs[/<slug>]` and
|
||||
`POST …/collections/<cid>/rfcs/propose`. A propose writes the entry under the
|
||||
target collection's `<subfolder>/rfcs/`.
|
||||
- **Collection directory at `/p/<project>/`** — lists the project's visible
|
||||
collections, or redirects into the sole one when there is exactly one
|
||||
(preserving the S1 single-collection UX). The catalog rail + entry views read
|
||||
the active `/c/<collection>/` segment and scope to it.
|
||||
|
||||
Changed:
|
||||
|
||||
- **The corpus mirror is collection-grained** — `cache.refresh_meta_repo`
|
||||
iterates each project's collections and reads each collection's
|
||||
`<subfolder>/rfcs/`, keying `cached_rfcs` by `collection_id`. The default
|
||||
collection keeps the shipped repo-root `rfcs/` path; N=1 serving is unchanged.
|
||||
|
||||
> ### Upgrade steps (0.40.0 → 0.41.0)
|
||||
>
|
||||
> - No required operator action — the slice is additive and the default-collection
|
||||
> paths are unchanged. A deployment **MAY** deploy this version with no config
|
||||
> change and keep running exactly as on 0.40.0.
|
||||
> - To add a second collection, a deployment owner/admin **MAY** call
|
||||
> `POST /api/projects/<id>/collections` (or commit a `<subfolder>/.collection.yaml`
|
||||
> to the content repo directly); the registry mirror picks it up on the next
|
||||
> refresh.
|
||||
> - Deployments that pin the framework version **MUST** bump their version pin to
|
||||
> `0.41.0`.
|
||||
|
||||
## 0.40.0 — 2026-06-05
|
||||
|
||||
**Minor (breaking URL) — §22 three-tier refactor, slice S1: the *collection*
|
||||
|
||||
+95
-27
@@ -21,6 +21,7 @@ from pydantic import BaseModel, Field
|
||||
from . import (
|
||||
api_admin,
|
||||
api_branches,
|
||||
api_collections,
|
||||
api_contributions,
|
||||
api_deployment,
|
||||
api_discussion,
|
||||
@@ -152,6 +153,7 @@ def 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_collections.make_router(config, gitea, bot))
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §17: /api/health — unauthenticated post-flight probe.
|
||||
@@ -717,15 +719,15 @@ def make_router(
|
||||
# second project's corpus renders under /p/<id>/.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/projects/{project_id}/rfcs")
|
||||
async def list_project_rfcs(
|
||||
project_id: str, request: Request, unreviewed: str | None = None
|
||||
def _require_collection_in_project(collection_id: str, project_id: str) -> None:
|
||||
# §22 S2: a collection-scoped route 404s when the collection does not
|
||||
# belong to the project in the path (shape matches an unknown id).
|
||||
if collections_mod.project_of_collection(collection_id) != project_id:
|
||||
raise HTTPException(404, "Not found")
|
||||
|
||||
def _list_rfcs_for_collection(
|
||||
collection_id: str, viewer, unreviewed: str | None
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
# §22.5 read gate: a gated project's catalog 404s to a non-member.
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
# §22 S1: serve the project's default collection (the corpus grain).
|
||||
collection_id = collections_mod.default_collection_id(project_id)
|
||||
viewer_id = viewer.user_id if viewer else None
|
||||
unreviewed_clause = ""
|
||||
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
|
||||
@@ -769,11 +771,7 @@ def make_router(
|
||||
]
|
||||
return {"items": items}
|
||||
|
||||
@router.get("/api/projects/{project_id}/rfcs/{slug}")
|
||||
async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
collection_id = collections_mod.default_collection_id(project_id)
|
||||
def _get_rfc_for_collection(collection_id: str, slug: str, viewer) -> dict[str, Any]:
|
||||
row = db.conn().execute(
|
||||
"SELECT * FROM cached_rfcs WHERE collection_id = ? AND slug = ?",
|
||||
(collection_id, slug),
|
||||
@@ -794,6 +792,49 @@ def make_router(
|
||||
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
||||
return payload
|
||||
|
||||
@router.get("/api/projects/{project_id}/rfcs")
|
||||
async def list_project_rfcs(
|
||||
project_id: str, request: Request, unreviewed: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
# §22.5 read gate: a gated project's catalog 404s to a non-member.
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
# §22 S1: the project-scoped route serves the default collection.
|
||||
collection_id = collections_mod.default_collection_id(project_id)
|
||||
return _list_rfcs_for_collection(collection_id, viewer, unreviewed)
|
||||
|
||||
@router.get("/api/projects/{project_id}/rfcs/{slug}")
|
||||
async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
collection_id = collections_mod.default_collection_id(project_id)
|
||||
return _get_rfc_for_collection(collection_id, slug, viewer)
|
||||
|
||||
# §22 S2: collection-scoped serve + propose. The catalog/entry views read
|
||||
# these under /p/<project>/c/<collection>/; the project-scoped routes above
|
||||
# stay as the default-collection compat surface.
|
||||
@router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs")
|
||||
async def list_collection_rfcs(
|
||||
project_id: str, collection_id: str, request: Request,
|
||||
unreviewed: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
_require_collection_in_project(collection_id, project_id)
|
||||
# §22.5 (S3): a hidden/gated collection 404s to a non-scope-role viewer.
|
||||
auth.require_collection_readable(viewer, collection_id)
|
||||
return _list_rfcs_for_collection(collection_id, viewer, unreviewed)
|
||||
|
||||
@router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}")
|
||||
async def get_collection_rfc(
|
||||
project_id: str, collection_id: str, slug: str, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
_require_collection_in_project(collection_id, project_id)
|
||||
auth.require_collection_readable(viewer, collection_id)
|
||||
return _get_rfc_for_collection(collection_id, slug, viewer)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §22.4c: mark-reviewed — clear an active entry's `unreviewed` flag
|
||||
# ---------------------------------------------------------------
|
||||
@@ -801,12 +842,13 @@ def make_router(
|
||||
@router.post("/api/projects/{project_id}/rfcs/{slug}/mark-reviewed")
|
||||
async def mark_reviewed(project_id: str, slug: str, request: Request) -> dict[str, Any]:
|
||||
"""§22.4c — clear an active entry's `unreviewed` flag. Authority is the
|
||||
§22.7 project superuser (project_admin or deployment owner/admin)."""
|
||||
§B.2 collection Owner (a collection/project/global Owner or deployment
|
||||
owner/admin reaching the entry's collection)."""
|
||||
viewer = auth.require_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
if not auth.is_project_superuser(viewer, project_id):
|
||||
raise HTTPException(403, "Only a project owner/admin can mark an entry reviewed")
|
||||
collection_id = collections_mod.default_collection_id(project_id)
|
||||
if not auth.is_collection_superuser(viewer, collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can mark an entry reviewed")
|
||||
row = db.conn().execute(
|
||||
"SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND collection_id = ?",
|
||||
(slug, collection_id),
|
||||
@@ -957,11 +999,20 @@ def make_router(
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
async def _propose_into_project(project_id: str, payload: ProposeBody, user) -> dict[str, Any]:
|
||||
# §22.6/§22.7: proposing a new entry requires project-level contribute
|
||||
# standing in the *target* project. On the public default project the
|
||||
# implicit-public baseline preserves the pre-multi-project flow.
|
||||
if not auth.can_contribute_in_project(user, project_id):
|
||||
raise HTTPException(403, "You do not have contribute access to this project")
|
||||
# Default-collection wrapper (§22 S1/S2): resolve the project's default
|
||||
# collection and delegate. Keeps the project-scoped propose routes intact.
|
||||
return await _propose_into_collection(
|
||||
project_id, collections_mod.default_collection_id(project_id), payload, user
|
||||
)
|
||||
|
||||
async def _propose_into_collection(
|
||||
project_id: str, collection_id: str, payload: ProposeBody, user
|
||||
) -> dict[str, Any]:
|
||||
# §B.2 (S3): proposing a new entry requires contribute standing in the
|
||||
# *target collection* — the four-layer scope-role union, with the
|
||||
# grandfathered implicit-public baseline on the default collection.
|
||||
if not auth.can_contribute_in_collection(user, collection_id):
|
||||
raise HTTPException(403, "You do not have contribute access to this collection")
|
||||
slug = payload.slug.strip().lower()
|
||||
if not entry_mod.is_valid_slug(slug):
|
||||
raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes")
|
||||
@@ -970,7 +1021,6 @@ def make_router(
|
||||
# We re-check atomically here even though the client also checks
|
||||
# on every keystroke, since a concurrent submission could land
|
||||
# between dialog-open and submit.
|
||||
collection_id = collections_mod.default_collection_id(project_id)
|
||||
clash = db.conn().execute(
|
||||
"SELECT 1 FROM cached_rfcs WHERE slug = ? AND collection_id = ?", (slug, collection_id)
|
||||
).fetchone()
|
||||
@@ -984,11 +1034,12 @@ def make_router(
|
||||
if idea_clash:
|
||||
raise HTTPException(409, f"Slug `{slug}` is already reserved by an open proposal")
|
||||
|
||||
# §22.4b: the target project's landing state. Through Plan A every
|
||||
# entry lands in the default project; M3-frontend routing carries a
|
||||
# non-default target later.
|
||||
target_project = project_id
|
||||
landing_state = "active" if projects_mod.project_initial_state(target_project) == "active" else "super-draft"
|
||||
# §22.4b: the target collection's landing state (the per-corpus field
|
||||
# moved down to the collection in migration 029).
|
||||
landing_state = (
|
||||
"active" if collections_mod.collection_initial_state(collection_id) == "active"
|
||||
else "super-draft"
|
||||
)
|
||||
|
||||
entry = entry_mod.Entry(
|
||||
slug=slug,
|
||||
@@ -1019,6 +1070,9 @@ def make_router(
|
||||
f"**Topic:** {entry.title}\n\n"
|
||||
f"{payload.pitch.strip()}"
|
||||
)
|
||||
# §22 S2: write the entry under the target collection's <subfolder>/rfcs.
|
||||
subfolder = collections_mod.subfolder_of(collection_id)
|
||||
rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs"
|
||||
try:
|
||||
pr = await bot.open_idea_pr(
|
||||
user.as_actor(),
|
||||
@@ -1028,6 +1082,7 @@ def make_router(
|
||||
file_contents=contents,
|
||||
pr_title=pr_title,
|
||||
pr_description=pr_description,
|
||||
rfcs_dir=rfcs_dir,
|
||||
)
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
@@ -1076,6 +1131,19 @@ def make_router(
|
||||
auth.require_project_readable(user, project_id)
|
||||
return await _propose_into_project(project_id, payload, user)
|
||||
|
||||
@router.post("/api/projects/{project_id}/collections/{collection_id}/rfcs/propose")
|
||||
async def propose_collection_rfc(
|
||||
project_id: str, collection_id: str, payload: ProposeBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
# §22 S2: propose a new entry into a specific collection of a project.
|
||||
user = auth.require_contributor(request)
|
||||
auth.require_project_readable(user, project_id)
|
||||
_require_collection_in_project(collection_id, project_id)
|
||||
# §22.5 (S3): a hidden/gated collection 404s a non-scope-role viewer
|
||||
# before the contribute check (existence is not revealed).
|
||||
auth.require_collection_readable(user, collection_id)
|
||||
return await _propose_into_collection(project_id, collection_id, payload, user)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §9.1 Slice 2 (roadmap #27): Claude Haiku tag suggestions as the
|
||||
# propose-RFC fields fill in. The modal debounce-posts the partial
|
||||
|
||||
+18
-18
@@ -1264,11 +1264,11 @@ def make_router(
|
||||
return row["on_behalf_of"] if row else None
|
||||
|
||||
def _can_read_branch(slug: str, branch: str, viewer) -> bool:
|
||||
# §22.5 visibility gate first (subtractive, §22.7): in a gated project
|
||||
# nothing — not even main or a read_public branch — is readable by a
|
||||
# non-member.
|
||||
pid = auth.project_of_rfc(slug)
|
||||
if not auth.can_read_project(viewer, pid):
|
||||
# §22.5 visibility gate first (subtractive, §B.2): in a hidden/gated
|
||||
# collection nothing — not even main or a read_public branch — is
|
||||
# readable by a non-scope-role viewer.
|
||||
cid = auth.collection_of_rfc(slug)
|
||||
if not auth.can_read_collection(viewer, cid):
|
||||
return False
|
||||
if branch == "main":
|
||||
return True
|
||||
@@ -1277,7 +1277,7 @@ def make_router(
|
||||
return True
|
||||
if viewer is None:
|
||||
return False
|
||||
if auth.is_project_superuser(viewer, pid):
|
||||
if auth.is_collection_superuser(viewer, cid):
|
||||
return True
|
||||
creator = _branch_creator(slug, branch)
|
||||
if creator and viewer.gitea_login == creator:
|
||||
@@ -1310,12 +1310,12 @@ def make_router(
|
||||
# legacy `repo:` is set (nothing, after the RFC-0001 fold-back).
|
||||
if rfc["state"] == "active" and rfc["repo"] and _is_meta_branch_name(branch):
|
||||
return False
|
||||
pid = auth.project_of_rfc(slug)
|
||||
cid = auth.collection_of_rfc(slug)
|
||||
# §22.5 visibility gate (subtractive): no contribute in an unreadable
|
||||
# project.
|
||||
if not auth.can_read_project(viewer, pid):
|
||||
# collection.
|
||||
if not auth.can_read_collection(viewer, cid):
|
||||
return False
|
||||
if auth.is_project_superuser(viewer, pid):
|
||||
if auth.is_collection_superuser(viewer, cid):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
@@ -1326,10 +1326,10 @@ def make_router(
|
||||
return True
|
||||
vis = _branch_vis(slug, branch)
|
||||
if vis["contribute_mode"] == "any-contributor":
|
||||
# "any contributor" means anyone with project-level write standing
|
||||
# (§22.6/§22.7) — the implicit-public baseline on a public project,
|
||||
# or an explicit project_contributor/admin elsewhere.
|
||||
return auth.can_contribute_in_project(viewer, pid)
|
||||
# "any contributor" means anyone with collection-level write standing
|
||||
# (§B.2) — the grandfathered baseline on the public default
|
||||
# collection, or an explicit scope grant reaching the collection.
|
||||
return auth.can_contribute_in_collection(viewer, cid)
|
||||
if vis["contribute_mode"] == "specific":
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
@@ -1352,7 +1352,7 @@ def make_router(
|
||||
def _require_branch_owner(rfc, viewer, creator: str | None) -> None:
|
||||
# §22.6: a project_admin is the per-RFC owner/arbiter authority lifted
|
||||
# to project scope, so it (and a deployment owner/admin) clears here.
|
||||
if auth.is_project_superuser(viewer, rfc["project_id"]):
|
||||
if auth.is_collection_superuser(viewer, rfc["collection_id"]):
|
||||
return
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
@@ -1368,7 +1368,7 @@ def make_router(
|
||||
has no owners, so the set collapses to the superuser tier only —
|
||||
sensible because admin oversight is the only path to canonicalizing
|
||||
edits on an unclaimed entry."""
|
||||
if auth.is_project_superuser(viewer, rfc["project_id"]):
|
||||
if auth.is_collection_superuser(viewer, rfc["collection_id"]):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
@@ -1381,7 +1381,7 @@ def make_router(
|
||||
"can_read": _can_read_branch(slug, branch, viewer),
|
||||
"can_contribute": _can_contribute(rfc, slug, branch, viewer) if viewer else False,
|
||||
"can_change_branch_settings": viewer is not None and (
|
||||
auth.is_project_superuser(viewer, rfc["project_id"])
|
||||
auth.is_collection_superuser(viewer, rfc["collection_id"])
|
||||
or (creator is not None and viewer.gitea_login == creator)
|
||||
or viewer.gitea_login in (owners + arbiters)
|
||||
),
|
||||
@@ -1427,7 +1427,7 @@ def make_router(
|
||||
def _can_resolve_thread(rfc, thread, creator: str | None, viewer) -> bool:
|
||||
if viewer is None:
|
||||
return False
|
||||
if auth.is_project_superuser(viewer, rfc["project_id"]):
|
||||
if auth.is_collection_superuser(viewer, rfc["collection_id"]):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""§22 S2 — collection directory + create-collection.
|
||||
|
||||
GET /api/projects/:id/collections — list the project's visible collections.
|
||||
GET /api/projects/:id/collections/:cid — one collection's settings.
|
||||
POST /api/projects/:id/collections — create a collection. Authorized by a
|
||||
deployment owner/admin (S2; scoped
|
||||
{owner, contributor} roles at the
|
||||
collection axis land in S3). The bot
|
||||
commits a `.collection.yaml` to the
|
||||
content repo, then the registry mirror
|
||||
upserts the collections row — §22.2
|
||||
keeps the registry the source of truth.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import (
|
||||
auth,
|
||||
collections as collections_mod,
|
||||
projects as projects_mod,
|
||||
registry as registry_mod,
|
||||
)
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||
|
||||
|
||||
class CreateCollectionBody(BaseModel):
|
||||
collection_id: str
|
||||
type: str
|
||||
name: str | None = None
|
||||
visibility: str | None = None
|
||||
initial_state: str | None = None
|
||||
|
||||
|
||||
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/projects/{project_id}/collections")
|
||||
async def list_cols(project_id: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
# §22.5 read gate: a gated project 404s a non-member.
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
# §22.5 (S3): the directory is viewer-aware — a hidden/gated collection
|
||||
# is listed only for a scope-role holder who can read it; `unlisted` is
|
||||
# omitted from enumeration for everyone (link-only).
|
||||
items = [
|
||||
c
|
||||
for c in collections_mod.list_collections(project_id, include_unlisted=True)
|
||||
if c["visibility"] != "unlisted" and auth.can_read_collection(viewer, c["id"])
|
||||
]
|
||||
return {"items": items}
|
||||
|
||||
@router.get("/api/projects/{project_id}/collections/{collection_id}")
|
||||
async def get_col(project_id: str, collection_id: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
col = collections_mod.get_collection(collection_id)
|
||||
if col is None or col["project_id"] != project_id:
|
||||
raise HTTPException(404, "Not found")
|
||||
# §22.5 (S3): a hidden/gated collection 404s a non-scope-role viewer.
|
||||
auth.require_collection_readable(viewer, collection_id)
|
||||
return col
|
||||
|
||||
@router.post("/api/projects/{project_id}/collections")
|
||||
async def create_col(
|
||||
project_id: str, body: CreateCollectionBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
# §B.1 (S3) authority: a deployment owner/admin or a project/global-scope
|
||||
# grant holder (Owner or RFC Contributor) may create a collection. The
|
||||
# read gate runs first so a gated project 404s a non-member.
|
||||
user = auth.require_contributor(request)
|
||||
auth.require_project_readable(user, project_id)
|
||||
if not auth.can_create_collection(user, project_id):
|
||||
raise HTTPException(403, "You may not create collections in this project")
|
||||
cid = body.collection_id.strip().lower()
|
||||
if not _SLUG_RE.match(cid) or cid == "default":
|
||||
raise HTTPException(422, "collection id must be a slug and not 'default'")
|
||||
if body.type not in registry_mod.VALID_TYPES:
|
||||
raise HTTPException(422, f"invalid type {body.type!r}")
|
||||
if body.visibility is not None:
|
||||
if body.visibility not in registry_mod.VALID_VISIBILITY:
|
||||
raise HTTPException(422, f"invalid visibility {body.visibility!r}")
|
||||
# §22.5 (S3) strictness: a collection may be set only as strict or
|
||||
# stricter than its project — never more public.
|
||||
pvis = auth.project_visibility(project_id)
|
||||
if auth.visibility_rank(body.visibility) < auth.visibility_rank(pvis):
|
||||
raise HTTPException(
|
||||
422,
|
||||
f"collection visibility {body.visibility!r} is looser than "
|
||||
f"the project's {pvis!r}; a collection may only narrow it",
|
||||
)
|
||||
if body.initial_state is not None and body.initial_state not in registry_mod.VALID_INITIAL_STATE:
|
||||
raise HTTPException(422, f"invalid initial_state {body.initial_state!r}")
|
||||
if collections_mod.get_collection(cid) is not None:
|
||||
raise HTTPException(409, f"collection `{cid}` already exists")
|
||||
content_repo = projects_mod.content_repo(project_id)
|
||||
if not content_repo:
|
||||
raise HTTPException(409, "project has no content repo")
|
||||
|
||||
manifest: dict[str, Any] = {"type": body.type}
|
||||
if body.name:
|
||||
manifest["name"] = body.name
|
||||
if body.visibility:
|
||||
manifest["visibility"] = body.visibility
|
||||
if body.initial_state:
|
||||
manifest["initial_state"] = body.initial_state
|
||||
manifest_yaml = yaml.safe_dump(manifest, sort_keys=False)
|
||||
|
||||
try:
|
||||
await bot.create_collection(
|
||||
user.as_actor(),
|
||||
org=config.gitea_org,
|
||||
content_repo=content_repo,
|
||||
collection_id=cid,
|
||||
manifest_yaml=manifest_yaml,
|
||||
)
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
|
||||
# §22.2: re-read the registry so the new manifest becomes a row.
|
||||
await registry_mod.refresh_registry(config, gitea)
|
||||
col = collections_mod.get_collection(cid)
|
||||
if col is None:
|
||||
raise HTTPException(500, "collection committed but not mirrored")
|
||||
return col
|
||||
|
||||
return router
|
||||
@@ -91,7 +91,7 @@ def _viewer_relationship(viewer, slug: str) -> str | None:
|
||||
"""Why this viewer can't *request* to contribute — or None if they can.
|
||||
Owners/admins already have the RFC; existing collaborators are already
|
||||
in. Both get a clear 409 rather than a useless self-request."""
|
||||
if auth.is_rfc_owner(viewer, slug) or auth.is_project_superuser(viewer, auth.project_of_rfc(slug)):
|
||||
if auth.is_rfc_owner(viewer, slug) or auth.is_collection_superuser(viewer, auth.collection_of_rfc(slug)):
|
||||
return "You already own or administer this RFC."
|
||||
if auth.is_rfc_collaborator(viewer, slug):
|
||||
return "You're already a collaborator on this RFC."
|
||||
|
||||
@@ -312,7 +312,7 @@ def _ensure_discussion_thread(slug: str, viewer) -> int:
|
||||
def _can_resolve(rfc, thread, viewer) -> bool:
|
||||
if viewer is None:
|
||||
return False
|
||||
if auth.is_project_superuser(viewer, rfc["project_id"]):
|
||||
if auth.is_collection_superuser(viewer, rfc["collection_id"]):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
|
||||
@@ -218,7 +218,7 @@ def make_router(
|
||||
can_merge = (
|
||||
viewer is not None
|
||||
and (
|
||||
auth.is_project_superuser(viewer, rfc["project_id"])
|
||||
auth.is_collection_superuser(viewer, rfc["collection_id"])
|
||||
or viewer.gitea_login in owners
|
||||
or viewer.gitea_login in arbiters
|
||||
)
|
||||
@@ -795,7 +795,7 @@ def _can_graduate(rfc, viewer) -> bool:
|
||||
if viewer is None:
|
||||
return False
|
||||
# §6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters.
|
||||
if auth.is_project_superuser(viewer, rfc["project_id"]):
|
||||
if auth.is_collection_superuser(viewer, rfc["collection_id"]):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
|
||||
@@ -786,7 +786,7 @@ def _can_merge(rfc, viewer) -> bool:
|
||||
"""§6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters."""
|
||||
if viewer is None:
|
||||
return False
|
||||
if auth.is_project_superuser(viewer, rfc["project_id"]):
|
||||
if auth.is_collection_superuser(viewer, rfc["collection_id"]):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
|
||||
+265
-51
@@ -16,6 +16,7 @@ from typing import Any
|
||||
import httpx
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from . import collections as collections_mod
|
||||
from . import db
|
||||
from .bot import Actor
|
||||
from .config import Config
|
||||
@@ -336,26 +337,37 @@ def project_visibility(project_id: str) -> str:
|
||||
return row["visibility"] or "gated"
|
||||
|
||||
|
||||
def project_member_role(user: SessionUser | None, project_id: str) -> str | None:
|
||||
"""The user's *explicit* §22.6 membership role at this project, or None.
|
||||
def _is_default_project(project_id: str) -> bool:
|
||||
"""True iff `project_id` owns the migration-seeded `default` collection — the
|
||||
deployment's primary project (§22.13), whatever its configured id. Only there
|
||||
do M2's role rows (which migrated to collection scope `default`) stand in for
|
||||
project-level authority."""
|
||||
return collections_mod.project_of_collection(collections_mod.DEFAULT_COLLECTION_ID) == project_id
|
||||
|
||||
§22 three-tier (S1): M2's `project_members` rows migrated into the unified
|
||||
`memberships` table at the project's default collection (and the project
|
||||
tier is freshly grantable). The unified `{owner, contributor}` roles are
|
||||
mapped back to the legacy `project_admin`/`project_contributor` strings the
|
||||
S1 project-grain authz still speaks; the four-layer scope resolver lands in
|
||||
S3. Reads the stored grant only (project OR default-collection scope) — it
|
||||
does not fold in the deployment tier or the implicit-on-public baseline."""
|
||||
|
||||
def project_member_role(user: SessionUser | None, project_id: str) -> str | None:
|
||||
"""The user's *project-grain* §22.6 role at this project, or None — the
|
||||
most-permissive of a **global** grant (inherits down to every project) and a
|
||||
**project**-scope grant. Mapped back to the legacy
|
||||
`project_admin`/`project_contributor` strings the project-grain authz speaks.
|
||||
|
||||
Back-compat: on the deployment's *default* project only, M2's rows live at
|
||||
collection scope `default` (§B.3 migration), so a `default` collection-scope
|
||||
grant there is read as project-level too. A collection grant on any other
|
||||
project is NOT project authority — that is the four-layer collection resolver
|
||||
(`effective_scope_role`). Does not fold in the deployment tier
|
||||
(`is_project_superuser` adds it) or the implicit-on-public baseline."""
|
||||
if user is None:
|
||||
return None
|
||||
from . import collections as collections_mod
|
||||
cid = collections_mod.default_collection_id(project_id)
|
||||
clauses = ["scope_type = 'global'", "(scope_type = 'project' AND scope_id = ?)"]
|
||||
params: list = [user.user_id, project_id]
|
||||
if _is_default_project(project_id):
|
||||
clauses.append("(scope_type = 'collection' AND scope_id = ?)")
|
||||
params.append(collections_mod.DEFAULT_COLLECTION_ID)
|
||||
row = db.conn().execute(
|
||||
"SELECT role FROM memberships "
|
||||
"WHERE user_id = ? AND ((scope_type = 'project' AND scope_id = ?) "
|
||||
" OR (scope_type = 'collection' AND scope_id = ?)) "
|
||||
"SELECT role FROM memberships WHERE user_id = ? AND (" + " OR ".join(clauses) + ") "
|
||||
"ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1",
|
||||
(user.user_id, project_id, cid),
|
||||
params,
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
@@ -378,6 +390,20 @@ def project_of_rfc(rfc_slug: str) -> str:
|
||||
return row["project_id"] or DEFAULT_PROJECT_ID
|
||||
|
||||
|
||||
def collection_of_rfc(rfc_slug: str) -> str:
|
||||
"""The collection an RFC belongs to (`cached_rfcs.collection_id`). Falls back
|
||||
to the default collection when the slug isn't cached. Mirrors
|
||||
`project_of_rfc`'s first-match semantics; a slug shared across collections is
|
||||
a known routing ambiguity (the RFC-grain helpers take a bare slug) resolved
|
||||
by the collection-qualified routes in later slices."""
|
||||
row = db.conn().execute(
|
||||
"SELECT collection_id FROM cached_rfcs WHERE slug = ?", (rfc_slug,)
|
||||
).fetchone()
|
||||
if row is None or not row["collection_id"]:
|
||||
return collections_mod.DEFAULT_COLLECTION_ID
|
||||
return row["collection_id"]
|
||||
|
||||
|
||||
def is_project_superuser(user: SessionUser | None, project_id: str) -> bool:
|
||||
"""Maximal authority within a project: a deployment owner/admin (superuser
|
||||
in every project, §22.7) or an explicit `project_admin` (§22.6). Both
|
||||
@@ -390,20 +416,30 @@ def is_project_superuser(user: SessionUser | None, project_id: str) -> bool:
|
||||
|
||||
|
||||
def can_read_project(user: SessionUser | None, project_id: str) -> bool:
|
||||
"""The §22.5 visibility gate. `public`/`unlisted` are readable by anyone
|
||||
(anonymous included — `unlisted` is link-only but the link still reads);
|
||||
`gated` is readable only by a deployment owner/admin or a granted project
|
||||
member of any role. Used as the subtractive read gate (a gated project's
|
||||
entries 404 to non-members)."""
|
||||
"""The §22.5 visibility gate at the project grain. `public`/`unlisted` are
|
||||
readable by anyone (anonymous included — `unlisted` is link-only but the link
|
||||
still reads); `gated` is readable only by a deployment owner/admin or a
|
||||
holder of any scope grant reaching the project — a global grant, a project
|
||||
grant, or membership at *any* collection within it (seeing a collection
|
||||
implies seeing its project). Used as the subtractive read gate (a gated
|
||||
project's entries 404 to non-members)."""
|
||||
vis = project_visibility(project_id)
|
||||
if vis in ("public", "unlisted"):
|
||||
return True
|
||||
# gated — members + superusers only, subject to the §6 admission floor.
|
||||
# gated — scope-role holders + superusers only, subject to the §6 floor.
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
|
||||
return True
|
||||
return project_member_role(user, project_id) is not None
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM memberships m WHERE m.user_id = ? AND ("
|
||||
" m.scope_type = 'global'"
|
||||
" OR (m.scope_type = 'project' AND m.scope_id = ?)"
|
||||
" OR (m.scope_type = 'collection' AND m.scope_id IN "
|
||||
" (SELECT id FROM collections WHERE project_id = ?))) LIMIT 1",
|
||||
(user.user_id, project_id, project_id),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def require_project_readable(user: SessionUser | None, project_id: str) -> None:
|
||||
@@ -465,6 +501,187 @@ def visible_project_ids(user: SessionUser | None) -> list[str]:
|
||||
return [r["id"] for r in rows if can_read_project(user, r["id"])]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# §22 three-tier — S3. The four-layer scope-role resolver (§B.2) and the
|
||||
# collection-grain visibility gate.
|
||||
#
|
||||
# A grant attaches the unified role {owner, contributor} at a scope: global,
|
||||
# project, or collection (§B.1). Grants inherit downward, are additive, and
|
||||
# admit no negative override (§B.2). Effective authority over a *collection* is
|
||||
# the most-permissive union of the layers reaching it:
|
||||
#
|
||||
# global (users.role owner/admin ∪ memberships scope_type='global')
|
||||
# ∪ project (memberships scope_type='project' at the collection's project)
|
||||
# ∪ collection (memberships scope_type='collection' at the collection)
|
||||
#
|
||||
# minus the §22.5 visibility gate and §6.2 write-mute (subtractive, as today).
|
||||
# Per-entry authority (owners / arbiters / rfc_collaborators) is a distinct,
|
||||
# finer layer the RFC-grain helpers union in beneath collection.
|
||||
#
|
||||
# OPERATOR DECISIONS (S3, session 0076):
|
||||
# * scope-role-primary — a plain granted account (users.role 'contributor', no
|
||||
# membership) is a granted *account*, not a write-everywhere global role.
|
||||
# Write standing comes from an explicit scope grant; the lone exception is the
|
||||
# grandfathered implicit-public baseline below.
|
||||
# * grandfathered baseline — the migration-seeded `default` collection keeps the
|
||||
# pre-three-tier implicit-on-public write baseline (a granted deployment
|
||||
# contributor may propose while it is public), so the N=1 deployment (§22.13)
|
||||
# loses no capability. Every *explicitly-created* collection requires an
|
||||
# explicit scope grant to write.
|
||||
# * hidden-from-public — a `gated` collection is invisible to the public (404,
|
||||
# omitted from the directory) yet visible to any scope-role holder reaching it
|
||||
# (collection/project/global). A collection's visibility may be set only as
|
||||
# strict or stricter than its project's (the rank ordering below).
|
||||
# ===========================================================================
|
||||
|
||||
# The global scope is a single tier per deployment; its grant rows use this
|
||||
# sentinel scope_id (migration 030).
|
||||
GLOBAL_SCOPE_ID = "*"
|
||||
|
||||
# §22.5 visibility strictness on the public-exposure axis: `public` is least
|
||||
# strict, `gated` most strict. A collection may narrow its project's visibility
|
||||
# but never widen it (`rank(collection) >= rank(project)`).
|
||||
_VISIBILITY_RANK = {"public": 0, "unlisted": 1, "gated": 2}
|
||||
|
||||
|
||||
def visibility_rank(visibility: str | None) -> int:
|
||||
"""The strictness rank of a §22.5 visibility (higher = stricter). An unknown
|
||||
value reads as the strictest (`gated`) — the safe default."""
|
||||
return _VISIBILITY_RANK.get(visibility or "", _VISIBILITY_RANK["gated"])
|
||||
|
||||
|
||||
def effective_scope_role(user: SessionUser | None, collection_id: str) -> str | None:
|
||||
"""The most-permissive unified role ({'owner','contributor'}) the user holds
|
||||
over `collection_id`, folding §B.2's global → project → collection layers.
|
||||
Returns None when no scope grant reaches the collection. 'owner' outranks
|
||||
'contributor'; there is no negative override (a parent grant is never
|
||||
subtracted by a child). Subject to the §6 admission floor."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return None
|
||||
# Global tier — a deployment owner/admin is a global Owner (§B.1).
|
||||
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
|
||||
return "owner"
|
||||
pid = collections_mod.project_of_collection(collection_id)
|
||||
row = db.conn().execute(
|
||||
"SELECT role FROM memberships "
|
||||
"WHERE user_id = ? AND ("
|
||||
" scope_type = 'global'"
|
||||
" OR (scope_type = 'project' AND scope_id = ?)"
|
||||
" OR (scope_type = 'collection' AND scope_id = ?)) "
|
||||
"ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1",
|
||||
(user.user_id, pid, collection_id),
|
||||
).fetchone()
|
||||
return row["role"] if row else None
|
||||
|
||||
|
||||
def collection_visibility(collection_id: str) -> str:
|
||||
"""The collection's own §22.5 visibility. A missing row reads as 'gated' —
|
||||
an unknown collection is invisible rather than open."""
|
||||
row = db.conn().execute(
|
||||
"SELECT visibility FROM collections WHERE id = ?", (collection_id,)
|
||||
).fetchone()
|
||||
if row is None or not row["visibility"]:
|
||||
return "gated"
|
||||
return row["visibility"]
|
||||
|
||||
|
||||
def effective_collection_visibility(collection_id: str) -> str:
|
||||
"""The stricter of the collection's own visibility and its project's (§22.5
|
||||
'both gates'). A collection is constrained to be ≥ its project in strictness,
|
||||
but we max() defensively so a misconfigured looser collection can never widen
|
||||
its project's gate."""
|
||||
cvis = collection_visibility(collection_id)
|
||||
pid = collections_mod.project_of_collection(collection_id)
|
||||
pvis = project_visibility(pid) if pid else "gated"
|
||||
return cvis if visibility_rank(cvis) >= visibility_rank(pvis) else pvis
|
||||
|
||||
|
||||
def can_read_collection(user: SessionUser | None, collection_id: str) -> bool:
|
||||
"""§22.5 read/existence gate at the collection grain. `public`/`unlisted`
|
||||
read by anyone (anonymous included — `unlisted` is link-only but the link
|
||||
reads); `gated` ("hidden from public existence") reads only for a scope-role
|
||||
holder over the collection (collection/project/global) or a deployment
|
||||
owner/admin. The subtractive read gate — a gated collection 404s a
|
||||
non-holder, indistinguishable from absent."""
|
||||
vis = effective_collection_visibility(collection_id)
|
||||
if vis in ("public", "unlisted"):
|
||||
return True
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
return effective_scope_role(user, collection_id) is not None
|
||||
|
||||
|
||||
def require_collection_readable(user: SessionUser | None, collection_id: str) -> None:
|
||||
"""Raise 404 when the collection is not readable by this viewer (§22.5: a
|
||||
hidden/gated collection is invisible to non-holders — the shape matches an
|
||||
unknown collection)."""
|
||||
if not can_read_collection(user, collection_id):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
|
||||
def is_collection_superuser(user: SessionUser | None, collection_id: str) -> bool:
|
||||
"""Maximal authority over a collection: an effective scope role of 'owner'
|
||||
reaching it (a collection Owner, a project Owner of its project, a global
|
||||
Owner, or a deployment owner/admin). Subsumes the per-entry owners/arbiters
|
||||
tier within the collection."""
|
||||
return effective_scope_role(user, collection_id) == "owner"
|
||||
|
||||
|
||||
def _has_collection_write_baseline(user: SessionUser | None, collection_id: str) -> bool:
|
||||
"""The grandfathered implicit-on-public write baseline, narrowed to the
|
||||
migration-seeded `default` collection (§22.13 N=1 case). A granted deployment
|
||||
`contributor` keeps its pre-three-tier write standing on the default
|
||||
collection while its effective visibility is public; every explicitly-created
|
||||
collection requires an explicit scope grant (S3 operator decision)."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
if collection_id != collections_mod.DEFAULT_COLLECTION_ID:
|
||||
return False
|
||||
return user.role == "contributor" and effective_collection_visibility(collection_id) == "public"
|
||||
|
||||
|
||||
def can_contribute_in_collection(user: SessionUser | None, collection_id: str) -> bool:
|
||||
"""May the user contribute *new* content to the collection (propose an entry)
|
||||
— the collection-level contribute standing. The union of the scope-role grant
|
||||
(owner/contributor reaching the collection) and the grandfathered default
|
||||
baseline, subject to the visibility read gate."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
if not can_read_collection(user, collection_id):
|
||||
return False
|
||||
if effective_scope_role(user, collection_id) is not None:
|
||||
return True
|
||||
return _has_collection_write_baseline(user, collection_id)
|
||||
|
||||
|
||||
def can_discuss_in_collection(user: SessionUser | None, collection_id: str) -> bool:
|
||||
"""May the user participate in discussion in the collection — the
|
||||
collection-level discuss standing. A superset of contribute for this pass
|
||||
(the read-only viewer tier is deferred, §B.3), so it mirrors
|
||||
`can_contribute_in_collection`."""
|
||||
return can_contribute_in_collection(user, collection_id)
|
||||
|
||||
|
||||
def can_create_collection(user: SessionUser | None, project_id: str) -> bool:
|
||||
"""May the user create a new collection in this project (§B.1)? Creating a
|
||||
collection is a *project-level* action: a deployment owner/admin, or any
|
||||
holder of a project-scope or global-scope grant (Owner OR RFC Contributor —
|
||||
'anyone at the project level with permission to create a collection'). A
|
||||
*collection*-scope grant cannot create sibling collections."""
|
||||
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'"
|
||||
" OR (scope_type = 'project' AND scope_id = ?)) LIMIT 1",
|
||||
(user.user_id, project_id),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
# v0.16.0 (roadmap item #12): per-RFC membership helpers.
|
||||
#
|
||||
# These don't replace `require_contributor` — they layer on top of it for
|
||||
@@ -561,16 +778,14 @@ def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
return False
|
||||
if user.permission_state != "granted":
|
||||
return False
|
||||
pid = project_of_rfc(rfc_slug)
|
||||
# §22.5 visibility gate is subtractive (§22.7) — no capability in a project
|
||||
# the viewer cannot even read.
|
||||
if not can_read_project(user, pid):
|
||||
cid = collection_of_rfc(rfc_slug)
|
||||
# §22.5 visibility gate is subtractive (§22.7) — no capability in a
|
||||
# collection the viewer cannot even read.
|
||||
if not can_read_collection(user, cid):
|
||||
return False
|
||||
# §22.7 union, override grants first — these bypass per-RFC curation
|
||||
# (project_viewer ⊇ discussant; project_admin / deployment superuser ⊇ all).
|
||||
if is_project_superuser(user, pid):
|
||||
return True
|
||||
if project_member_role(user, pid) in ("project_viewer", "project_contributor"):
|
||||
# §B.2 union, scope-role grants first — these bypass per-RFC curation (a
|
||||
# collection/project/global Owner or RFC Contributor ⊇ discussant).
|
||||
if effective_scope_role(user, cid) is not None:
|
||||
return True
|
||||
# per-RFC authority (union term).
|
||||
owners = _rfc_owners_set(rfc_slug)
|
||||
@@ -578,11 +793,11 @@ def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
return True
|
||||
if is_rfc_collaborator(user, rfc_slug, role_in_rfc=None):
|
||||
return True
|
||||
# implicit-public baseline (curation preserved): a granted deployment
|
||||
# contributor on a public project may discuss only while the RFC is
|
||||
# unclaimed. The first §13.1 claim engages the per-RFC gate, mirroring the
|
||||
# grandfathered implicit-public baseline (curation preserved): on the default
|
||||
# collection a granted deployment contributor may discuss only while the RFC
|
||||
# is unclaimed. The first §13.1 claim engages the per-RFC gate, mirroring the
|
||||
# pre-multi-project v0.16.0 contract.
|
||||
if not owners and _has_write_baseline(user, pid):
|
||||
if not owners and _has_collection_write_baseline(user, cid):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -606,14 +821,12 @@ def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
return False
|
||||
if user.permission_state != "granted":
|
||||
return False
|
||||
pid = project_of_rfc(rfc_slug)
|
||||
if not can_read_project(user, pid):
|
||||
cid = collection_of_rfc(rfc_slug)
|
||||
if not can_read_collection(user, cid):
|
||||
return False
|
||||
# §22.7 union, override grants first (project_contributor ⊇
|
||||
# rfc_collaborators(contributor); project_admin / superuser ⊇ all).
|
||||
if is_project_superuser(user, pid):
|
||||
return True
|
||||
if project_member_role(user, pid) == "project_contributor":
|
||||
# §B.2 union, scope-role grants first (a collection/project/global RFC
|
||||
# Contributor ⊇ rfc_collaborators(contributor); an Owner ⊇ all).
|
||||
if effective_scope_role(user, cid) is not None:
|
||||
return True
|
||||
# per-RFC authority (union term). A 'discussant' row is NOT sufficient —
|
||||
# PRs are the higher-privilege surface.
|
||||
@@ -622,9 +835,10 @@ def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
return True
|
||||
if is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor"):
|
||||
return True
|
||||
# implicit-public baseline (curation preserved): until an owner exists, a
|
||||
# granted deployment contributor on a public project may contribute.
|
||||
if not owners and _has_write_baseline(user, pid):
|
||||
# grandfathered implicit-public baseline (curation preserved): until an owner
|
||||
# exists, a granted deployment contributor on the public default collection
|
||||
# may contribute.
|
||||
if not owners and _has_collection_write_baseline(user, cid):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -637,13 +851,13 @@ def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
return False
|
||||
if user.permission_state != "granted":
|
||||
return False
|
||||
pid = project_of_rfc(rfc_slug)
|
||||
if not can_read_project(user, pid):
|
||||
cid = collection_of_rfc(rfc_slug)
|
||||
if not can_read_collection(user, cid):
|
||||
return False
|
||||
# Deployment owner/admin or project_admin (§22.6) may invite; otherwise
|
||||
# only the RFC's frontmatter owner. Per-RFC collaborators and the
|
||||
# implicit-public baseline do not get the invite-others power.
|
||||
if is_project_superuser(user, pid):
|
||||
# An Owner reaching the collection (collection/project/global Owner, or a
|
||||
# deployment owner/admin) may invite; otherwise only the RFC's frontmatter
|
||||
# owner. Per-RFC collaborators and the baseline do not get the invite power.
|
||||
if is_collection_superuser(user, cid):
|
||||
return True
|
||||
return is_rfc_owner(user, rfc_slug)
|
||||
|
||||
|
||||
+42
-3
@@ -163,6 +163,41 @@ class Bot:
|
||||
def __init__(self, gitea: Gitea):
|
||||
self._gitea = gitea
|
||||
|
||||
# ----- Content repo: collection structure (§22 S2) -----
|
||||
|
||||
async def create_collection(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
content_repo: str,
|
||||
collection_id: str,
|
||||
manifest_yaml: str,
|
||||
) -> dict:
|
||||
"""§22 S2: commit `<collection_id>/.collection.yaml` to the content
|
||||
repo's main. A structural admin action — committed straight to main (no
|
||||
PR), like the registry config it feeds; the registry mirror then upserts
|
||||
the collections row (§22.2 keeps the registry the source of truth). Logs
|
||||
an audit row for the §6.5 trail."""
|
||||
path = f"{collection_id}/.collection.yaml"
|
||||
created = await self._gitea.create_file(
|
||||
org,
|
||||
content_repo,
|
||||
path,
|
||||
content=manifest_yaml,
|
||||
message=_stamp_single(f"chore: create collection {collection_id}", actor),
|
||||
branch="main",
|
||||
author_name=actor.display_name,
|
||||
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"create_collection",
|
||||
bot_commit_sha=created.get("commit", {}).get("sha"),
|
||||
details={"collection_id": collection_id, "repo": content_repo},
|
||||
)
|
||||
return created
|
||||
|
||||
# ----- Meta repo: idea PRs (§9.1 / §9.2) -----
|
||||
|
||||
async def open_idea_pr(
|
||||
@@ -175,12 +210,16 @@ class Bot:
|
||||
file_contents: str,
|
||||
pr_title: str,
|
||||
pr_description: str,
|
||||
rfcs_dir: str = "rfcs",
|
||||
) -> dict:
|
||||
"""Per §9.1: open a meta-repo PR adding one file under rfcs/.
|
||||
"""Per §9.1: open a meta-repo PR adding one file under `<rfcs_dir>/`.
|
||||
|
||||
One file per PR keeps idea submissions atomic and conflict-free.
|
||||
The PR title and the file-add commit subject share §9.2's fixed
|
||||
pattern; callers compose `pr_title` as `Propose: <Title>`.
|
||||
pattern; callers compose `pr_title` as `Propose: <Title>`. §22 S2:
|
||||
`rfcs_dir` carries the target collection's `<subfolder>/rfcs` so a
|
||||
propose into a named collection writes under its subfolder; it
|
||||
defaults to `rfcs` (the default collection / shipped behaviour).
|
||||
"""
|
||||
branch = f"propose/{slug}"
|
||||
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
||||
@@ -189,7 +228,7 @@ class Bot:
|
||||
created = await self._gitea.create_file(
|
||||
org,
|
||||
meta_repo,
|
||||
f"rfcs/{slug}.md",
|
||||
f"{rfcs_dir}/{slug}.md",
|
||||
content=file_contents,
|
||||
message=commit_message,
|
||||
branch=branch,
|
||||
|
||||
+26
-11
@@ -54,15 +54,27 @@ async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
|
||||
|
||||
|
||||
async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: Gitea) -> None:
|
||||
# §22 S1: the corpus grain is the collection. The mirror is project-grained
|
||||
# (reads rfcs/ at the repo root = the project's default collection); resolve
|
||||
# that collection once and key cached_rfcs by it.
|
||||
# §22 S2: the corpus grain is the collection. Mirror every collection of the
|
||||
# project from its `<subfolder>/rfcs/` directory, keying cached_rfcs by the
|
||||
# collection id. The default collection (subfolder '') reads `rfcs/` — the
|
||||
# shipped path, unchanged. include_unlisted: the mirror serves every
|
||||
# collection's content regardless of enumeration visibility.
|
||||
from . import collections as collections_mod
|
||||
collection_id = collections_mod.default_collection_id(project_id)
|
||||
for col in collections_mod.list_collections(project_id, include_unlisted=True):
|
||||
await _refresh_collection_corpus(
|
||||
org, project_id, repo, col["id"], col["subfolder"] or "", gitea
|
||||
)
|
||||
|
||||
|
||||
async def _refresh_collection_corpus(
|
||||
org: str, project_id: str, repo: str, collection_id: str, subfolder: str, gitea: Gitea
|
||||
) -> None:
|
||||
rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs"
|
||||
try:
|
||||
files = await gitea.list_dir(org, repo, "rfcs", ref="main")
|
||||
files = await gitea.list_dir(org, repo, rfcs_dir, ref="main")
|
||||
except GiteaError as e:
|
||||
log.warning("refresh_meta_repo: project %s: cannot list rfcs/: %s", project_id, e)
|
||||
log.warning("refresh_meta_repo: %s/%s: cannot list %s: %s",
|
||||
project_id, collection_id, rfcs_dir, e)
|
||||
return
|
||||
|
||||
seen_slugs: set[str] = set()
|
||||
@@ -76,17 +88,19 @@ async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: G
|
||||
try:
|
||||
entry = entry_mod.parse(text)
|
||||
except Exception as parse_err:
|
||||
log.warning("refresh_meta_repo: %s: skipping %s: %s", project_id, f["path"], parse_err)
|
||||
log.warning("refresh_meta_repo: %s/%s: skipping %s: %s",
|
||||
project_id, collection_id, f["path"], parse_err)
|
||||
continue
|
||||
if not entry.slug:
|
||||
log.warning("refresh_meta_repo: %s: skipping %s: missing slug", project_id, f["path"])
|
||||
log.warning("refresh_meta_repo: %s/%s: skipping %s: missing slug",
|
||||
project_id, collection_id, f["path"])
|
||||
continue
|
||||
seen_slugs.add(entry.slug)
|
||||
_upsert_cached_rfc(entry, body_sha=sha, collection_id=collection_id)
|
||||
|
||||
# Entries removed from a project's rfcs/ — the spec keeps withdrawn entries
|
||||
# Entries removed from a collection's rfcs/ — the spec keeps withdrawn entries
|
||||
# as historical record (§3), so this fires only for out-of-band deletes;
|
||||
# leave the row, scoped to this project, for reconciler attention.
|
||||
# leave the row, scoped to this collection, for reconciler attention.
|
||||
existing = {
|
||||
row["slug"]
|
||||
for row in db.conn().execute(
|
||||
@@ -94,7 +108,8 @@ async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: G
|
||||
)
|
||||
}
|
||||
for missing in existing - seen_slugs:
|
||||
log.info("refresh_meta_repo: %s/%s no longer in rfcs/ — leaving cache row", project_id, missing)
|
||||
log.info("refresh_meta_repo: %s/%s/%s no longer present — leaving cache row",
|
||||
project_id, collection_id, missing)
|
||||
|
||||
|
||||
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, collection_id: str = "default") -> None:
|
||||
|
||||
+19
-2
@@ -231,13 +231,30 @@ def apply_registry(doc: RegistryDoc, registry_sha: str, default_id: str) -> None
|
||||
)
|
||||
|
||||
|
||||
def _strictest_visibility(a: str, b: str) -> str:
|
||||
"""The stricter of two §22.5 visibilities on the public-exposure axis
|
||||
(`public` < `unlisted` < `gated`). Used to enforce that a collection is set
|
||||
only as strict or stricter than its project (S3 operator decision)."""
|
||||
rank = {"public": 0, "unlisted": 1, "gated": 2}
|
||||
return a if rank.get(a, 2) >= rank.get(b, 2) else b
|
||||
|
||||
|
||||
def _upsert_named_collection(
|
||||
proj: ProjectEntry, subdir: str, ce: CollectionEntry, sha: str
|
||||
) -> None:
|
||||
"""Upsert one named collection (S2). Type is immutable (§22.4a): a type
|
||||
change against an existing row is refused (logged, not applied). A None
|
||||
manifest visibility inherits the project's visibility."""
|
||||
visibility = ce.visibility or proj.visibility
|
||||
manifest visibility inherits the project's visibility; a manifest that tries
|
||||
to be *looser* than its project is clamped to the project's (S3 strictness:
|
||||
a collection may narrow but never widen its project's visibility)."""
|
||||
requested = ce.visibility or proj.visibility
|
||||
visibility = _strictest_visibility(requested, proj.visibility)
|
||||
if visibility != requested:
|
||||
log.warning(
|
||||
"registry: collection %s visibility %r looser than project %s %r — "
|
||||
"clamped to %r (S3 strictness)",
|
||||
subdir, requested, proj.id, proj.visibility, visibility,
|
||||
)
|
||||
with db.tx() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT type FROM collections WHERE id = ?", (subdir,)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
-- migrate:no-foreign-keys
|
||||
--
|
||||
-- §22 three-tier — S3. Admit a *global*-scope grant to the memberships table.
|
||||
--
|
||||
-- §B.2's resolver folds four layers (global → project → collection → per-entry).
|
||||
-- Migration 029 created `memberships` with scope_type ∈ {project, collection}
|
||||
-- only; the global tier was left to S3. A global grant is how a "global RFC
|
||||
-- Contributor" (a contributor who may propose in every collection of every
|
||||
-- project, distinct from a deployment owner/admin) is represented — see
|
||||
-- docs/design/2026-06-05-three-tier-projects-collections.md §B.2/§B.3 and the
|
||||
-- C.1 "cleo" scenario.
|
||||
--
|
||||
-- SQLite can't ALTER a CHECK constraint in place, so the table is rebuilt by the
|
||||
-- create-copy-drop-rename procedure (the 028/029 pattern). The global scope uses
|
||||
-- a stable sentinel scope_id of '*' (one global tier per deployment); the
|
||||
-- UNIQUE(scope_type, scope_id, user_id) then admits exactly one global grant per
|
||||
-- user, mirroring the project/collection rows.
|
||||
|
||||
CREATE TABLE memberships__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope_type TEXT NOT NULL CHECK (scope_type IN ('global', 'project', 'collection')),
|
||||
scope_id TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'contributor')),
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (scope_type, scope_id, user_id)
|
||||
);
|
||||
INSERT INTO memberships__new (id, scope_type, scope_id, user_id, role, granted_by, granted_at)
|
||||
SELECT id, scope_type, scope_id, user_id, role, granted_by, granted_at FROM memberships;
|
||||
DROP TABLE memberships;
|
||||
ALTER TABLE memberships__new RENAME TO memberships;
|
||||
CREATE INDEX idx_memberships_user ON memberships(user_id);
|
||||
CREATE INDEX idx_memberships_scope ON memberships(scope_type, scope_id);
|
||||
@@ -0,0 +1,83 @@
|
||||
"""§22 S2 — create-collection vertical: a deployment owner/admin POSTs, the bot
|
||||
commits a `.collection.yaml`, and the registry mirror upserts the collections
|
||||
row (registry stays the source of truth)."""
|
||||
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_collection_commits_manifest_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/default/collections",
|
||||
json={"collection_id": "features", "type": "bdd", "name": "Features"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["type"] == "bdd"
|
||||
|
||||
# The bot committed the manifest to the content repo's main.
|
||||
f = fake.files.get(("wiggleverse", "meta", "main", "features/.collection.yaml"))
|
||||
assert f is not None
|
||||
assert "type: bdd" in f["content"]
|
||||
|
||||
# The registry refresh mirrored it into a collections row.
|
||||
row = db.conn().execute(
|
||||
"SELECT type, project_id, subfolder FROM collections WHERE id='features'"
|
||||
).fetchone()
|
||||
assert (row["type"], row["project_id"], row["subfolder"]) == ("bdd", "default", "features")
|
||||
|
||||
# It is now navigable via the directory + scoped serve.
|
||||
items = client.get("/api/projects/default/collections").json()["items"]
|
||||
assert any(c["id"] == "features" for c in items)
|
||||
assert client.get("/api/projects/default/collections/features/rfcs").status_code == 200
|
||||
|
||||
|
||||
def test_create_collection_requires_admin(app_with_fake_gitea):
|
||||
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/default/collections",
|
||||
json={"collection_id": "x", "type": "bdd"})
|
||||
assert r.status_code in (401, 403)
|
||||
|
||||
|
||||
def test_create_collection_anonymous_rejected(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/projects/default/collections",
|
||||
json={"collection_id": "x", "type": "bdd"})
|
||||
assert r.status_code in (401, 403)
|
||||
|
||||
|
||||
def test_create_collection_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/default/collections",
|
||||
json={"collection_id": "features", "type": "bdd"})
|
||||
assert ok.status_code == 200, ok.text
|
||||
dup = client.post("/api/projects/default/collections",
|
||||
json={"collection_id": "features", "type": "bdd"})
|
||||
assert dup.status_code == 409
|
||||
|
||||
|
||||
def test_create_collection_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/default/collections",
|
||||
json={"collection_id": "default", "type": "bdd"})
|
||||
assert r.status_code == 422
|
||||
@@ -0,0 +1,164 @@
|
||||
"""§22 S2 — collection-grained corpus mirror + collection-scoped serve/propose.
|
||||
|
||||
The mirror test drives cache.refresh_meta_repo against an in-memory content repo
|
||||
holding entries under both the default `rfcs/` and a named collection's
|
||||
`features/rfcs/`, and asserts cached_rfcs is keyed by the right collection_id."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from app import cache, db
|
||||
from app.config import Config
|
||||
|
||||
|
||||
def _db() -> Config:
|
||||
cfg = Config(
|
||||
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="wiggleverse",
|
||||
registry_repo="registry", oauth_client_id="x",
|
||||
oauth_client_secret="x", app_url="x", secret_key="x",
|
||||
database_path=Path(tempfile.mkdtemp(prefix="colserve-")) / "t.db",
|
||||
owner_gitea_login="x", webhook_secret="x",
|
||||
)
|
||||
db.run_migrations(cfg)
|
||||
if db._CONN is not None:
|
||||
db._CONN.close()
|
||||
db._CONN = None
|
||||
db.init(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
class _CorpusGitea:
|
||||
"""A content repo modelled as a flat {path: text} map, listing files under a
|
||||
directory prefix and reading them back."""
|
||||
|
||||
def __init__(self, tree: dict[str, str]):
|
||||
self._tree = tree
|
||||
|
||||
async def list_dir(self, org, repo, path, ref="main"):
|
||||
out = []
|
||||
prefix = (path.rstrip("/") + "/") if path else ""
|
||||
for p in self._tree:
|
||||
if p.startswith(prefix) and "/" not in p[len(prefix):]:
|
||||
out.append({"type": "file", "name": p.split("/")[-1], "path": p})
|
||||
return out
|
||||
|
||||
async def read_file(self, org, repo, path, ref="main"):
|
||||
t = self._tree.get(path)
|
||||
return (t, "sha-" + path) if t is not None else None
|
||||
|
||||
|
||||
def _entry_md(slug, title):
|
||||
return f"---\nslug: {slug}\ntitle: {title}\nstate: active\n---\nbody\n"
|
||||
|
||||
|
||||
def _seed_project_with_two_collections():
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
|
||||
"VALUES ('ohm','Ohm','ohm-rfc','public', datetime('now'))")
|
||||
for cid, sub in [("default", ""), ("features", "features")]:
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, initial_state, "
|
||||
"visibility, created_at, updated_at) VALUES (?, 'ohm','document',?, "
|
||||
"'super-draft','public', datetime('now'), datetime('now'))", (cid, sub))
|
||||
|
||||
|
||||
def test_mirror_keys_entries_by_collection():
|
||||
cfg = _db()
|
||||
_seed_project_with_two_collections()
|
||||
gitea = _CorpusGitea({
|
||||
"rfcs/a.md": _entry_md("a", "Default A"),
|
||||
"features/rfcs/b.md": _entry_md("b", "Feature B"),
|
||||
})
|
||||
asyncio.run(cache.refresh_meta_repo(cfg, gitea))
|
||||
got = {(r["collection_id"], r["slug"]) for r in
|
||||
db.conn().execute("SELECT collection_id, slug FROM cached_rfcs")}
|
||||
assert got == {("default", "a"), ("features", "b")}
|
||||
|
||||
|
||||
# --- collection-scoped serve + propose (full app) -----------------------------
|
||||
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
from test_propose_vertical import ( # noqa: E402,F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def _add_features_collection(content_repo="meta"):
|
||||
"""Add a named 'features' collection (subfolder 'features') under the seeded
|
||||
default project, plus a single entry under features/rfcs/ in the db cache."""
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, "
|
||||
"initial_state, visibility, name, created_at, updated_at) VALUES "
|
||||
"('features','default','document','features','super-draft','public','Features', "
|
||||
"datetime('now'), datetime('now'))")
|
||||
|
||||
|
||||
def test_scoped_list_returns_only_that_collection(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_features_collection()
|
||||
# Seed one entry under each collection's rfcs dir + mirror them in.
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/a.md")] = {
|
||||
"content": _entry_md("a", "Default A"), "sha": "sa"}
|
||||
fake.files[("wiggleverse", "meta", "main", "features/rfcs/b.md")] = {
|
||||
"content": _entry_md("b", "Feature B"), "sha": "sb"}
|
||||
from app import cache as cache_mod, gitea as gitea_mod
|
||||
from app.config import load_config
|
||||
cfg = load_config()
|
||||
asyncio.run(cache_mod.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
|
||||
|
||||
r = client.get("/api/projects/default/collections/features/rfcs")
|
||||
assert r.status_code == 200, r.text
|
||||
assert [i["slug"] for i in r.json()["items"]] == ["b"]
|
||||
# The default collection still serves only its own entry.
|
||||
r2 = client.get("/api/projects/default/collections/default/rfcs")
|
||||
assert [i["slug"] for i in r2.json()["items"]] == ["a"]
|
||||
|
||||
|
||||
def test_scoped_propose_writes_into_collection_subfolder(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_features_collection()
|
||||
provision_user_row(user_id=3, login="alice", role="contributor")
|
||||
# §22 S3: an explicitly-created collection requires an explicit scope
|
||||
# grant to write (the grandfathered baseline covers only `default`).
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('collection', 'features', 3, 'contributor')")
|
||||
sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice",
|
||||
role="contributor", email="alice@test")
|
||||
r = client.post(
|
||||
"/api/projects/default/collections/features/rfcs/propose",
|
||||
json={"title": "New B", "slug": "newb", "pitch": "x", "tags": []})
|
||||
assert r.status_code == 200, r.text
|
||||
# The bot wrote the entry under features/rfcs/, not rfcs/.
|
||||
keys = {(k[1], k[3]) for k in fake.files
|
||||
if k[1] == "meta" and k[3].endswith("newb.md")}
|
||||
assert ("meta", "features/rfcs/newb.md") in keys
|
||||
|
||||
|
||||
def test_scoped_routes_404_for_collection_outside_project(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/projects/default/collections/nope/rfcs")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_s2_anonymous_empty_public_collection(app_with_fake_gitea):
|
||||
"""C3.6 (@S2): a public collection with no entries; an anonymous visitor
|
||||
lands on its catalog → an empty catalog (200, no items), and the propose
|
||||
action is not available to them (the propose route rejects anonymous)."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_features_collection() # public, no entries
|
||||
# Anonymous (no session cookie) reads the empty catalog — 200, [].
|
||||
r = client.get("/api/projects/default/collections/features/rfcs")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["items"] == []
|
||||
# No propose action for an anonymous visitor.
|
||||
r2 = client.post(
|
||||
"/api/projects/default/collections/features/rfcs/propose",
|
||||
json={"title": "X", "slug": "x", "pitch": "p", "tags": []})
|
||||
assert r2.status_code == 401
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Migration 030 — the global-scope grant (§22 three-tier S3).
|
||||
|
||||
Proves: `memberships.scope_type` now admits 'global' alongside 'project' and
|
||||
'collection' (§B.2's four-layer resolver), existing rows survive the rebuild,
|
||||
and the UNIQUE(scope_type, scope_id, user_id) shape is preserved.
|
||||
Template: test_migration_029_collections.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, path):
|
||||
self.database_path = path
|
||||
|
||||
|
||||
def _fresh_db():
|
||||
d = tempfile.mkdtemp()
|
||||
path = Path(d) / "t.db"
|
||||
db.run_migrations(_Cfg(str(path)))
|
||||
return db.connect(str(path))
|
||||
|
||||
|
||||
def _add_user(conn, uid, login):
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, gitea_id, gitea_login, display_name, role) "
|
||||
"VALUES (?, ?, ?, ?, 'contributor')",
|
||||
(uid, uid, login, login.capitalize()),
|
||||
)
|
||||
|
||||
|
||||
def test_global_scope_type_is_accepted():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "cleo")
|
||||
# global grant — the new tier — is accepted.
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('global', '*', 1, 'contributor')"
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT scope_type, scope_id, role FROM memberships WHERE user_id = 1"
|
||||
).fetchone()
|
||||
assert row["scope_type"] == "global"
|
||||
assert row["scope_id"] == "*"
|
||||
assert row["role"] == "contributor"
|
||||
|
||||
|
||||
def test_project_and_collection_scopes_still_accepted():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "ben")
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('project', 'default', 1, 'owner')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('collection', 'default', 1, 'contributor')"
|
||||
)
|
||||
n = conn.execute("SELECT COUNT(*) AS n FROM memberships WHERE user_id = 1").fetchone()["n"]
|
||||
assert n == 2
|
||||
|
||||
|
||||
def test_unknown_scope_type_still_rejected():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "x")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('deployment', '*', 1, 'owner')"
|
||||
)
|
||||
|
||||
|
||||
def test_one_global_grant_per_user():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "cleo")
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('global', '*', 1, 'contributor')"
|
||||
)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('global', '*', 1, 'owner')"
|
||||
)
|
||||
@@ -28,6 +28,13 @@ def test_propose_into_second_project_lands_scoped(app_with_fake_gitea):
|
||||
with TestClient(app) as client:
|
||||
_register_ecomm(fake)
|
||||
provision_user_row(user_id=3, login="alice", role="contributor")
|
||||
# §22 S3: the grandfathered implicit-public baseline covers only the N=1
|
||||
# `default` collection; a second project requires an explicit scope grant
|
||||
# to write. Grant alice contributor at the ecomm project.
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('project', 'ecomm', 3, 'contributor')")
|
||||
sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice",
|
||||
role="contributor", email="alice@test")
|
||||
r = client.post("/api/projects/ecomm/rfcs/propose", json={
|
||||
|
||||
@@ -90,6 +90,28 @@ class FakeGitea:
|
||||
self._commit_counter += 1
|
||||
return f"sha{self._commit_counter:04d}"
|
||||
|
||||
def _dir_listing(self, owner, repo, ref, dirpath):
|
||||
"""Children directly under `dirpath` on (owner, repo, ref): files as
|
||||
`type: file` and immediate subdirectories as `type: dir` (the shape real
|
||||
Gitea returns for a contents listing)."""
|
||||
prefix = (dirpath.rstrip("/") + "/") if dirpath else ""
|
||||
files: dict[str, dict] = {}
|
||||
dirs: set[str] = set()
|
||||
for (o, r, br, p), data in self.files.items():
|
||||
if (o, r, br) != (owner, repo, ref) or not p.startswith(prefix):
|
||||
continue
|
||||
rest = p[len(prefix):]
|
||||
if "/" in rest:
|
||||
dirs.add(rest.split("/", 1)[0])
|
||||
elif rest:
|
||||
files[p] = data
|
||||
children = [{"name": n, "path": prefix + n, "type": "dir"} for n in sorted(dirs)]
|
||||
children += [
|
||||
{"name": p.rsplit("/", 1)[-1], "path": p, "type": "file", "sha": d["sha"]}
|
||||
for p, d in sorted(files.items())
|
||||
]
|
||||
return children
|
||||
|
||||
def _enrich_pr(self, owner: str, repo: str, pr: dict) -> dict:
|
||||
"""Return the PR with mergeability fields filled in.
|
||||
|
||||
@@ -227,6 +249,16 @@ class FakeGitea:
|
||||
}
|
||||
return httpx.Response(201, json={"name": new})
|
||||
|
||||
# GET /repos/{owner}/{repo}/contents (root listing, empty path). §22 S2:
|
||||
# the registry mirror walks the content-repo root for collection
|
||||
# subfolders, so the simulator models a root directory listing that
|
||||
# surfaces both file and `dir` children.
|
||||
m_root = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/?", path)
|
||||
if method == "GET" and m_root:
|
||||
owner, repo = m_root.groups()
|
||||
ref = request.url.params.get("ref", "main")
|
||||
return httpx.Response(200, json=self._dir_listing(owner, repo, ref, ""))
|
||||
|
||||
# GET /repos/{owner}/{repo}/contents/{path}?ref=...
|
||||
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path)
|
||||
if method == "GET" and m:
|
||||
@@ -242,17 +274,8 @@ class FakeGitea:
|
||||
"sha": f["sha"],
|
||||
"content": base64.b64encode(f["content"].encode()).decode(),
|
||||
})
|
||||
# Directory listing
|
||||
prefix = fpath.rstrip("/") + "/"
|
||||
children = []
|
||||
for (o, r, br, p), data in self.files.items():
|
||||
if (o, r, br) == (owner, repo, ref) and p.startswith(prefix) and "/" not in p[len(prefix):]:
|
||||
children.append({
|
||||
"name": p.rsplit("/", 1)[-1],
|
||||
"path": p,
|
||||
"type": "file",
|
||||
"sha": data["sha"],
|
||||
})
|
||||
# Directory listing — both file and subdir children.
|
||||
children = self._dir_listing(owner, repo, ref, fpath)
|
||||
if children:
|
||||
return httpx.Response(200, json=children)
|
||||
return httpx.Response(404, json={"message": "not found"})
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Slice S3 — scope-role enforcement + collection-grain visibility (@S3).
|
||||
|
||||
The acceptance gate for S3 is "every Part C.1 scenario passes" (the design doc
|
||||
docs/design/2026-06-05-three-tier-projects-collections.md, §C.1, tagged @S3) plus
|
||||
the operator's S3 visibility requirements (a collection settable public/hidden;
|
||||
hidden = visible to project/global scope contributors but not the public; a
|
||||
collection's visibility may be set only as strict or stricter than its project).
|
||||
|
||||
The §B.2 resolver folds four layers — global → project → collection → per-entry —
|
||||
most-permissively, with no negative override. The scenarios below are exercised
|
||||
directly against the resolver/gate helpers, and the visibility ones additionally
|
||||
through the HTTP surface.
|
||||
|
||||
Background (C.1): a deployment with a project "ohm" owning collections "model"
|
||||
(document) and "features" (bdd); a second project "acme" with collection
|
||||
"specs". Plus a hidden ("gated") collection "secret" under ohm for the
|
||||
hidden-from-public scenarios.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _su(user_id: int, login: str, role: str = "contributor", *, state: str = "granted"):
|
||||
from app import auth
|
||||
|
||||
return auth.SessionUser(
|
||||
user_id=user_id, gitea_id=user_id, gitea_login=login,
|
||||
display_name=login.capitalize(), email=f"{login}@test", avatar_url="",
|
||||
role=role, permission_state=state,
|
||||
)
|
||||
|
||||
|
||||
def _project(pid: str, visibility: str = "public", content_repo: str = "meta") -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
(pid, pid.capitalize(), content_repo, visibility),
|
||||
)
|
||||
|
||||
|
||||
def _collection(cid: str, project_id: str, *, ctype: str = "document",
|
||||
visibility: str = "public", subfolder: str | None = None) -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections "
|
||||
"(id, project_id, type, subfolder, initial_state, visibility, name, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, 'super-draft', ?, ?, datetime('now'), datetime('now'))",
|
||||
(cid, project_id, ctype, subfolder if subfolder is not None else cid,
|
||||
visibility, cid.capitalize()),
|
||||
)
|
||||
|
||||
|
||||
def _grant(scope_type: str, scope_id: str, user_id: int, role: str) -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(scope_type, scope_id, user_id, role),
|
||||
)
|
||||
|
||||
|
||||
def _seed_world() -> None:
|
||||
"""The C.1 background plus a hidden collection and a second project."""
|
||||
_project("ohm", "public")
|
||||
_collection("ohm", "ohm", subfolder="") # ohm's structural default
|
||||
_collection("model", "ohm", ctype="document")
|
||||
_collection("features", "ohm", ctype="bdd")
|
||||
_collection("secret", "ohm", visibility="gated") # hidden from public
|
||||
_project("acme", "public")
|
||||
_collection("specs", "acme", ctype="specification")
|
||||
# the cast
|
||||
for uid, login in [(1, "ada"), (2, "ben"), (3, "cleo"), (4, "dan"),
|
||||
(5, "eve"), (6, "fay"), (7, "gil"), (8, "hana")]:
|
||||
provision_user_row(user_id=uid, login=login, role="contributor")
|
||||
_grant("collection", "model", 1, "contributor") # ada
|
||||
_grant("project", "ohm", 2, "contributor") # ben
|
||||
_grant("global", "*", 3, "contributor") # cleo
|
||||
_grant("collection", "features", 4, "owner") # dan
|
||||
_grant("project", "ohm", 5, "owner") # eve
|
||||
_grant("collection", "model", 6, "contributor") # fay (+ project owner below)
|
||||
_grant("project", "ohm", 6, "owner") # fay
|
||||
_grant("project", "ohm", 7, "contributor") # gil
|
||||
# hana (8): no grant.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C.1 — role usage: inheritance and the most-permissive union
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_c1_1_collection_contributor_proposes_only_in_that_collection(app_with_fake_gitea):
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_seed_world()
|
||||
ada = _su(1, "ada")
|
||||
# may submit a new entry in ohm/model
|
||||
assert auth.can_contribute_in_collection(ada, "model") is True
|
||||
# ohm/features is read-only and propose is not offered
|
||||
assert auth.can_read_collection(ada, "features") is True
|
||||
assert auth.can_contribute_in_collection(ada, "features") is False
|
||||
|
||||
|
||||
def test_c1_2_project_contributor_proposes_in_every_collection(app_with_fake_gitea):
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_seed_world()
|
||||
ben = _su(2, "ben")
|
||||
assert auth.can_contribute_in_collection(ben, "model") is True
|
||||
assert auth.can_contribute_in_collection(ben, "features") is True
|
||||
# a collection added later is writable with no new grant
|
||||
_collection("roadmap", "ohm", ctype="document")
|
||||
assert auth.can_contribute_in_collection(ben, "roadmap") is True
|
||||
|
||||
|
||||
def test_c1_3_global_contributor_proposes_everywhere(app_with_fake_gitea):
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_seed_world()
|
||||
cleo = _su(3, "cleo")
|
||||
assert auth.can_contribute_in_collection(cleo, "model") is True
|
||||
assert auth.can_contribute_in_collection(cleo, "specs") is True # acme
|
||||
|
||||
|
||||
def test_c1_4_collection_owner_administers_one_collection_only(app_with_fake_gitea):
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_seed_world()
|
||||
dan = _su(4, "dan")
|
||||
# graduate / mark-reviewed / manage membership in ohm/features
|
||||
assert auth.is_collection_superuser(dan, "features") is True
|
||||
# but not change ohm project settings
|
||||
assert auth.is_project_superuser(dan, "ohm") is False
|
||||
assert auth.can_create_collection(dan, "ohm") is False
|
||||
# and not act on entries in ohm/model
|
||||
assert auth.is_collection_superuser(dan, "model") is False
|
||||
assert auth.can_contribute_in_collection(dan, "model") is False
|
||||
|
||||
|
||||
def test_c1_5_project_owner_administers_all_collections_and_creates_more(app_with_fake_gitea):
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_seed_world()
|
||||
eve = _su(5, "eve")
|
||||
assert auth.is_collection_superuser(eve, "model") is True
|
||||
assert auth.is_collection_superuser(eve, "features") is True
|
||||
assert auth.is_project_superuser(eve, "ohm") is True # edit project settings
|
||||
assert auth.can_create_collection(eve, "ohm") is True # create a new collection
|
||||
|
||||
|
||||
def test_c1_6_most_permissive_union_higher_grant_wins(app_with_fake_gitea):
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_seed_world()
|
||||
fay = _su(6, "fay")
|
||||
# collection RFC Contributor at model + project Owner at ohm → acts as Owner in model
|
||||
assert auth.effective_scope_role(fay, "model") == "owner"
|
||||
assert auth.is_collection_superuser(fay, "model") is True
|
||||
|
||||
|
||||
def test_c1_7_no_negative_override(app_with_fake_gitea):
|
||||
from app import auth, db
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_seed_world()
|
||||
gil = _su(7, "gil")
|
||||
# gil can propose in ohm/model via the project grant…
|
||||
assert auth.can_contribute_in_collection(gil, "model") is True
|
||||
# …and there is no collection-scope row to remove at model while keeping
|
||||
# the project grant (a child cannot subtract a parent grant).
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM memberships WHERE user_id = 7 AND scope_type = 'collection' AND scope_id = 'model'"
|
||||
).fetchone()
|
||||
assert row is None
|
||||
|
||||
|
||||
def test_c1_8_granted_account_no_role_sees_only_public(app_with_fake_gitea):
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
_seed_world()
|
||||
hana = _su(8, "hana")
|
||||
# may read public collections
|
||||
assert auth.can_read_collection(hana, "model") is True
|
||||
# but is not offered the propose action anywhere (no scope role; the
|
||||
# grandfathered baseline covers only the N=1 `default` collection)
|
||||
assert auth.can_contribute_in_collection(hana, "model") is False
|
||||
assert auth.can_contribute_in_collection(hana, "features") is False
|
||||
assert auth.can_contribute_in_collection(hana, "specs") is False
|
||||
# gated (hidden) collections do not appear for her
|
||||
assert auth.can_read_collection(hana, "secret") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collection-grain visibility — the operator's S3 requirements
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_hidden_collection_invisible_to_public_visible_to_scope_holder(app_with_fake_gitea):
|
||||
"""A gated collection is omitted from the directory and 404s on read for the
|
||||
public, yet is listed + readable for a scope-role contributor."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
# anonymous: the gated 'secret' collection is not listed, and 404s.
|
||||
listed = {c["id"] for c in client.get("/api/projects/ohm/collections").json()["items"]}
|
||||
assert "secret" not in listed
|
||||
assert "model" in listed # public ones still listed
|
||||
assert client.get("/api/projects/ohm/collections/secret").status_code == 404
|
||||
assert client.get("/api/projects/ohm/collections/secret/rfcs").status_code == 404
|
||||
|
||||
# ben (project contributor) sees and reads it.
|
||||
sign_in_as(client, user_id=2, gitea_login="ben", display_name="Ben", role="contributor")
|
||||
listed2 = {c["id"] for c in client.get("/api/projects/ohm/collections").json()["items"]}
|
||||
assert "secret" in listed2
|
||||
assert client.get("/api/projects/ohm/collections/secret").status_code == 200
|
||||
assert client.get("/api/projects/ohm/collections/secret/rfcs").status_code == 200
|
||||
|
||||
# hana (granted, no role) is back to the public view.
|
||||
sign_in_as(client, user_id=8, gitea_login="hana", display_name="Hana", role="contributor")
|
||||
listed3 = {c["id"] for c in client.get("/api/projects/ohm/collections").json()["items"]}
|
||||
assert "secret" not in listed3
|
||||
assert client.get("/api/projects/ohm/collections/secret").status_code == 404
|
||||
|
||||
|
||||
def test_collection_visibility_strictness_validated_at_create(app_with_fake_gitea):
|
||||
"""A collection may be created only as strict or stricter than its project;
|
||||
a looser request is refused (422). On a gated project, a 'public' collection
|
||||
is rejected."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_project("locked", "gated")
|
||||
_collection("locked", "locked", subfolder="", visibility="gated")
|
||||
# eve is a deployment owner here to clear the create-authority gate;
|
||||
# the strictness check fires regardless.
|
||||
provision_user_row(user_id=9, login="root", role="owner")
|
||||
sign_in_as(client, user_id=9, gitea_login="root", display_name="Root", role="owner")
|
||||
r = client.post("/api/projects/locked/collections", json={
|
||||
"collection_id": "wideopen", "type": "document", "visibility": "public",
|
||||
})
|
||||
assert r.status_code == 422, r.text
|
||||
assert "looser" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_create_collection_allowed_for_project_owner_not_plain_contributor(app_with_fake_gitea):
|
||||
"""§B.1: a project-scope Owner may create a collection; a plain granted
|
||||
contributor with no project/global grant may not (403)."""
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
# gil is only a project *contributor* on ohm — per §B.1 a project-scope
|
||||
# contributor CAN create collections (the project-level create
|
||||
# affordance). A collection-scope grant cannot.
|
||||
sign_in_as(client, user_id=7, gitea_login="gil", display_name="Gil", role="contributor")
|
||||
r_ok = client.post("/api/projects/ohm/collections", json={
|
||||
"collection_id": "fromgil", "type": "document", "visibility": "public",
|
||||
})
|
||||
assert r_ok.status_code in (200, 502), r_ok.text # past the authz gate
|
||||
|
||||
# ada holds only a *collection*-scope grant (at model) — no create right.
|
||||
sign_in_as(client, user_id=1, gitea_login="ada", display_name="Ada", role="contributor")
|
||||
r_no = client.post("/api/projects/ohm/collections", json={
|
||||
"collection_id": "fromada", "type": "document", "visibility": "public",
|
||||
})
|
||||
assert r_no.status_code == 403, r_no.text
|
||||
@@ -549,7 +549,8 @@ means the deployment runs and either gains a capability or provably loses none
|
||||
exactly as before, now with a real collection layer and one extra path segment.
|
||||
**Completes:** `@S1` (the single-collection / single-project redirect skips).
|
||||
|
||||
- **S2 — Create & navigate a second collection.** Teach the registry mirror to
|
||||
- **S2 — Create & navigate a second collection.** *(Shipped v0.41.0.)* Teach the
|
||||
registry mirror to
|
||||
read `.collection.yaml`; add the bot-commit-wrapped **create-collection**
|
||||
endpoint (authorized by existing deployment owner/admin for now — the scoped
|
||||
role surface lands in S3); the project collection-directory at `/p/<project>/`;
|
||||
@@ -558,12 +559,26 @@ means the deployment runs and either gains a capability or provably loses none
|
||||
one and it is navigable + proposable. **Completes:** `@S2` (anonymous reader of
|
||||
an empty collection catalog).
|
||||
|
||||
- **S3 — Scope-role enforcement.** The four-layer most-permissive resolver
|
||||
(§B.2) over `{owner, contributor}` grants at `{global, project, collection}`,
|
||||
with grants applied administratively (DB / admin endpoint); every write gate
|
||||
re-checked under the collection axis. **Usable end-state:** a user granted
|
||||
RFC Contributor at a scope can contribute across exactly that subtree, and
|
||||
Owners administer their subtree. **Completes:** `@S3` (all of C.1 — role usage,
|
||||
- **S3 — Scope-role enforcement.** *(Shipped v0.42.0.)* The four-layer
|
||||
most-permissive resolver (§B.2) over `{owner, contributor}` grants at
|
||||
`{global, project, collection}` (migration 030 adds the `global` scope_type),
|
||||
with grants applied administratively (DB / the Owner-authorized create
|
||||
surface); every write gate re-checked under the collection axis. **Plus the
|
||||
operator's S3 visibility requirements:** collection-grain visibility is
|
||||
enforced — a `gated` collection is hidden from the public (404, omitted from
|
||||
the directory) yet visible to scope-role contributors; a collection's
|
||||
visibility may be set only as strict or stricter than its project's
|
||||
(`public` < `unlisted` < `gated`). **Keystone reconciliation (session 0076):**
|
||||
§B.1/§B.3's literal "deployment contributor = global RFC Contributor"
|
||||
contradicted the C.1 "hana" scenario and the M2 implicit-public baseline;
|
||||
resolved as — a plain granted account is a granted *account*, not a
|
||||
write-everywhere global role; "global RFC Contributor" is an explicit
|
||||
`scope_type='global'` grant; the implicit-public write baseline is
|
||||
grandfathered onto the migration-seeded `default` collection only (N=1
|
||||
preserved). *Flag for the SPEC merge (S6): reinterprets §B.1/§B.3.* **Usable
|
||||
end-state:** a user granted RFC Contributor at a scope can contribute across
|
||||
exactly that subtree, Owners administer their subtree, and a collection can be
|
||||
hidden from the public. **Completes:** `@S3` (all of C.1 — role usage,
|
||||
inheritance, union, no-negative-override).
|
||||
|
||||
- **S4 — Invitation surfaces + role-aware empty states.** The invite UI
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.40.0",
|
||||
"version": "0.42.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+16
-9
@@ -15,6 +15,7 @@ import RFCView from './components/RFCView.jsx'
|
||||
import PRView from './components/PRView.jsx'
|
||||
import ProposalView from './components/ProposalView.jsx'
|
||||
import ProposeModal from './components/ProposeModal.jsx'
|
||||
import CollectionDirectory from './components/CollectionDirectory.jsx'
|
||||
import ContributeRequestForm from './components/ContributeRequestForm.jsx'
|
||||
import Landing from './components/Landing.jsx'
|
||||
import Login from './components/Login.jsx'
|
||||
@@ -66,6 +67,10 @@ export default function App() {
|
||||
// right project. Falls back to the deployment default off a project route.
|
||||
const _projMatch = location.pathname.match(/^\/p\/([^/]+)/)
|
||||
const currentProjectId = (_projMatch && _projMatch[1]) || deployment.defaultProjectId
|
||||
// §22 S2 — the collection the viewer is currently in (from the /c/<cid>/ URL
|
||||
// segment), so a propose targets that collection. Falls back to the default.
|
||||
const _colMatch = location.pathname.match(/^\/p\/[^/]+\/c\/([^/]+)/)
|
||||
const currentCollectionId = (_colMatch && _colMatch[1]) || DEFAULT_COLLECTION
|
||||
// #28 Parts 2–3: the LinkedText create/contribute affordances route via
|
||||
// query params so they need no prop-threading from deep in a comment
|
||||
// list. `?propose=<term>` opens the propose modal pre-filled;
|
||||
@@ -360,9 +365,10 @@ export default function App() {
|
||||
/>
|
||||
<main className="main-pane">
|
||||
<Routes>
|
||||
{/* §22 three-tier (C3.7): the project landing redirects into
|
||||
its sole/default collection (S1: the `default` one). */}
|
||||
<Route path="" element={<DefaultCollectionRedirect />} />
|
||||
{/* §22 S2: the project landing is the collection directory —
|
||||
it lists collections, or (C3.7/C3.8) redirects into the
|
||||
sole visible collection when there is exactly one. */}
|
||||
<Route path="" element={<CollectionDirectoryRoute />} />
|
||||
{/* Backcompat: the shipped v0.35.0 corpus URLs without a
|
||||
/c/<collection>/ segment redirect into the default
|
||||
collection, so old bookmarks keep working. */}
|
||||
@@ -389,12 +395,13 @@ export default function App() {
|
||||
viewer={viewer}
|
||||
initialTitle={proposeParam || ''}
|
||||
projectId={currentProjectId}
|
||||
collectionId={currentCollectionId}
|
||||
onClose={() => { setProposeOpen(false); clearParams('propose') }}
|
||||
onSubmitted={({ pr_number }) => {
|
||||
setProposeOpen(false)
|
||||
clearParams('propose')
|
||||
setCatalogVersion(v => v + 1)
|
||||
navigate(proposalPath(currentProjectId, pr_number))
|
||||
navigate(proposalPath(currentProjectId, pr_number, currentCollectionId))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -414,12 +421,12 @@ export default function App() {
|
||||
)
|
||||
}
|
||||
|
||||
// §22 three-tier — corpus redirects mounted under /p/:projectId/*.
|
||||
// C3.7: the project landing skips the (single-collection) directory and lands in
|
||||
// the project's default collection.
|
||||
function DefaultCollectionRedirect() {
|
||||
// §22 S2 — the project landing at /p/:projectId/ is the collection directory.
|
||||
// A tiny wrapper reads the route's projectId and hands it to CollectionDirectory
|
||||
// (which lists collections, or redirects into the sole one — C3.7/C3.8).
|
||||
function CollectionDirectoryRoute() {
|
||||
const { projectId } = useParams()
|
||||
return <Navigate to={`/p/${projectId}/c/${DEFAULT_COLLECTION}/`} replace />
|
||||
return <CollectionDirectory projectId={projectId} />
|
||||
}
|
||||
|
||||
// Backcompat for the shipped v0.35.0 corpus URLs that lacked the
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// §22 S2 — the API client builds collection-scoped URLs when a collection id is
|
||||
// supplied, and falls back to the project/default-collection paths otherwise.
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { listRFCs, getRFC, proposeRFC, listCollections } from './api.js'
|
||||
|
||||
function mockFetch() {
|
||||
const fn = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ items: [] }),
|
||||
}))
|
||||
global.fetch = fn
|
||||
return fn
|
||||
}
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('collection-scoped api URLs', () => {
|
||||
it('listRFCs scopes to a collection when given one', async () => {
|
||||
const f = mockFetch()
|
||||
await listRFCs('ohm', 'features')
|
||||
expect(f).toHaveBeenCalledWith('/api/projects/ohm/collections/features/rfcs')
|
||||
})
|
||||
|
||||
it('listRFCs falls back to the project default path without a collection', async () => {
|
||||
const f = mockFetch()
|
||||
await listRFCs('ohm')
|
||||
expect(f).toHaveBeenCalledWith('/api/projects/ohm/rfcs')
|
||||
})
|
||||
|
||||
it('getRFC scopes to a collection when given one', async () => {
|
||||
const f = mockFetch()
|
||||
await getRFC('ohm', 'login', 'features')
|
||||
expect(f).toHaveBeenCalledWith('/api/projects/ohm/collections/features/rfcs/login')
|
||||
})
|
||||
|
||||
it('proposeRFC targets the collection-scoped propose route', async () => {
|
||||
const f = mockFetch()
|
||||
await proposeRFC('ohm', { title: 'T', slug: 's', pitch: 'p', tags: [], collectionId: 'features' })
|
||||
expect(f.mock.calls[0][0]).toBe('/api/projects/ohm/collections/features/rfcs/propose')
|
||||
})
|
||||
|
||||
it('listCollections hits the project collections route', async () => {
|
||||
const f = mockFetch()
|
||||
await listCollections('ohm')
|
||||
expect(f).toHaveBeenCalledWith('/api/projects/ohm/collections')
|
||||
})
|
||||
})
|
||||
+37
-7
@@ -182,22 +182,50 @@ export async function getProject(projectId) {
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}`))
|
||||
}
|
||||
|
||||
// §22.4 (Plan B): per-project serving. Given a projectId, read the
|
||||
// project-scoped routes so a non-default project's corpus renders; without
|
||||
// one, fall back to the default-project compat path.
|
||||
export async function listRFCs(projectId) {
|
||||
// §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.
|
||||
export async function listRFCs(projectId, collectionId) {
|
||||
if (projectId && collectionId) {
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}/rfcs`))
|
||||
}
|
||||
const url = projectId ? `/api/projects/${projectId}/rfcs` : '/api/rfcs'
|
||||
return jsonOrThrow(await fetch(url))
|
||||
}
|
||||
|
||||
export async function getRFC(projectId, slug) {
|
||||
export async function getRFC(projectId, slug, collectionId) {
|
||||
// Back-compat: getRFC(slug) (one arg) still hits the unscoped default path.
|
||||
if (slug === undefined) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${projectId}`))
|
||||
}
|
||||
if (collectionId) {
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}/rfcs/${slug}`))
|
||||
}
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`))
|
||||
}
|
||||
|
||||
// §22 S2: the collections of a project (for the /p/<project>/ directory).
|
||||
export async function listCollections(projectId) {
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections`))
|
||||
}
|
||||
|
||||
// §22 S2: create-collection (deployment owner/admin). The backend commits a
|
||||
// .collection.yaml and re-mirrors the registry, returning the new collection.
|
||||
export async function createCollection(projectId, { collectionId, type, name, visibility, initialState }) {
|
||||
const res = await fetch(`/api/projects/${projectId}/collections`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
collection_id: collectionId,
|
||||
type,
|
||||
name: name || null,
|
||||
visibility: visibility || null,
|
||||
initial_state: initialState || null,
|
||||
}),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function listProposals(projectId) {
|
||||
const url = projectId ? `/api/projects/${projectId}/proposals` : '/api/proposals'
|
||||
return jsonOrThrow(await fetch(url))
|
||||
@@ -209,8 +237,10 @@ export async function getProposal(prNumber) {
|
||||
|
||||
// §22.4 (Plan B write): propose into a specific project when projectId is
|
||||
// given; else the default-project compat path.
|
||||
export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase }) {
|
||||
const url = projectId ? `/api/projects/${projectId}/rfcs/propose` : '/api/rfcs/propose'
|
||||
export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase, collectionId }) {
|
||||
const url = (projectId && collectionId)
|
||||
? `/api/projects/${projectId}/collections/${collectionId}/rfcs/propose`
|
||||
: (projectId ? `/api/projects/${projectId}/rfcs/propose` : '/api/rfcs/propose')
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { listRFCs, listProposals } from '../api'
|
||||
import { entryPath, proposalPath, useProjectId } from '../lib/entryPaths'
|
||||
import { entryPath, proposalPath, useProjectId, useCollectionId } from '../lib/entryPaths'
|
||||
|
||||
const STATE_CHIPS = [
|
||||
{ id: 'super-draft', label: 'Super-draft' },
|
||||
@@ -32,11 +32,14 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
const [pendingOpen, setPendingOpen] = useState(true)
|
||||
const { slug, prNumber } = useParams()
|
||||
const pid = useProjectId()
|
||||
// §22 S2: the catalog is scoped to the active collection (the `/c/:cid/`
|
||||
// route segment, else the project's default collection).
|
||||
const cid = useCollectionId()
|
||||
|
||||
useEffect(() => {
|
||||
listRFCs(pid).then(d => setRfcs(d.items)).catch(() => setRfcs([]))
|
||||
listRFCs(pid, cid).then(d => setRfcs(d.items)).catch(() => setRfcs([]))
|
||||
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
|
||||
}, [version, pid])
|
||||
}, [version, pid, cid])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase()
|
||||
@@ -105,7 +108,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
return (
|
||||
<Link
|
||||
key={r.slug}
|
||||
to={entryPath(pid, r.slug)}
|
||||
to={entryPath(pid, r.slug, cid)}
|
||||
className={`catalog-row ${isActive ? 'active' : ''} ${isSuper ? 'is-super' : ''}`}
|
||||
>
|
||||
<div className="row-top">
|
||||
@@ -133,7 +136,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
{proposals.map(p => (
|
||||
<Link
|
||||
key={p.pr_number}
|
||||
to={proposalPath(pid, p.pr_number)}
|
||||
to={proposalPath(pid, p.pr_number, cid)}
|
||||
className={`pending-row ${String(prNumber) === String(p.pr_number) ? 'active' : ''}`}
|
||||
>
|
||||
<div>{p.title.replace(/^Propose:\s*/, '')}</div>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// §22 S2 — the project collection directory at `/p/<project>/`. Lists the
|
||||
// project's caller-visible collections as cards linking into each collection's
|
||||
// `/p/<project>/c/<collection>/` home. When exactly one collection is visible
|
||||
// the directory is skipped and we redirect straight into it (the S1 C3.7/C3.8
|
||||
// single-collection UX, preserved). The role-keyed "Create your first
|
||||
// collection" empty state is S4; S2 shows a minimal note when there are none.
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, Navigate } from 'react-router-dom'
|
||||
import { listCollections } from '../api'
|
||||
import { collectionHome } from '../lib/entryPaths'
|
||||
import { entryNoun } from './ProjectLayout.jsx'
|
||||
|
||||
export default function CollectionDirectory({ projectId }) {
|
||||
const [cols, setCols] = useState(null)
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
listCollections(projectId)
|
||||
.then(d => { if (live) setCols(d.items) })
|
||||
.catch(() => { if (live) setCols([]) })
|
||||
return () => { live = false }
|
||||
}, [projectId])
|
||||
|
||||
if (cols === null) {
|
||||
return <main className="chrome-pane"><div className="boot">Loading…</div></main>
|
||||
}
|
||||
// C3.7/C3.8: a single visible collection skips the directory.
|
||||
if (cols.length === 1) {
|
||||
return <Navigate to={collectionHome(projectId, cols[0].id)} replace />
|
||||
}
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
<div className="directory">
|
||||
<h1>Collections</h1>
|
||||
{cols.length === 0 ? (
|
||||
<p className="directory-tagline">No collections yet.</p>
|
||||
) : (
|
||||
<ul className="directory-list">
|
||||
{cols.map(c => (
|
||||
<li key={c.id} className="directory-card">
|
||||
<Link to={collectionHome(projectId, c.id)}>
|
||||
<span className="directory-card-name">{c.name || c.id}</span>
|
||||
<span className="directory-card-type">{entryNoun(c.type)}s</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
|
||||
let mockItems = []
|
||||
vi.mock('../api', () => ({
|
||||
listCollections: vi.fn(async () => ({ items: mockItems })),
|
||||
}))
|
||||
import CollectionDirectory from './CollectionDirectory.jsx'
|
||||
|
||||
beforeEach(() => { mockItems = [] })
|
||||
|
||||
function renderDir(items) {
|
||||
mockItems = items
|
||||
return render(
|
||||
<MemoryRouter initialEntries={["/p/ohm/"]}>
|
||||
<Routes>
|
||||
<Route path="/p/:projectId/*" element={<CollectionDirectory projectId="ohm" />} />
|
||||
<Route path="/p/:projectId/c/:collectionId/*" element={<div>collection home</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('CollectionDirectory', () => {
|
||||
it('lists a card per collection with the type-driven noun + link when 2+', async () => {
|
||||
renderDir([
|
||||
{ id: 'default', name: 'Model', type: 'document' },
|
||||
{ id: 'features', name: 'Scenarios', type: 'bdd' },
|
||||
])
|
||||
await waitFor(() => expect(screen.getByText('Scenarios')).toBeInTheDocument())
|
||||
expect(screen.getByText('Model').closest('a')).toHaveAttribute('href', '/p/ohm/c/default/')
|
||||
expect(screen.getByText('Scenarios').closest('a')).toHaveAttribute('href', '/p/ohm/c/features/')
|
||||
expect(screen.getByText('RFCs')).toBeInTheDocument() // document → RFCs
|
||||
expect(screen.getByText('Features')).toBeInTheDocument() // bdd → Features (type noun)
|
||||
})
|
||||
|
||||
it('redirects into the sole collection when exactly one is visible', async () => {
|
||||
renderDir([{ id: 'default', name: 'Model', type: 'document' }])
|
||||
await waitFor(() => expect(screen.getByText('collection home')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('shows a minimal empty note when there are no collections', async () => {
|
||||
renderDir([])
|
||||
await waitFor(() => expect(screen.getByText('No collections yet.')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
@@ -28,7 +28,7 @@ function slugify(title) {
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '', projectId }) {
|
||||
export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '', projectId, collectionId }) {
|
||||
// #28 Part 2: a "create RFC for '<term>'" affordance pre-fills the title
|
||||
// (App passes the `?propose=<term>` value here); the slug derives from it
|
||||
// via the same effect that drives manual typing.
|
||||
@@ -98,6 +98,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
|
||||
pitch: pitch.trim(),
|
||||
tags,
|
||||
proposedUseCase: useCase.trim() || null,
|
||||
collectionId,
|
||||
})
|
||||
// v0.15.0 — analytics: fire on the §9.1 propose-RFC submit.
|
||||
// Slug is a stable, low-cardinality identifier (kebab-case
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// project-scoped, so the builders emit the default collection segment; the
|
||||
// collection-aware link layer (named collections) lands in S2. Components build
|
||||
// links via these helpers so that flip happens in one place.
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useProject } from '../components/ProjectLayout.jsx'
|
||||
import { useDeployment } from '../context/DeploymentProvider'
|
||||
|
||||
@@ -37,3 +38,10 @@ export function useProjectId() {
|
||||
const { defaultProjectId } = useDeployment()
|
||||
return (ctx && ctx.projectId) || defaultProjectId
|
||||
}
|
||||
|
||||
// §22 S2 — the collection id a component should scope to: the `/c/:collectionId/`
|
||||
// route segment when present, else the project's default collection.
|
||||
export function useCollectionId() {
|
||||
const { collectionId } = useParams()
|
||||
return collectionId || DEFAULT_COLLECTION
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user