diff --git a/CLAUDE.md b/CLAUDE.md index 704a2f37b..fc1a51942 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -184,6 +184,7 @@ npm run screenshots:smoke # gating interaction tests (search, Orbit, nav) | `src/types/image-sequence-constants.ts` | Constants shared by the publisher API (`functions/`), the GHA runner (`cli/`), and the portal (`src/`) for the image-sequence upload pipeline | | `src/types/zyra-workflow-constants.ts` | Constants shared by the publisher API (`functions/`), the GHA runner (`cli/`), and the portal (`src/`) for the Zyra workflow pipeline — stage/command allowlist, template fields, run statuses (`docs/ZYRA_INTEGRATION_PLAN.md`) | | `src/types/node-features.ts` | Per-node feature-toggle constants shared by the publisher API (`functions/`) and the portal + public SPA — `FEATURE_KEYS` / `FeatureMap`, all-on defaults, fail-open normalization (missing/unknown keys resolve to enabled) | +| `src/types/publisher-roles.ts` | Publisher role → capability matrix shared by the publisher API (`functions/`) and the portal — `CAPABILITIES` / `ROLES` / `ROLE_CAPABILITIES`, `roleCan` / `capabilitiesForRole`, legacy-string `normalizeRole` (fail-closed to `reviewer`). The single source of truth for authorization (`docs/PUBLISHER_ROLES_PLAN.md`) | | `src/data/regions.ts` | Common region bounding boxes for name-based region resolution | | `src/services/orbitCharacter/index.ts` | `OrbitController` — public API for the Orbit character (owns the Three.js scene, rAF loop, state machine) | | `src/services/orbitCharacter/orbitScene.ts` | Three.js scene + per-frame update for the Orbit character | @@ -243,14 +244,14 @@ npm run screenshots:smoke # gating interaction tests (search, Orbit, nav) | `src/ui/publisher/pages/workflows.ts` | `/publish/workflows` — Zyra workflow list | | `src/ui/publisher/pages/workflow-detail.ts` | `/publish/workflows/:id` — workflow summary + run history + Run now | | `src/ui/publisher/pages/workflow-edit.ts` | `/publish/workflows/new` + `…/:id/edit` — workflow form (YAML→JSON client-side, server-side Validate) | -| `src/ui/publisher/pages/featured-hero.ts` | `/publish/featured-hero` — set the "Right now" hero override (`docs/HERO_ADMIN_SCOPING.md`) | +| `src/ui/publisher/pages/featured-hero.ts` | `/publish/featured-hero` — set the "Right now" hero override (`hero.manage`: editors / admins); callers without it get a read-only view of the current pin (`docs/HERO_ADMIN_SCOPING.md`) | | `src/ui/publisher/pages/node-profile.ts` | `/publish/node-profile` — edit the node / host-organization profile (org name, mission, about, region focus, tone, links) — the "about the host" context Phase 3d AI drafts ground themselves in | | `src/ui/publisher/pages/blog.ts` | `/publish/blog` — blog authoring list (drafts + published, status badges, New post) | | `src/ui/publisher/pages/blog-edit.ts` | `/publish/blog/new` + `…/:id/edit` — tabbed blog editor (Content / Sources / Media / AI draft): dataset/event grounding pickers, the **Media** tab (reuses the Events-tab `media-suggest` engine — Worldview / Commons / ShakeMap / NHC / agency YouTube + the cited event's story image — to insert imagery into the body or set the post's cover image), the AI Generate panel (tone/length/companion-tour → `POST /publish/blog/generate`), markdown body with the shared toolbar + sanitized Preview, Save/Publish/Unpublish | | `src/ui/publisher/pages/feeds.ts` | `/publish/feeds` — the current-events feed console: registered connectors (pause/resume/remove, Run now, last-run status), the curated preset gallery, and the bring-your-own RSS/Atom form (`docs/CURRENT_EVENTS_PLAN.md` §9) | | `src/ui/publisher/pages/events.ts` | `/publish/events` — current-events review queue: curator approve/reject of proposed events + their dataset links (`docs/CURRENT_EVENTS_PLAN.md` §5) | -| `src/ui/publisher/pages/analytics.ts` | `/publish/analytics` — privileged analytics dashboard over the D1 rollups, incl. the MapLibre spatial-attention heatmap (Phase B of `docs/ANALYTICS_STORAGE_AND_ADMIN_PLAN.md`) | -| `src/ui/publisher/pages/feedback.ts` | `/publish/feedback` — privileged feedback review (AI thumbs + bug/feature reports) over the D1 feedback tables; replaces the feedback-admin HTML dashboard (Phase C of `docs/ANALYTICS_STORAGE_AND_ADMIN_PLAN.md`) | +| `src/ui/publisher/pages/analytics.ts` | `/publish/analytics` — read-only analytics dashboard over the D1 rollups (open to any active publisher), incl. the MapLibre spatial-attention heatmap (Phase B of `docs/ANALYTICS_STORAGE_AND_ADMIN_PLAN.md`) | +| `src/ui/publisher/pages/feedback.ts` | `/publish/feedback` — read-only feedback review (AI thumbs + bug/feature reports; open to any active publisher) over the D1 feedback tables; replaces the feedback-admin HTML dashboard (Phase C of `docs/ANALYTICS_STORAGE_AND_ADMIN_PLAN.md`) | | `src/ui/publisher/pages/me.ts` | `/publish/me` — current-user identity + role display | | `src/ui/publisher/pages/users.ts` | `/publish/users` — admin-only Users tab: approve / reject / suspend / reactivate publishers and change roles (admin / publisher / readonly) | | `src/ui/tourAuthoring/index.ts` | Tour-authoring public surface — detects `?tourEdit=` and mounts the dock | diff --git a/docs/BACKEND_MODULES.md b/docs/BACKEND_MODULES.md index f35dd5800..1e5c1d9dc 100644 --- a/docs/BACKEND_MODULES.md +++ b/docs/BACKEND_MODULES.md @@ -213,6 +213,7 @@ design rationale in the `docs/CATALOG_*` plan docs. | `functions/api/v1/_lib/preview-token.ts` | Short-lived signed preview tokens for unpublished datasets and tours | | `functions/api/v1/_lib/publisher-mutations.ts` | Admin user-administration store helper — list / get / update publishers, self-lockout + last-admin guardrails | | `functions/api/v1/_lib/publisher-store.ts` | D1 reader / writer for the `publishers` table | +| `functions/api/v1/_lib/capabilities.ts` | Server-side capability check (`can` / `canOwnOrAny`) — thin adapter over the shared role→capability matrix (`src/types/publisher-roles.ts`); the authorization primitive routes/mutations gate on (`docs/PUBLISHER_ROLES_PLAN.md`) | | `functions/api/v1/_lib/r2-public-url.ts` | Build a publicly-readable URL for an R2 object key | | `functions/api/v1/_lib/image-upload.ts` | Shared validation for small direct image uploads (base64-in-JSON): png/jpeg/webp allowlist, magic-byte check against the claimed type, bounded decode, sha256 for content-addressed keys — used by the node-profile logo and tour-media routes | | `functions/api/v1/_lib/r2-store.ts` | R2 storage helpers — Phase 1b | diff --git a/docs/CATALOG_PUBLISHING_TOOLS.md b/docs/CATALOG_PUBLISHING_TOOLS.md index b192ca6f7..98a9786b8 100644 --- a/docs/CATALOG_PUBLISHING_TOOLS.md +++ b/docs/CATALOG_PUBLISHING_TOOLS.md @@ -704,11 +704,16 @@ Phase 3 ships a two-tier human role model behind Cloudflare Access: - Cloudflare Access protects `/publish/**` and `/api/v1/publish/**`. - On first login, the API handler reads the Access JWT and finds an - existing `publishers` row by email or JIT-provisions one. A login - from a `TRUSTED_PUBLISHER_DOMAINS` domain (or dev-bypass) provisions - as `role='admin', status='active'`; any other user login provisions - as `role='publisher', status='pending'`; service tokens as - `role='service', status='active'`. + existing `publishers` row by email or JIT-provisions one. Defaults are + least-privilege: a login from a `TRUSTED_PUBLISHER_DOMAINS` domain + provisions as `role='reviewer', status='active'` (auto-approved, + read-only); any other user login as `role='reviewer', + status='pending'`; service tokens as `role='service', + status='active'`; dev-bypass as `role='admin', status='active'`. The + one exception is the bootstrap: the first human on a deploy with **no + active admin** is provisioned `role='admin', status='active'` (service + tokens excepted) so there is always an operator to approve/promote the + rest. - `affiliation` defaults to the deploying organisation's name (a Wrangler env var) and is editable in the portal's profile page. - **Admins** have full administrative authority over the deploying diff --git a/docs/PUBLISHER_ROLES_PLAN.md b/docs/PUBLISHER_ROLES_PLAN.md new file mode 100644 index 000000000..017e15c61 --- /dev/null +++ b/docs/PUBLISHER_ROLES_PLAN.md @@ -0,0 +1,395 @@ +# Publisher roles & capabilities — scoping + +**Status:** implemented (R1–R5 landed); this doc is now the reference +for the model. All five open decisions were resolved as recommended +(D1 tighter events approval, D2 rename, D3 editor hero, D4 contributor +default, D5 plain drafts). +**Last reviewed:** 2026-07-15 +**Owner:** catalog / publisher-portal track +**Supersedes when:** the role/capability matrix is folded into +`CATALOG_PUBLISHING_TOOLS.md` (the `publisher-store.ts` taxonomy comment +already points here). + +> **Implementation note.** The matrix lives in +> `src/types/publisher-roles.ts` (shared) with the server adapter in +> `functions/api/v1/_lib/capabilities.ts`. `isPrivileged` / `isAdmin` +> are retained as thin aliases (`operator.manage` / `users.manage`). +> Every create surface now gates in the UI on `content.create`: +> events / hero / users as originally specified, plus datasets / blog / +> tours / import, which hide (or, for import, replace with a restricted +> card) their create affordance after a `/me` probe. The datasets probe +> runs on a dedicated `meFetchFn` seam so it doesn't perturb the +> list's fetch-sequence tests. The server 403 remains the authoritative +> gate behind every one of these. + +A design for a WordPress-style five-role model for the publisher +portal, replacing today's effectively-binary (`admin` vs everyone) +authorization with an explicit **capability matrix**. + +This is a scoping artifact, not an implementation. It lays out the +problem, the capability vocabulary, the role→capability matrix, the +per-endpoint mapping, the data model + migration, the portal UI, the +phasing, and the open decisions. + +--- + +## 1. Why + +The recent "read-all / write-own" work (PR #283) opened datasets, +events, and blog to any active publisher while keeping *writes* +owner-scoped. That's the right shape, but it exposed a structural gap: + +- **Authorization is binary.** The only two primitives in the codebase + are `isPrivileged(p)` (`role ∈ {admin, service}`) and `isAdmin(p)` + (`role === 'admin'`). Every gate is one of those two plus an + ownership check. +- **`readonly` is a no-op.** `ASSIGNABLE_ROLES` advertises + `['admin', 'publisher', 'readonly']`, but `readonly` is **never + checked anywhere**. A `readonly` account can create and write its own + datasets/blog/events exactly like a `publisher`. We ship a role that + does nothing. +- **Nothing sits between author and admin.** The only way to let + someone review/publish *other people's* content is to hand them full + admin — which also grants user management, feed connectors, the node + profile, and the signing keys. That's too much power for a "managing + editor." + +WordPress solved this decades ago with a small, legible ladder: +Administrator ▸ Editor ▸ Author ▸ Contributor ▸ Subscriber. Each rung +is a well-understood bundle of capabilities. We adopt the same ladder, +implemented as a capability matrix so the policy lives in one table. + +### Non-goals + +- **Per-object ACLs / sharing.** No "share this dataset with user X." + Access is role + ownership, not per-row grants. +- **Groups / teams beyond `org_id`.** The `publishers.org_id` column + exists and may later scope visibility to an org, but this plan is + about *role* capabilities, not org membership. +- **Federation role propagation.** Roles are node-local. A peer node's + roles are its own; nothing here crosses the federation boundary. +- **A general CMS permission engine.** Fixed capability enum, fixed + role table. No user-defined roles or runtime capability editing. + +--- + +## 2. The five roles + +| Role | WordPress analogue | One-line charter | +|---|---|---| +| **Admin** | Administrator | Everything — content, users, feeds, node profile, keys. | +| **Editor** | Editor | Review/publish/edit **any** content; **no** users/operator settings. | +| **Author** | Author | Create + publish/edit **own** content. | +| **Contributor** | Contributor | Create + edit **own drafts**; **cannot self-publish** — an Editor/Admin approves. | +| **Reviewer** | Subscriber | **Read-only** — sees catalog, queues, insights; authors nothing. | + +Plus one non-assignable machine role: + +- **Service** — machine credential / CLI token. Capability-equivalent + to Admin. Provisioned automatically from an Access service token, + never hand-assigned (unchanged from today). + +### Naming (Decision D2, §8) + +Today's stored role strings are `admin` / `publisher` / `readonly` / +`service`. This plan proposes canonical strings +`admin` / `editor` / `author` / `contributor` / `reviewer` / `service`, +migrating `publisher → author` and `readonly → reviewer` (additive +backfill, same pattern as `0023_publisher_roles_two_tier.sql` which +renamed `staff/community → admin/publisher`). Display labels are i18n +keys, so the wire string and the shown label are already decoupled. + +--- + +## 3. Capability vocabulary + +A capability is a verb the server checks, independent of role. The +matrix maps roles to capability sets; gates ask `can(publisher, cap)`, +never `role === '...'`. + +| Capability | Meaning | +|---|---| +| `content.read` | Read the catalog, the events queue, the blog list, and any single row/draft. | +| `content.create` | Create a new **own** draft (dataset / event / blog / tour). | +| `content.edit.own` | Edit/delete a row you own. | +| `content.publish.own` | Publish/retract (blog, dataset) or **approve** (event) a row you own. | +| `content.edit.any` | Edit/delete **any** row, regardless of owner. | +| `content.publish.any` | Publish/retract/approve/reject **any** row, incl. claiming an unclaimed event. | +| `insights.read` | Read Analytics + Feedback dashboards. | +| `hero.read` | See the current "Right now" hero pin. | +| `hero.manage` | Set/clear the hero override. | +| `operator.manage` | Feed connectors, node profile, R2/key config, feed Refresh. | +| `users.manage` | Approve/suspend publishers, assign roles (the Users tab). | + +Ownership-aware gates compose a capability with an ownership test, e.g. + +``` +canEditDataset(p, row) = + can(p, 'content.edit.any') || + (can(p, 'content.edit.own') && row.publisher_id === p.id) +``` + +This is the same shape as today's `canMutateDataset` / +`canMutateBlogPost` / `canMutateEvent`, but the privileged half becomes +a capability check instead of `isPrivileged`. + +--- + +## 4. Role → capability matrix + +`●` = granted, `–` = denied. + +| Capability | Reviewer | Contributor | Author | Editor | Admin | +|---|:--:|:--:|:--:|:--:|:--:| +| `content.read` | ● | ● | ● | ● | ● | +| `insights.read` | ● | ● | ● | ● | ● | +| `hero.read` | ● | ● | ● | ● | ● | +| `content.create` | – | ● | ● | ● | ● | +| `content.edit.own` | – | ● | ● | ● | ● | +| `content.publish.own` | – | – | ● | ● | ● | +| `content.edit.any` | – | – | – | ● | ● | +| `content.publish.any` | – | – | – | ● | ● | +| `hero.manage` | – | – | – | ●¹ | ● | +| `operator.manage` | – | – | – | – | ● | +| `users.manage` | – | – | – | – | ● | + +¹ `hero.manage` for Editor is **Decision D3** (§8) — the current +behavior is admin-only. Recommendation: grant it to Editor (the hero is +an editorial curation call), but it is the one cell reasonable people +will disagree on. + +Service = Admin's column. + +**Reading the ladder:** each role is a strict superset of the one to +its left, except that Editor gains the `*.any` tier and Admin adds the +operator/users tier on top. That monotonicity is worth preserving — +it's what makes the model legible. + +--- + +## 5. Per-endpoint mapping + +The gate each route should call after Phase R1. "own-or-any" means the +ownership-composed check from §3. + +### Datasets (`functions/api/v1/publish/datasets*`) +| Route | Today | New gate | +|---|---|---| +| `GET` list / `GET :id` | open to active | `content.read` | +| `POST` (create draft) | open to active | `content.create` | +| `PUT :id` (edit) | owner-or-privileged | edit own-or-any | +| `POST :id/publish`, `/retract` | owner-or-privileged | publish own-or-any | +| `DELETE :id` | owner-or-privileged | delete own-or-any | +| `POST :id/preview` | owner-or-privileged | edit own-or-any | +| `POST :id/reindex` | `isPrivileged` | `content.publish.any` | + +### Events (`functions/api/v1/publish/events*`) +| Route | Today | New gate | +|---|---|---| +| `GET` list | open to active | `content.read` | +| `POST` (manual create) | open to active | `content.create` (creator owns) | +| `POST :id` (review/approve/reject) | unclaimed-or-owner-or-priv | **see D1** | +| `POST :id/image`, `/tour` | unclaimed-or-owner-or-priv | edit own-or-any | +| `POST refresh` (feeds) | `isPrivileged` | `operator.manage` | + +### Blog (`functions/api/v1/publish/blog*`) +| Route | Today | New gate | +|---|---|---| +| `GET` list / `GET :id` | open to active | `content.read` | +| `POST` (create), `/generate` | open to active | `content.create` | +| `PUT :id` (edit) | author-or-priv | edit own-or-any | +| `POST :id` (publish/unpublish) | author-or-priv | publish own-or-any | + +### Tours (`functions/api/v1/publish/tours*`) +Same shape as datasets (create/edit/publish own-or-any). Tours were not +part of PR #283's read-all work; folding them into the capability model +is part of Phase R3/R4. + +### Everything else +| Surface | New gate | +|---|---| +| Analytics, Feedback (GET) | `insights.read` | +| Featured Hero (GET / PUT-DELETE) | `hero.read` / `hero.manage` | +| Feeds console, Node profile, key/R2 config | `operator.manage` | +| Users tab | `users.manage` | + +--- + +## 6. The events-approval reconciliation (Decision D1) + +This is the one place the five-role model **changes behavior we just +shipped**, so it gets its own section. + +PR #283 decided: *any active publisher may approve an unclaimed +(feed-proposed) event, thereby claiming ownership.* Under a role ladder +where **publishing is a privilege that grows with rank**, that's too +generous — approving an event makes it public, and "make content you +don't own public" is exactly `content.publish.any` (Editor+). + +Recommended reconciliation: + +- **Contributor** — may `POST` a manual event (it's a draft/proposed + row they own) but **cannot approve** anything, including their own. +- **Author** — may approve/reject an event **they own** (their manual + event) via `content.publish.own`. May **not** approve an unclaimed + feed event (that needs `.any`). +- **Editor / Admin** — may approve/reject **any** event via + `content.publish.any`, which is also what claims an unclaimed feed + event. Approving still stamps `owner_id` (the claim), so the audit + trail of "who made it public" is preserved. + +Net effect: the feed review queue becomes an **Editor** responsibility +(matches "the managing editor runs the newsroom"), while Authors and +Contributors propose. This is a one-line change to `canMutateEvent` +plus the approve-branch in the review handler, and an update to the +events route tests added in PR #283. + +> If the team prefers the shipped egalitarian-queue behavior (any +> author can claim + approve a feed event), that's the alternative for +> D1 — grant `content.publish.any` on unclaimed-owner rows to Author. +> The doc recommends the tighter reading for ladder consistency. + +--- + +## 7. Implementation shape + +### 7.1 Capability layer (server) + +A new pure module `functions/api/v1/_lib/capabilities.ts`: + +```ts +export type Capability = 'content.read' | 'content.create' | … +export const ROLE_CAPABILITIES: Record> = { … } +export function can(publisher: PublisherRow, cap: Capability): boolean +``` + +- `isPrivileged` / `isAdmin` become thin, deprecated aliases + (`can(p, 'content.publish.any')` / `can(p, 'users.manage')`) so + Phase R1 is a **no-behavior-change** refactor with the existing tests + still green. +- The matrix is unit-tested as a table (role × capability), so a + future edit to one cell is a visible, reviewed diff. + +### 7.2 Shared role→capability constant (both tiers) + +The portal needs to gate controls the same way the server gates +requests. Mirror the `src/types/node-features.ts` pattern: a shared +constant (`src/types/publisher-roles.ts`) with the role list + the +capability map + a `can(role, cap)` helper, imported by both +`functions/` and `src/ui/publisher/`. `GET /api/v1/publish/me` returns +the caller's `role` (it already does) and gains a derived +`capabilities: string[]` so the portal never hard-codes the matrix. + +### 7.3 Data model / migration + +- `0039_publisher_roles_five.sql` — no schema change (the `role` column + has no CHECK constraint, by design; see + `0005_publishers_audit.sql`). Additive backfill only: + `UPDATE publishers SET role='author' WHERE role='publisher';` + `UPDATE publishers SET role='reviewer' WHERE role='readonly';` + New rows (`editor`, `contributor`) need no backfill. `is_admin` stays + the synced mirror of `role='admin'`. +- `ASSIGNABLE_ROLES` → `['admin','editor','author','contributor','reviewer']`. +- Provisioning defaults tightened to least-privilege (**Decision D4**, + §8): untrusted-domain logins → `reviewer`/`pending`; trusted-domain + logins → `reviewer`/`active` (auto-approved, no longer auto-admin); + the first human on a deploy with no active admin is bootstrapped to + `admin`/`active` so a fresh deploy still has an operator. + +### 7.4 Portal UI + +- **Users tab** (`pages/users.ts`) — the role `