Compare commits

..

10 Commits

Author SHA1 Message Date
Ben Stull c2f566512a §22 S3: scope-role enforcement + collection-grain visibility (@S3) — v0.42.0
Implement slice S3 of the §22 three-tier refactor: the four-layer
most-permissive scope-role resolver (§B.2) over {owner, contributor}
grants at {global, project, collection}, with the §22.5 visibility gate
enforced at the collection grain.

- migration 030: memberships.scope_type += 'global' (the global RFC
  Contributor tier; sentinel scope_id '*').
- auth.effective_scope_role folds global → project → collection,
  most-permissive, no negative override; can_read_collection /
  can_contribute_in_collection / is_collection_superuser /
  can_create_collection gate reads, writes, admin, and create.
- collection-grain visibility: a gated collection is hidden from the
  public (404, omitted from the directory) yet visible+listed for a
  scope-role holder; a collection may be set only as strict or stricter
  than its project (public < unlisted < gated), validated at create and
  clamped at the mirror.
- entry-scoped authority (mark-reviewed, graduate, branch read/contribute,
  PR/discussion/contribution moderation) re-pointed from the project grain
  to the entry's collection.
- create-collection authority widened to a project/global-scope grant
  holder (§B.1), not only a deployment owner/admin.

Keystone reconciliation (session 0076): a plain granted account is a
granted *account*, not a write-everywhere global role; the implicit-public
write baseline is grandfathered onto the migration-seeded `default`
collection only, so the N=1 deployment loses no capability. Reinterprets
§B.1/§B.3 literally — flagged for the SPEC merge (S6).

Completes @S3 (C1.1–C1.8). Tests: test_s3_scope_roles_vertical.py (8 C.1
scenarios + visibility/strictness), test_migration_030_global_scope.py.
Full backend suite 493 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 18:07:51 -07:00
Ben Stull 39ce54fbcc Merge pull request '§22 S2: create & navigate a second collection — v0.41.0 (@S2)' (#20) from feat/s2-second-collection into main 2026-06-05 20:19:25 +00:00
Ben Stull 55d04ce4ca §22 S2: release v0.41.0 — create & navigate a second collection (@S2)
Minor, non-breaking: named collections via .collection.yaml, create-collection
endpoint, collection-scoped serve/propose, and the /p/<project>/ collection
directory. Completes acceptance @S2 (C3.6). 478 backend + 26 frontend green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:17:31 -07:00
Ben Stull bd6dc6524a §22 S2: @S2 acceptance — anonymous empty public collection catalog (C3.6)
Anonymous reader of an empty public collection gets a 200 empty catalog and no
propose action (the propose route rejects anonymous); the Catalog footer's
'Sign in to propose' prompt is the existing anonymous affordance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:14:52 -07:00
Ben Stull 17bdd5fd9a §22 S2: collection directory at /p/<project>/ (1 → redirect, 2+ → list)
Replace DefaultCollectionRedirect with a CollectionDirectory that lists the
project's visible collections, or redirects into the sole one when there is
exactly one (preserving the S1 C3.7/C3.8 single-collection UX). The
create-first-collection empty state is S4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:14:18 -07:00
Ben Stull 2b32e124ab §22 S2: Catalog + propose scoped to the active collection
Catalog reads the /c/:collectionId/ segment via useCollectionId and fetches the
collection-scoped catalog, building entry/proposal links with the active
collection; the propose modal threads the active collection so a propose from a
named collection targets it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:12:45 -07:00
Ben Stull 98eea3e2d6 §22 S2: collection-scoped frontend path + API helpers
useCollectionId() hook; listRFCs/getRFC/proposeRFC take an optional collection
id and target the /collections/<cid>/ routes; add listCollections +
createCollection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:11:27 -07:00
Ben Stull 0c654b173d §22 S2: create-collection endpoint (bot commit + registry refresh)
POST /api/projects/<id>/collections, owner/admin-gated, commits a
.collection.yaml to the content repo main via bot.create_collection, then
re-mirrors the registry so the collections row appears (§22.2). Adds GET
list/one collection routes. Extends FakeGitea to model directory listings so
the mirror's content-repo walk is exercised end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:10:09 -07:00
Ben Stull f57d4080dc §22 S2: collection-scoped list/get/propose endpoints
Refactor the project-scoped serve/propose internals into collection-grained
helpers (_list_rfcs_for_collection, _get_rfc_for_collection,
_propose_into_collection); add routes under
/api/projects/<id>/collections/<cid>/rfcs[/<slug>|/propose]. Propose writes
the entry under the target collection's <subfolder>/rfcs via a new rfcs_dir
param on bot.open_idea_pr. Default-collection routes preserved as wrappers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:05:58 -07:00
Ben Stull 91b0fb358c §22 S2: corpus mirror reads each collection's <subfolder>/rfcs/
refresh_meta_repo now iterates a project's collections and keys cached_rfcs by
collection_id; the default collection (subfolder '') keeps the shipped rfcs/
root path. N=1 default path unchanged (469 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:02:59 -07:00
30 changed files with 1685 additions and 159 deletions
+137
View File
@@ -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 release's steps in order — no A-to-B path is pre-computed beyond
that. 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.1C1.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 ## 0.40.0 — 2026-06-05
**Minor (breaking URL) — §22 three-tier refactor, slice S1: the *collection* **Minor (breaking URL) — §22 three-tier refactor, slice S1: the *collection*
+1 -1
View File
@@ -1 +1 @@
0.40.0 0.42.0
+95 -27
View File
@@ -21,6 +21,7 @@ from pydantic import BaseModel, Field
from . import ( from . import (
api_admin, api_admin,
api_branches, api_branches,
api_collections,
api_contributions, api_contributions,
api_deployment, api_deployment,
api_discussion, api_discussion,
@@ -152,6 +153,7 @@ def make_router(
# §22.9/§22.10 (M3): runtime deployment + per-project config (replaces # §22.9/§22.10 (M3): runtime deployment + per-project config (replaces
# VITE_APP_NAME) + the old-URL 308 redirects. # VITE_APP_NAME) + the old-URL 308 redirects.
router.include_router(api_deployment.make_router(config)) router.include_router(api_deployment.make_router(config))
router.include_router(api_collections.make_router(config, gitea, bot))
# --------------------------------------------------------------- # ---------------------------------------------------------------
# §17: /api/health — unauthenticated post-flight probe. # §17: /api/health — unauthenticated post-flight probe.
@@ -717,15 +719,15 @@ def make_router(
# second project's corpus renders under /p/<id>/. # second project's corpus renders under /p/<id>/.
# --------------------------------------------------------------- # ---------------------------------------------------------------
@router.get("/api/projects/{project_id}/rfcs") def _require_collection_in_project(collection_id: str, project_id: str) -> None:
async def list_project_rfcs( # §22 S2: a collection-scoped route 404s when the collection does not
project_id: str, request: Request, unreviewed: str | None = None # 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]: ) -> 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 viewer_id = viewer.user_id if viewer else None
unreviewed_clause = "" unreviewed_clause = ""
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"): if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
@@ -769,11 +771,7 @@ def make_router(
] ]
return {"items": items} return {"items": items}
@router.get("/api/projects/{project_id}/rfcs/{slug}") def _get_rfc_for_collection(collection_id: str, slug: str, viewer) -> dict[str, Any]:
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)
row = db.conn().execute( row = db.conn().execute(
"SELECT * FROM cached_rfcs WHERE collection_id = ? AND slug = ?", "SELECT * FROM cached_rfcs WHERE collection_id = ? AND slug = ?",
(collection_id, slug), (collection_id, slug),
@@ -794,6 +792,49 @@ def make_router(
payload["proposed_use_case"] = uc["use_case"] if uc else None payload["proposed_use_case"] = uc["use_case"] if uc else None
return payload 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 # §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") @router.post("/api/projects/{project_id}/rfcs/{slug}/mark-reviewed")
async def mark_reviewed(project_id: str, slug: str, request: Request) -> dict[str, Any]: 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.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) viewer = auth.require_user(request)
auth.require_project_readable(viewer, project_id) 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) 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( row = db.conn().execute(
"SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND collection_id = ?", "SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND collection_id = ?",
(slug, 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]: 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 # Default-collection wrapper (§22 S1/S2): resolve the project's default
# standing in the *target* project. On the public default project the # collection and delegate. Keeps the project-scoped propose routes intact.
# implicit-public baseline preserves the pre-multi-project flow. return await _propose_into_collection(
if not auth.can_contribute_in_project(user, project_id): project_id, collections_mod.default_collection_id(project_id), payload, user
raise HTTPException(403, "You do not have contribute access to this project") )
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() slug = payload.slug.strip().lower()
if not entry_mod.is_valid_slug(slug): if not entry_mod.is_valid_slug(slug):
raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes") 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 # We re-check atomically here even though the client also checks
# on every keystroke, since a concurrent submission could land # on every keystroke, since a concurrent submission could land
# between dialog-open and submit. # between dialog-open and submit.
collection_id = collections_mod.default_collection_id(project_id)
clash = db.conn().execute( clash = db.conn().execute(
"SELECT 1 FROM cached_rfcs WHERE slug = ? AND collection_id = ?", (slug, collection_id) "SELECT 1 FROM cached_rfcs WHERE slug = ? AND collection_id = ?", (slug, collection_id)
).fetchone() ).fetchone()
@@ -984,11 +1034,12 @@ def make_router(
if idea_clash: if idea_clash:
raise HTTPException(409, f"Slug `{slug}` is already reserved by an open proposal") 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 # §22.4b: the target collection's landing state (the per-corpus field
# entry lands in the default project; M3-frontend routing carries a # moved down to the collection in migration 029).
# non-default target later. landing_state = (
target_project = project_id "active" if collections_mod.collection_initial_state(collection_id) == "active"
landing_state = "active" if projects_mod.project_initial_state(target_project) == "active" else "super-draft" else "super-draft"
)
entry = entry_mod.Entry( entry = entry_mod.Entry(
slug=slug, slug=slug,
@@ -1019,6 +1070,9 @@ def make_router(
f"**Topic:** {entry.title}\n\n" f"**Topic:** {entry.title}\n\n"
f"{payload.pitch.strip()}" 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: try:
pr = await bot.open_idea_pr( pr = await bot.open_idea_pr(
user.as_actor(), user.as_actor(),
@@ -1028,6 +1082,7 @@ def make_router(
file_contents=contents, file_contents=contents,
pr_title=pr_title, pr_title=pr_title,
pr_description=pr_description, pr_description=pr_description,
rfcs_dir=rfcs_dir,
) )
except GiteaError as e: except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}") raise HTTPException(502, f"Gitea: {e.detail}")
@@ -1076,6 +1131,19 @@ def make_router(
auth.require_project_readable(user, project_id) auth.require_project_readable(user, project_id)
return await _propose_into_project(project_id, payload, user) 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 # §9.1 Slice 2 (roadmap #27): Claude Haiku tag suggestions as the
# propose-RFC fields fill in. The modal debounce-posts the partial # propose-RFC fields fill in. The modal debounce-posts the partial
+18 -18
View File
@@ -1264,11 +1264,11 @@ def make_router(
return row["on_behalf_of"] if row else None return row["on_behalf_of"] if row else None
def _can_read_branch(slug: str, branch: str, viewer) -> bool: def _can_read_branch(slug: str, branch: str, viewer) -> bool:
# §22.5 visibility gate first (subtractive, §22.7): in a gated project # §22.5 visibility gate first (subtractive, §B.2): in a hidden/gated
# nothing — not even main or a read_public branch — is readable by a # collection nothing — not even main or a read_public branch — is
# non-member. # readable by a non-scope-role viewer.
pid = auth.project_of_rfc(slug) cid = auth.collection_of_rfc(slug)
if not auth.can_read_project(viewer, pid): if not auth.can_read_collection(viewer, cid):
return False return False
if branch == "main": if branch == "main":
return True return True
@@ -1277,7 +1277,7 @@ def make_router(
return True return True
if viewer is None: if viewer is None:
return False return False
if auth.is_project_superuser(viewer, pid): if auth.is_collection_superuser(viewer, cid):
return True return True
creator = _branch_creator(slug, branch) creator = _branch_creator(slug, branch)
if creator and viewer.gitea_login == creator: 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). # legacy `repo:` is set (nothing, after the RFC-0001 fold-back).
if rfc["state"] == "active" and rfc["repo"] and _is_meta_branch_name(branch): if rfc["state"] == "active" and rfc["repo"] and _is_meta_branch_name(branch):
return False return False
pid = auth.project_of_rfc(slug) cid = auth.collection_of_rfc(slug)
# §22.5 visibility gate (subtractive): no contribute in an unreadable # §22.5 visibility gate (subtractive): no contribute in an unreadable
# project. # collection.
if not auth.can_read_project(viewer, pid): if not auth.can_read_collection(viewer, cid):
return False return False
if auth.is_project_superuser(viewer, pid): if auth.is_collection_superuser(viewer, cid):
return True return True
owners = json.loads(rfc["owners_json"] or "[]") owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]")
@@ -1326,10 +1326,10 @@ def make_router(
return True return True
vis = _branch_vis(slug, branch) vis = _branch_vis(slug, branch)
if vis["contribute_mode"] == "any-contributor": if vis["contribute_mode"] == "any-contributor":
# "any contributor" means anyone with project-level write standing # "any contributor" means anyone with collection-level write standing
# (§22.6/§22.7) — the implicit-public baseline on a public project, # (§B.2) — the grandfathered baseline on the public default
# or an explicit project_contributor/admin elsewhere. # collection, or an explicit scope grant reaching the collection.
return auth.can_contribute_in_project(viewer, pid) return auth.can_contribute_in_collection(viewer, cid)
if vis["contribute_mode"] == "specific": if vis["contribute_mode"] == "specific":
row = db.conn().execute( row = db.conn().execute(
""" """
@@ -1352,7 +1352,7 @@ def make_router(
def _require_branch_owner(rfc, viewer, creator: str | None) -> None: def _require_branch_owner(rfc, viewer, creator: str | None) -> None:
# §22.6: a project_admin is the per-RFC owner/arbiter authority lifted # §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. # 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 return
owners = json.loads(rfc["owners_json"] or "[]") owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_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 has no owners, so the set collapses to the superuser tier only
sensible because admin oversight is the only path to canonicalizing sensible because admin oversight is the only path to canonicalizing
edits on an unclaimed entry.""" 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 return True
owners = json.loads(rfc["owners_json"] or "[]") owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_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_read": _can_read_branch(slug, branch, viewer),
"can_contribute": _can_contribute(rfc, slug, branch, viewer) if viewer else False, "can_contribute": _can_contribute(rfc, slug, branch, viewer) if viewer else False,
"can_change_branch_settings": viewer is not None and ( "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 (creator is not None and viewer.gitea_login == creator)
or viewer.gitea_login in (owners + arbiters) 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: def _can_resolve_thread(rfc, thread, creator: str | None, viewer) -> bool:
if viewer is None: if viewer is None:
return False return False
if auth.is_project_superuser(viewer, rfc["project_id"]): if auth.is_collection_superuser(viewer, rfc["collection_id"]):
return True return True
owners = json.loads(rfc["owners_json"] or "[]") owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]")
+136
View File
@@ -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
+1 -1
View File
@@ -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. """Why this viewer can't *request* to contribute — or None if they can.
Owners/admins already have the RFC; existing collaborators are already Owners/admins already have the RFC; existing collaborators are already
in. Both get a clear 409 rather than a useless self-request.""" 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." return "You already own or administer this RFC."
if auth.is_rfc_collaborator(viewer, slug): if auth.is_rfc_collaborator(viewer, slug):
return "You're already a collaborator on this RFC." return "You're already a collaborator on this RFC."
+1 -1
View File
@@ -312,7 +312,7 @@ def _ensure_discussion_thread(slug: str, viewer) -> int:
def _can_resolve(rfc, thread, viewer) -> bool: def _can_resolve(rfc, thread, viewer) -> bool:
if viewer is None: if viewer is None:
return False return False
if auth.is_project_superuser(viewer, rfc["project_id"]): if auth.is_collection_superuser(viewer, rfc["collection_id"]):
return True return True
owners = json.loads(rfc["owners_json"] or "[]") owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]")
+2 -2
View File
@@ -218,7 +218,7 @@ def make_router(
can_merge = ( can_merge = (
viewer is not None viewer is not None
and ( 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 owners
or viewer.gitea_login in arbiters or viewer.gitea_login in arbiters
) )
@@ -795,7 +795,7 @@ def _can_graduate(rfc, viewer) -> bool:
if viewer is None: if viewer is None:
return False return False
# §6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters. # §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 return True
owners = json.loads(rfc["owners_json"] or "[]") owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]")
+1 -1
View File
@@ -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.""" """§6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters."""
if viewer is None: if viewer is None:
return False return False
if auth.is_project_superuser(viewer, rfc["project_id"]): if auth.is_collection_superuser(viewer, rfc["collection_id"]):
return True return True
owners = json.loads(rfc["owners_json"] or "[]") owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]")
+265 -51
View File
@@ -16,6 +16,7 @@ from typing import Any
import httpx import httpx
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from . import collections as collections_mod
from . import db from . import db
from .bot import Actor from .bot import Actor
from .config import Config from .config import Config
@@ -336,26 +337,37 @@ def project_visibility(project_id: str) -> str:
return row["visibility"] or "gated" return row["visibility"] or "gated"
def project_member_role(user: SessionUser | None, project_id: str) -> str | None: def _is_default_project(project_id: str) -> bool:
"""The user's *explicit* §22.6 membership role at this project, or None. """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 def project_member_role(user: SessionUser | None, project_id: str) -> str | None:
tier is freshly grantable). The unified `{owner, contributor}` roles are """The user's *project-grain* §22.6 role at this project, or None — the
mapped back to the legacy `project_admin`/`project_contributor` strings the most-permissive of a **global** grant (inherits down to every project) and a
S1 project-grain authz still speaks; the four-layer scope resolver lands in **project**-scope grant. Mapped back to the legacy
S3. Reads the stored grant only (project OR default-collection scope) it `project_admin`/`project_contributor` strings the project-grain authz speaks.
does not fold in the deployment tier or the implicit-on-public baseline."""
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: if user is None:
return None return None
from . import collections as collections_mod clauses = ["scope_type = 'global'", "(scope_type = 'project' AND scope_id = ?)"]
cid = collections_mod.default_collection_id(project_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( row = db.conn().execute(
"SELECT role FROM memberships " "SELECT role FROM memberships WHERE user_id = ? AND (" + " OR ".join(clauses) + ") "
"WHERE user_id = ? AND ((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", "ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1",
(user.user_id, project_id, cid), params,
).fetchone() ).fetchone()
if row is None: if row is None:
return None return None
@@ -378,6 +390,20 @@ def project_of_rfc(rfc_slug: str) -> str:
return row["project_id"] or DEFAULT_PROJECT_ID 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: def is_project_superuser(user: SessionUser | None, project_id: str) -> bool:
"""Maximal authority within a project: a deployment owner/admin (superuser """Maximal authority within a project: a deployment owner/admin (superuser
in every project, §22.7) or an explicit `project_admin` (§22.6). Both 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: def can_read_project(user: SessionUser | None, project_id: str) -> bool:
"""The §22.5 visibility gate. `public`/`unlisted` are readable by anyone """The §22.5 visibility gate at the project grain. `public`/`unlisted` are
(anonymous included `unlisted` is link-only but the link still reads); readable by anyone (anonymous included `unlisted` is link-only but the link
`gated` is readable only by a deployment owner/admin or a granted project still reads); `gated` is readable only by a deployment owner/admin or a
member of any role. Used as the subtractive read gate (a gated project's holder of any scope grant reaching the project a global grant, a project
entries 404 to non-members).""" 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) vis = project_visibility(project_id)
if vis in ("public", "unlisted"): if vis in ("public", "unlisted"):
return True 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": if user is None or user.permission_state != "granted":
return False return False
if user.role in _DEPLOYMENT_SUPERUSER_ROLES: if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
return True 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: 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"])] 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. # v0.16.0 (roadmap item #12): per-RFC membership helpers.
# #
# These don't replace `require_contributor` — they layer on top of it for # 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 return False
if user.permission_state != "granted": if user.permission_state != "granted":
return False return False
pid = project_of_rfc(rfc_slug) cid = collection_of_rfc(rfc_slug)
# §22.5 visibility gate is subtractive (§22.7) — no capability in a project # §22.5 visibility gate is subtractive (§22.7) — no capability in a
# the viewer cannot even read. # collection the viewer cannot even read.
if not can_read_project(user, pid): if not can_read_collection(user, cid):
return False return False
# §22.7 union, override grants first — these bypass per-RFC curation # §B.2 union, scope-role grants first — these bypass per-RFC curation (a
# (project_viewer ⊇ discussant; project_admin / deployment superuser ⊇ all). # collection/project/global Owner or RFC Contributor ⊇ discussant).
if is_project_superuser(user, pid): if effective_scope_role(user, cid) is not None:
return True
if project_member_role(user, pid) in ("project_viewer", "project_contributor"):
return True return True
# per-RFC authority (union term). # per-RFC authority (union term).
owners = _rfc_owners_set(rfc_slug) owners = _rfc_owners_set(rfc_slug)
@@ -578,11 +793,11 @@ def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
return True return True
if is_rfc_collaborator(user, rfc_slug, role_in_rfc=None): if is_rfc_collaborator(user, rfc_slug, role_in_rfc=None):
return True return True
# implicit-public baseline (curation preserved): a granted deployment # grandfathered implicit-public baseline (curation preserved): on the default
# contributor on a public project may discuss only while the RFC is # collection a granted deployment contributor may discuss only while the RFC
# unclaimed. The first §13.1 claim engages the per-RFC gate, mirroring the # is unclaimed. The first §13.1 claim engages the per-RFC gate, mirroring the
# pre-multi-project v0.16.0 contract. # 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 True
return False return False
@@ -606,14 +821,12 @@ def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
return False return False
if user.permission_state != "granted": if user.permission_state != "granted":
return False return False
pid = project_of_rfc(rfc_slug) cid = collection_of_rfc(rfc_slug)
if not can_read_project(user, pid): if not can_read_collection(user, cid):
return False return False
# §22.7 union, override grants first (project_contributor ⊇ # §B.2 union, scope-role grants first (a collection/project/global RFC
# rfc_collaborators(contributor); project_admin / superuser ⊇ all). # Contributor ⊇ rfc_collaborators(contributor); an Owner ⊇ all).
if is_project_superuser(user, pid): if effective_scope_role(user, cid) is not None:
return True
if project_member_role(user, pid) == "project_contributor":
return True return True
# per-RFC authority (union term). A 'discussant' row is NOT sufficient — # per-RFC authority (union term). A 'discussant' row is NOT sufficient —
# PRs are the higher-privilege surface. # PRs are the higher-privilege surface.
@@ -622,9 +835,10 @@ def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
return True return True
if is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor"): if is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor"):
return True return True
# implicit-public baseline (curation preserved): until an owner exists, a # grandfathered implicit-public baseline (curation preserved): until an owner
# granted deployment contributor on a public project may contribute. # exists, a granted deployment contributor on the public default collection
if not owners and _has_write_baseline(user, pid): # may contribute.
if not owners and _has_collection_write_baseline(user, cid):
return True return True
return False return False
@@ -637,13 +851,13 @@ def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
return False return False
if user.permission_state != "granted": if user.permission_state != "granted":
return False return False
pid = project_of_rfc(rfc_slug) cid = collection_of_rfc(rfc_slug)
if not can_read_project(user, pid): if not can_read_collection(user, cid):
return False return False
# Deployment owner/admin or project_admin (§22.6) may invite; otherwise # An Owner reaching the collection (collection/project/global Owner, or a
# only the RFC's frontmatter owner. Per-RFC collaborators and the # deployment owner/admin) may invite; otherwise only the RFC's frontmatter
# implicit-public baseline do not get the invite-others power. # owner. Per-RFC collaborators and the baseline do not get the invite power.
if is_project_superuser(user, pid): if is_collection_superuser(user, cid):
return True return True
return is_rfc_owner(user, rfc_slug) return is_rfc_owner(user, rfc_slug)
+42 -3
View File
@@ -163,6 +163,41 @@ class Bot:
def __init__(self, gitea: Gitea): def __init__(self, gitea: Gitea):
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) ----- # ----- Meta repo: idea PRs (§9.1 / §9.2) -----
async def open_idea_pr( async def open_idea_pr(
@@ -175,12 +210,16 @@ class Bot:
file_contents: str, file_contents: str,
pr_title: str, pr_title: str,
pr_description: str, pr_description: str,
rfcs_dir: str = "rfcs",
) -> dict: ) -> 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. 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 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}" branch = f"propose/{slug}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main") await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
@@ -189,7 +228,7 @@ class Bot:
created = await self._gitea.create_file( created = await self._gitea.create_file(
org, org,
meta_repo, meta_repo,
f"rfcs/{slug}.md", f"{rfcs_dir}/{slug}.md",
content=file_contents, content=file_contents,
message=commit_message, message=commit_message,
branch=branch, branch=branch,
+26 -11
View File
@@ -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: 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 # §22 S2: the corpus grain is the collection. Mirror every collection of the
# (reads rfcs/ at the repo root = the project's default collection); resolve # project from its `<subfolder>/rfcs/` directory, keying cached_rfcs by the
# that collection once and key cached_rfcs by it. # 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 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: 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: 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 return
seen_slugs: set[str] = set() seen_slugs: set[str] = set()
@@ -76,17 +88,19 @@ async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: G
try: try:
entry = entry_mod.parse(text) entry = entry_mod.parse(text)
except Exception as parse_err: 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 continue
if not entry.slug: 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 continue
seen_slugs.add(entry.slug) seen_slugs.add(entry.slug)
_upsert_cached_rfc(entry, body_sha=sha, collection_id=collection_id) _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; # 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 = { existing = {
row["slug"] row["slug"]
for row in db.conn().execute( 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: 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: def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, collection_id: str = "default") -> None:
+19 -2
View File
@@ -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( def _upsert_named_collection(
proj: ProjectEntry, subdir: str, ce: CollectionEntry, sha: str proj: ProjectEntry, subdir: str, ce: CollectionEntry, sha: str
) -> None: ) -> None:
"""Upsert one named collection (S2). Type is immutable (§22.4a): a type """Upsert one named collection (S2). Type is immutable (§22.4a): a type
change against an existing row is refused (logged, not applied). A None change against an existing row is refused (logged, not applied). A None
manifest visibility inherits the project's visibility.""" manifest visibility inherits the project's visibility; a manifest that tries
visibility = ce.visibility or proj.visibility 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: with db.tx() as conn:
existing = conn.execute( existing = conn.execute(
"SELECT type FROM collections WHERE id = ?", (subdir,) "SELECT type FROM collections WHERE id = ?", (subdir,)
+34
View File
@@ -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: with TestClient(app) as client:
_register_ecomm(fake) _register_ecomm(fake)
provision_user_row(user_id=3, login="alice", role="contributor") 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", sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice",
role="contributor", email="alice@test") role="contributor", email="alice@test")
r = client.post("/api/projects/ecomm/rfcs/propose", json={ r = client.post("/api/projects/ecomm/rfcs/propose", json={
+34 -11
View File
@@ -90,6 +90,28 @@ class FakeGitea:
self._commit_counter += 1 self._commit_counter += 1
return f"sha{self._commit_counter:04d}" 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: def _enrich_pr(self, owner: str, repo: str, pr: dict) -> dict:
"""Return the PR with mergeability fields filled in. """Return the PR with mergeability fields filled in.
@@ -227,6 +249,16 @@ class FakeGitea:
} }
return httpx.Response(201, json={"name": new}) 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=... # GET /repos/{owner}/{repo}/contents/{path}?ref=...
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path) m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path)
if method == "GET" and m: if method == "GET" and m:
@@ -242,17 +274,8 @@ class FakeGitea:
"sha": f["sha"], "sha": f["sha"],
"content": base64.b64encode(f["content"].encode()).decode(), "content": base64.b64encode(f["content"].encode()).decode(),
}) })
# Directory listing # Directory listing — both file and subdir children.
prefix = fpath.rstrip("/") + "/" children = self._dir_listing(owner, repo, ref, fpath)
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"],
})
if children: if children:
return httpx.Response(200, json=children) return httpx.Response(200, json=children)
return httpx.Response(404, json={"message": "not found"}) 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. exactly as before, now with a real collection layer and one extra path segment.
**Completes:** `@S1` (the single-collection / single-project redirect skips). **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** read `.collection.yaml`; add the bot-commit-wrapped **create-collection**
endpoint (authorized by existing deployment owner/admin for now — the scoped endpoint (authorized by existing deployment owner/admin for now — the scoped
role surface lands in S3); the project collection-directory at `/p/<project>/`; 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 one and it is navigable + proposable. **Completes:** `@S2` (anonymous reader of
an empty collection catalog). an empty collection catalog).
- **S3 — Scope-role enforcement.** The four-layer most-permissive resolver - **S3 — Scope-role enforcement.** *(Shipped v0.42.0.)* The four-layer
(§B.2) over `{owner, contributor}` grants at `{global, project, collection}`, most-permissive resolver (§B.2) over `{owner, contributor}` grants at
with grants applied administratively (DB / admin endpoint); every write gate `{global, project, collection}` (migration 030 adds the `global` scope_type),
re-checked under the collection axis. **Usable end-state:** a user granted with grants applied administratively (DB / the Owner-authorized create
RFC Contributor at a scope can contribute across exactly that subtree, and surface); every write gate re-checked under the collection axis. **Plus the
Owners administer their subtree. **Completes:** `@S3` (all of C.1 — role usage, 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). inheritance, union, no-negative-override).
- **S4 — Invitation surfaces + role-aware empty states.** The invite UI - **S4 — Invitation surfaces + role-aware empty states.** The invite UI
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "rfc-app-frontend", "name": "rfc-app-frontend",
"private": true, "private": true,
"version": "0.40.0", "version": "0.42.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+16 -9
View File
@@ -15,6 +15,7 @@ import RFCView from './components/RFCView.jsx'
import PRView from './components/PRView.jsx' import PRView from './components/PRView.jsx'
import ProposalView from './components/ProposalView.jsx' import ProposalView from './components/ProposalView.jsx'
import ProposeModal from './components/ProposeModal.jsx' import ProposeModal from './components/ProposeModal.jsx'
import CollectionDirectory from './components/CollectionDirectory.jsx'
import ContributeRequestForm from './components/ContributeRequestForm.jsx' import ContributeRequestForm from './components/ContributeRequestForm.jsx'
import Landing from './components/Landing.jsx' import Landing from './components/Landing.jsx'
import Login from './components/Login.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. // right project. Falls back to the deployment default off a project route.
const _projMatch = location.pathname.match(/^\/p\/([^/]+)/) const _projMatch = location.pathname.match(/^\/p\/([^/]+)/)
const currentProjectId = (_projMatch && _projMatch[1]) || deployment.defaultProjectId 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 23: the LinkedText create/contribute affordances route via // #28 Parts 23: the LinkedText create/contribute affordances route via
// query params so they need no prop-threading from deep in a comment // query params so they need no prop-threading from deep in a comment
// list. `?propose=<term>` opens the propose modal pre-filled; // list. `?propose=<term>` opens the propose modal pre-filled;
@@ -360,9 +365,10 @@ export default function App() {
/> />
<main className="main-pane"> <main className="main-pane">
<Routes> <Routes>
{/* §22 three-tier (C3.7): the project landing redirects into {/* §22 S2: the project landing is the collection directory
its sole/default collection (S1: the `default` one). */} it lists collections, or (C3.7/C3.8) redirects into the
<Route path="" element={<DefaultCollectionRedirect />} /> sole visible collection when there is exactly one. */}
<Route path="" element={<CollectionDirectoryRoute />} />
{/* Backcompat: the shipped v0.35.0 corpus URLs without a {/* Backcompat: the shipped v0.35.0 corpus URLs without a
/c/<collection>/ segment redirect into the default /c/<collection>/ segment redirect into the default
collection, so old bookmarks keep working. */} collection, so old bookmarks keep working. */}
@@ -389,12 +395,13 @@ export default function App() {
viewer={viewer} viewer={viewer}
initialTitle={proposeParam || ''} initialTitle={proposeParam || ''}
projectId={currentProjectId} projectId={currentProjectId}
collectionId={currentCollectionId}
onClose={() => { setProposeOpen(false); clearParams('propose') }} onClose={() => { setProposeOpen(false); clearParams('propose') }}
onSubmitted={({ pr_number }) => { onSubmitted={({ pr_number }) => {
setProposeOpen(false) setProposeOpen(false)
clearParams('propose') clearParams('propose')
setCatalogVersion(v => v + 1) 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/*. // §22 S2 the project landing at /p/:projectId/ is the collection directory.
// C3.7: the project landing skips the (single-collection) directory and lands in // A tiny wrapper reads the route's projectId and hands it to CollectionDirectory
// the project's default collection. // (which lists collections, or redirects into the sole one C3.7/C3.8).
function DefaultCollectionRedirect() { function CollectionDirectoryRoute() {
const { projectId } = useParams() 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 // Backcompat for the shipped v0.35.0 corpus URLs that lacked the
+48
View File
@@ -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
View File
@@ -182,22 +182,50 @@ export async function getProject(projectId) {
return jsonOrThrow(await fetch(`/api/projects/${projectId}`)) return jsonOrThrow(await fetch(`/api/projects/${projectId}`))
} }
// §22.4 (Plan B): per-project serving. Given a projectId, read the // §22.4 (Plan B) / §22 S2: per-collection serving. With a projectId + a
// project-scoped routes so a non-default project's corpus renders; without // collectionId, read the collection-scoped routes; with only a projectId, the
// one, fall back to the default-project compat path. // project default-collection compat path; with neither, the unscoped path.
export async function listRFCs(projectId) { 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' const url = projectId ? `/api/projects/${projectId}/rfcs` : '/api/rfcs'
return jsonOrThrow(await fetch(url)) 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. // Back-compat: getRFC(slug) (one arg) still hits the unscoped default path.
if (slug === undefined) { if (slug === undefined) {
return jsonOrThrow(await fetch(`/api/rfcs/${projectId}`)) 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}`)) 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) { export async function listProposals(projectId) {
const url = projectId ? `/api/projects/${projectId}/proposals` : '/api/proposals' const url = projectId ? `/api/projects/${projectId}/proposals` : '/api/proposals'
return jsonOrThrow(await fetch(url)) 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 // §22.4 (Plan B write): propose into a specific project when projectId is
// given; else the default-project compat path. // given; else the default-project compat path.
export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase }) { export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase, collectionId }) {
const url = projectId ? `/api/projects/${projectId}/rfcs/propose` : '/api/rfcs/propose' 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, { const res = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
+8 -5
View File
@@ -9,7 +9,7 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { listRFCs, listProposals } from '../api' import { listRFCs, listProposals } from '../api'
import { entryPath, proposalPath, useProjectId } from '../lib/entryPaths' import { entryPath, proposalPath, useProjectId, useCollectionId } from '../lib/entryPaths'
const STATE_CHIPS = [ const STATE_CHIPS = [
{ id: 'super-draft', label: 'Super-draft' }, { id: 'super-draft', label: 'Super-draft' },
@@ -32,11 +32,14 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
const [pendingOpen, setPendingOpen] = useState(true) const [pendingOpen, setPendingOpen] = useState(true)
const { slug, prNumber } = useParams() const { slug, prNumber } = useParams()
const pid = useProjectId() 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(() => { 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([])) listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
}, [version, pid]) }, [version, pid, cid])
const filtered = useMemo(() => { const filtered = useMemo(() => {
const needle = search.trim().toLowerCase() const needle = search.trim().toLowerCase()
@@ -105,7 +108,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
return ( return (
<Link <Link
key={r.slug} key={r.slug}
to={entryPath(pid, r.slug)} to={entryPath(pid, r.slug, cid)}
className={`catalog-row ${isActive ? 'active' : ''} ${isSuper ? 'is-super' : ''}`} className={`catalog-row ${isActive ? 'active' : ''} ${isSuper ? 'is-super' : ''}`}
> >
<div className="row-top"> <div className="row-top">
@@ -133,7 +136,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
{proposals.map(p => ( {proposals.map(p => (
<Link <Link
key={p.pr_number} 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' : ''}`} className={`pending-row ${String(prNumber) === String(p.pr_number) ? 'active' : ''}`}
> >
<div>{p.title.replace(/^Propose:\s*/, '')}</div> <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())
})
})
+2 -1
View File
@@ -28,7 +28,7 @@ function slugify(title) {
.replace(/^-+|-+$/g, '') .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 // #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 // (App passes the `?propose=<term>` value here); the slug derives from it
// via the same effect that drives manual typing. // via the same effect that drives manual typing.
@@ -98,6 +98,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
pitch: pitch.trim(), pitch: pitch.trim(),
tags, tags,
proposedUseCase: useCase.trim() || null, proposedUseCase: useCase.trim() || null,
collectionId,
}) })
// v0.15.0 analytics: fire on the §9.1 propose-RFC submit. // v0.15.0 analytics: fire on the §9.1 propose-RFC submit.
// Slug is a stable, low-cardinality identifier (kebab-case // Slug is a stable, low-cardinality identifier (kebab-case
+8
View File
@@ -4,6 +4,7 @@
// project-scoped, so the builders emit the default collection segment; the // project-scoped, so the builders emit the default collection segment; the
// collection-aware link layer (named collections) lands in S2. Components build // collection-aware link layer (named collections) lands in S2. Components build
// links via these helpers so that flip happens in one place. // links via these helpers so that flip happens in one place.
import { useParams } from 'react-router-dom'
import { useProject } from '../components/ProjectLayout.jsx' import { useProject } from '../components/ProjectLayout.jsx'
import { useDeployment } from '../context/DeploymentProvider' import { useDeployment } from '../context/DeploymentProvider'
@@ -37,3 +38,10 @@ export function useProjectId() {
const { defaultProjectId } = useDeployment() const { defaultProjectId } = useDeployment()
return (ctx && ctx.projectId) || defaultProjectId 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
}