publisher: role-based access — read-all/write-own + WordPress-style five-role capability model#283
Conversation
…scoped Previously a `publisher`-role account saw only its own dataset rows (list, detail, and every mutation gated through the same owner-scoped `getDatasetForPublisher`), so a secondary publisher login showed a tiny subset of the catalog an admin sees. Split visibility from ownership: reads are now open to every authenticated publisher — the whole node catalog is listable and readable — while writes (edit / publish / retract / delete / preview / reindex) stay owner-scoped. `admin` / `service` continue to mutate any row. - `dataset-mutations.ts`: `listDatasetsForPublisher` no longer applies the owner scope; add `getDatasetById` (unscoped single-row read) for the detail GET, and `canMutateDataset` as the single source of truth for the write rule. `getDatasetForPublisher` stays the owner-scoped mutation gate, unchanged. - List + detail routes stamp a per-row `can_edit` flag. - Portal: the list shows a View link (not Edit/Retract/Delete) for rows the caller can't mutate; the detail page hides Edit/Preview/Retract for those rows; a non-owner deep-linking to `/edit` is bounced to the read-only detail page instead of a form whose Save would 404. - Tests cover the read-open / write-owner-scoped split at both the mutation layer and the route layer, plus the portal gating. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude <noreply@anthropic.com>
🖼️ Visual report68 shot(s) · 2 viewport(s) (desktop, mobile) · 9 with problems · 17 problem(s) total Regression: 16 shot(s) changed, 0 new (baseline-less, soft pass), threshold 0.001.
Shots with problems
Full report → Advisory — this check never fails the build. Visual review only. |
…ics, feedback These three tabs were visible in the sidebar but each page hard-blocked non-admins with a "restricted" card (and the APIs returned 403), so a publisher-role account could see the links but not the content. Make them genuinely read-only for any active publisher; all mutations stay admin/service-only. - Analytics & Feedback: relax the GET route from `isPrivileged` to any active publisher (the middleware already rejects pending/suspended). Both pages are already read-only (analytics CSV export is built client-side; feedback has no mutation calls), so the frontend just drops the `me`-fetch + restricted-card gate and renders the data. The operator backfill (`analytics-export.ts` POST) stays privileged. - Featured Hero: the write endpoints (PUT/DELETE) remain 403 for non-admins. The page now renders a read-only view for them — the currently-featured dataset as a live preview card plus its window and headline, or an empty state — with no editing controls. - Removed the now-dead `*.restricted` i18n keys for these three; added read-only hero strings. Updated page/route doc comments and the CLAUDE.md module map. - Tests: analytics/feedback route tests now assert 200 (read access) for a publisher-role caller; hero page tests cover the read-only view (empty state + current-pin display). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude <noreply@anthropic.com>
Give publishers the same access to events that they have to datasets:
the whole events queue is readable by any active publisher, but writes
(review, edit image, generate tour) stay owner-scoped.
Events, unlike datasets, are often machine-created by feed connectors
(proposed, unowned), so ownership is assigned by action rather than at
insert:
- Creating a manual event ("+ New event" drawer) makes the creator
its owner.
- Approving an as-yet-unclaimed proposed event claims it for the
approver.
An unclaimed event (owner_id IS NULL) is open for any active publisher
to act on — that's what lets the first approver claim it. Once owned,
only the owner or an admin/service caller may write.
- Migration 0038 adds a nullable `current_events.owner_id` + index.
- events-store: `owner_id` on the row/insert, `canMutateEvent` (the
read-open/write-owner-scoped rule), `claimEventOwner` (set owner only
when NULL, so a later reviewer never steals it).
- Routes: events GET relaxed to any active publisher + per-event
`can_edit`; events POST relaxed + stamps creator as owner; the review,
image, and tour writes gate on `canMutateEvent` (approve also claims
ownership). Feed refresh stays admin-only.
- Tests cover create-owns, approve-claims, non-owner 403, and the
no-ownership-transfer-on-re-review invariant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p
Signed-off-by: Claude <noreply@anthropic.com>
Give publishers the same access to blog posts they have to datasets: the whole authoring list is readable by any active publisher, but writes stay author-scoped. Blog rows already carry `author_id`, so "drafting a post makes them an owner" is inherent — this opens the create/generate paths and gates edit/publish on ownership. - blog-store: `canMutateBlogPost` (author or admin/service). - blog list + detail GET stamp per-post `can_edit`; create (POST) and AI generate are open to any active publisher (author stamped on insert); edit (PUT) and publish/unpublish gate on `canMutateBlogPost` → 403 forbidden_owner for a non-author. - Tests cover publisher-authored drafts, cross-author 403 on edit / publish, and the per-post can_edit flag. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude <noreply@anthropic.com>
…s per-ownership Frontend half of the events/blog read-all / write-own model. - Sidebar: Events and Blog are no longer admin-only (Feeds stays admin-only — it drives the feed connectors). - Blog list: renders for every publisher; the Edit control shows only for posts the caller authored (`can_edit`), others get read-only access via the title link + the public View link when published. - Blog editor: New-post is open to any publisher; opening a post the caller doesn't own renders a view-only page (title + status + sanitized markdown body) instead of the form. - Events page: renders the whole queue for every publisher; the feed-Refresh action is admin-only, New event stays open to all. - Event detail: gates every write affordance on the event's `can_edit` — approve/reject, image upload, media suggestions, metadata edit, bulk-approve, generate-tour, add-dataset, and the per-pairing ✓/✕ buttons — showing a view-only notice and display-only pairing rows for events the caller can't mutate. - Wire types carry `can_edit`; removed the now-dead `*.restricted` strings, added read-only notices. Tests updated across sidebar, blog list/editor, and the events page (read-only + admin-only-refresh). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude <noreply@anthropic.com>
A design for a WordPress-style five-role model (Admin / Editor / Author / Contributor / Reviewer) replacing today's effectively-binary authorization, implemented as an explicit role→capability matrix. Captures the finding that `readonly` is currently a no-op (never checked anywhere), the capability vocabulary, the role matrix, per-endpoint mapping, the data model + additive migration, the portal UI, phasing (R1 behavior-preserving → R5 polish), and the open decisions — notably D1, that under a publish-is-a-privilege ladder, approving an unclaimed event should require content.publish.any (Editor+), refining the interim "any publisher approves unclaimed" behavior from PR #283. Design doc only — no code changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude <noreply@anthropic.com>
First phase of the WordPress-style role model (docs/PUBLISHER_ROLES_PLAN.md).
Introduces the role→capability matrix as the single source of truth,
with zero behavior change for the current roles.
- src/types/publisher-roles.ts — shared, dependency-free matrix
(CAPABILITIES / ROLES / ROLE_CAPABILITIES) + roleCan / capabilitiesForRole
/ normalizeRole (legacy publisher→author, readonly→reviewer; fail-closed
to reviewer). Imported by both functions/ and the portal.
- functions/_lib/capabilities.ts — server can() / canOwnOrAny() adapter.
- isPrivileged → can(operator.manage), isAdmin → can(users.manage): held
by exactly {admin,service} and {admin} respectively, so every existing
gate is behavior-identical. Content ownership helpers (canMutateDataset
/ canMutateBlogPost / canMutateEvent + the dataset mutation-visibility
scope) now compose content.edit.{own,any} instead of isPrivileged.
- GET /publish/me returns the caller's resolved `capabilities`.
- Matrix is unit-tested as an exhaustive truth table; capabilities helper
tested; module maps updated.
The matrix already defines editor/contributor and (for now) mirrors
reviewer to author to preserve the unenforced-readonly behavior; later
phases flip individual cells + rename the stored role strings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p
Signed-off-by: Claude <noreply@anthropic.com>
Activates the middle of the five-role ladder (docs/PUBLISHER_ROLES_PLAN.md). - Migration 0039 renames publisher→author, readonly→reviewer (additive, pattern of 0023). New editor/contributor roles need no backfill. - Matrix: reviewer becomes a true read-only role (content.read + insights.read + hero.read, authors nothing). ASSIGNABLE_ROLES now the canonical five; the Users-tab validation follows the shared list. - Reviewer is enforced: the create endpoints (datasets / blog / blog-generate / events / tours POST) now gate on content.create, so a read-only reviewer is refused (403 forbidden_role) instead of silently being allowed to author. - Editor is enabled: it already gains content.*.any through the R1 ownership helpers (edit/publish any dataset/blog/event), and the hero write endpoints move from isPrivileged → hero.manage (decision D3), so Editors can pin the "Right now" hero while Authors cannot. - Default role for a new untrusted-domain login is now contributor (least-privilege, decision D4). - Tests: matrix reviewer row flipped to read-only; reviewer-create 403; editor-hero 200; provisioning + user-admin expectations updated to the canonical names. Contributor's no-self-publish gate + the events approval reconciliation (D1) land in R4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude <noreply@anthropic.com>
…n (D1)
Completes the five-role ladder's write semantics.
Contributor (create + edit.own, no publish):
- The publish/retract endpoints (datasets) and publish/unpublish
(blog) now require content.publish.{own,any} in addition to the
ownership/edit gate. A contributor can create and edit its own
drafts but gets 403 on publish; an author publishes its own, an
editor/admin publishes any.
Events approval (decision D1):
- canMutateEvent drops the unclaimed free pass — editing an event now
needs edit.own on an owned row or edit.any; an unclaimed feed event
is Editor territory.
- New canReviewEvent gates approve/reject on content.publish.{own,any}.
The review handler is two-tier: baseline edit (canMutateEvent) for
metadata edits, plus a publish check when the submit carries an
event/link decision. So a contributor may correct its own event's
metadata but not approve it; an author approves its own manual event;
approving an unclaimed feed event requires publish.any (editor/admin),
which also claims it. This supersedes PR #283's "any publisher
approves unclaimed".
- Tests: contributor create/edit-yes-publish-no (datasets); the events
approval matrix (editor claims unclaimed, author own-only, contributor
edit-not-approve, cross-owner 403).
Portal gating for these tiers lands in the next (frontend) commit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p
Signed-off-by: Claude <noreply@anthropic.com>
Frontend for the five-role model. - Users tab: the role picker now offers the canonical five (admin/editor/author/contributor/reviewer) from the shared ASSIGNABLE_ROLES, pre-selecting the caller's normalized role; legacy publisher/readonly rows still display with their own labels. - me + users role labels: added editor/author/contributor/reviewer i18n strings; me.ts's localizedRole switches on the raw stored string (legacy strings keep their labels, unknown roles show verbatim rather than being mislabeled as the fail-closed reviewer). - Featured Hero: editable for hero.manage holders (editor + admin), not just admins — authors and below get the existing read-only view. - Contributor publish-hiding: dataset detail + list responses stamp `can_publish` (author-own or editor/admin-any); the detail Publish/ Retract control and the list Retract are gated on it, so a contributor sees Edit on its own draft but no Publish (the server also 403s). - Events "New event" is gated on content.create via the role the page already fetches (reviewers get neither Refresh nor New event). Create-button hiding on the datasets/blog pages is left to the server 403 for now — adding a per-page capability fetch there collided with tests that assert exact fetch sequences; not worth the churn for a cosmetic gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude <noreply@anthropic.com>
Status → implemented; records the resolved decisions (D1–D5 as recommended) and the one deviation (datasets/blog create-button hiding left to the server 403). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude <noreply@anthropic.com>
Fixes three concrete gaps in the five-role model surfaced by a
security review of the branch.
1. Reviewer tour-authoring escalation (high). `tours/draft.ts` POST
lacked the `content.create` gate its sibling create endpoints have,
so a read-only reviewer could mint a draft tour it owns, then author
(`/json`, `/media`) and publish it live through the ownership-gated
tour routes — fully defeating the reviewer restriction. Added the
gate.
2. Event content overwrite via the idempotent upsert (high). The events
POST create path (relaxed from admin/service to any authoring role)
runs `ingestEvent`, which is idempotent on `(feedId, externalId)` and
overwrites an existing event's content with NO ownership check. Since
`parseCreate` reads those keys from the request body and `feedId` is
exposed via the now-open queue, an author/contributor could rewrite a
curator-approved public event's title/source/image/geometry. Now the
feed key is honored only for `content.publish.any` holders
(editor/admin/service); lower roles always mint a fresh, feed-less
manual event they own.
3. Datasets PUT/DELETE ownership-only gate (medium). Both gated on
`getDatasetForPublisher` (ownership) but not an edit capability, so a
reviewer demoted after authoring could still edit/delete its own
rows. Added `canMutateDataset`, matching blog/events.
Also gated tour publish/retract on `content.publish.{own,any}` (they
were ownership-only, letting a contributor publish its own tour).
Regression tests added for each.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p
Signed-off-by: Claude <noreply@anthropic.com>
Migration 0038 added current_events.owner_id + idx_current_events_owner but the checked-in migrations/catalog-schema.sql snapshot and the EXPECTED_INDEXES list in scripts/seed-catalog.test.ts were not regenerated, failing the schema-drift unit test in CI (the full `npm run test` covers scripts/, which the per-package runs I'd used locally did not). - Ran `npm run db:dump-schema` to refresh the snapshot. - Added idx_current_events_owner to EXPECTED_INDEXES (sorted position). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude <noreply@anthropic.com>
Adds a collapsible "What each role can do" reference at the top of the Users/Team tab so an admin sees exactly what a role grants before assigning it. - The table is built straight from the shared role→capability matrix (src/types/publisher-roles.ts) via roleCan, so it can never drift from what the server enforces. Columns are the five assignable roles ordered least → most privileged (the matrix is strictly cumulative); rows are the meaningful capability groups, each cell a yes/no mark with a translated aria-label. - Collapsible <details> so it stays out of the way; new i18n strings + a small CSS block. Test asserts the reviewer column is read-only and the admin column grants everything. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude <noreply@anthropic.com>
The publisher.team.rolesNote string still described the old
two-role split ('Publishers create and publish content'). Update
it to reflect the five assignable roles (admin, editor, author,
contributor, reviewer) so it reads consistently next to the new
role→capability guide on the Team tab.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p
Signed-off-by: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Implements a shared, WordPress-style publisher role → capability matrix across the portal UI and Cloudflare Pages Functions backend, and applies a consistent read-all / write-own authorization model across datasets, events, blog, and selected read-only “insights” pages.
Changes:
- Added a shared cross-tier capability matrix (
src/types/publisher-roles.ts) with server adapter helpers (functions/api/v1/_lib/capabilities.ts) and updated endpoints/UI gating to use capabilities + ownership. - Opened read access broadly (catalog, events queue, blog list, analytics, feedback) while stamping per-row permission hints (
can_edit,can_publish) to drive portal affordances. - Added migrations + schema/index updates for event ownership, plus extensive backend and portal test coverage for role/capability gates.
Reviewed changes
Copilot reviewed 74 out of 74 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/ui/publisher/types.ts | Adds per-dataset can_edit / can_publish flags for UI gating. |
| src/ui/publisher/pages/users.ts | Expands role selection to five roles and adds a role→capability guide table. |
| src/ui/publisher/pages/users.test.ts | Tests for the role→capability guide rendering. |
| src/ui/publisher/pages/me.ts | Updates role localization for new roles while preserving legacy labels. |
| src/ui/publisher/pages/feedback.ts | Removes privileged client gate; makes feedback page read-only for all publishers. |
| src/ui/publisher/pages/featured-hero.ts | Adds read-only hero view for non-hero.manage callers; gates writes via capability. |
| src/ui/publisher/pages/featured-hero.test.ts | Tests read-only hero rendering and privileged form behavior. |
| src/ui/publisher/pages/events.ts | Makes events queue readable to all publishers; gates toolbar actions via role/capabilities. |
| src/ui/publisher/pages/events.test.ts | Tests queue read access and per-event read-only behavior via can_edit. |
| src/ui/publisher/pages/datasets.ts | Hides mutation controls for non-editable rows; adds “View” affordance. |
| src/ui/publisher/pages/datasets.test.ts | Tests “View-only row” behavior when can_edit is false. |
| src/ui/publisher/pages/dataset-edit.ts | Redirects deep-linked non-owners away from edit form when can_edit is false. |
| src/ui/publisher/pages/dataset-edit.test.ts | Tests edit-page redirect when can_edit is false. |
| src/ui/publisher/pages/dataset-detail.ts | Gated Edit/Preview/Publish/Retract controls using can_edit/can_publish. |
| src/ui/publisher/pages/dataset-detail.test.ts | Tests hiding mutation/publish controls based on can_edit/can_publish. |
| src/ui/publisher/pages/blog.ts | Makes blog list readable to all; gates edit link per post via can_edit. |
| src/ui/publisher/pages/blog-edit.ts | Adds read-only rendering for non-owned posts; removes privileged gating. |
| src/ui/publisher/pages/blog-edit.test.ts | Tests open “new post” access and read-only view for non-owned posts. |
| src/ui/publisher/pages/analytics.ts | Removes privileged client gate; makes analytics page read-only for all publishers. |
| src/ui/publisher/components/sidebar.ts | Makes Events/Blog visible for all publishers in nav (read-all/write-own). |
| src/ui/publisher/components/sidebar.test.ts | Updates nav/badge tests for Events/Blog visibility changes. |
| src/ui/publisher/components/events/events-model.ts | Adds can_edit on event wire model for portal gating. |
| src/ui/publisher/components/events/event-detail.ts | Hides write affordances when can_edit is false; introduces read-only notice. |
| src/types/publisher-roles.ts | Introduces shared role/capability matrix + normalization utilities. |
| src/types/publisher-roles.test.ts | Exhaustive truth-table tests for role→capability grants. |
| src/styles/publisher.css | Adds styling for the role→capability guide table. |
| scripts/seed-catalog.test.ts | Updates expected indexes to include current_events.owner_id index. |
| migrations/catalog/0039_publisher_roles_five.sql | Renames legacy roles publisher→author, readonly→reviewer. |
| migrations/catalog/0038_event_owner.sql | Adds current_events.owner_id + index to support write-own events. |
| migrations/catalog-schema.sql | Updates schema snapshot with owner_id column + index. |
| locales/en.json | Adds/updates strings for new roles, role guide, and read-only surfaces. |
| functions/api/v1/publish/tours/draft.ts | Gates tour draft creation on content.create. |
| functions/api/v1/publish/tours/draft.test.ts | Tests reviewer 403 for tour draft creation. |
| functions/api/v1/publish/tours/[id]/retract.ts | Gates tour retract as publish-tier (content.publish.*) with ownership composition. |
| functions/api/v1/publish/tours/[id]/publish.ts | Gates tour publish as publish-tier (content.publish.*) with ownership composition. |
| functions/api/v1/publish/tours.ts | Gates tour create on content.create. |
| functions/api/v1/publish/publishers.test.ts | Updates role strings in user-management tests to canonical names. |
| functions/api/v1/publish/me.ts | Adds resolved capabilities list to /publish/me response. |
| functions/api/v1/publish/feedback.ts | Removes privileged backend gate; makes feedback read-only for all publishers. |
| functions/api/v1/publish/feedback.test.ts | Updates tests for feedback read access. |
| functions/api/v1/publish/featured-hero.ts | Gates hero mutation endpoints via hero.manage. |
| functions/api/v1/publish/featured-hero.test.ts | Adds editor coverage for hero manage and updates role fixtures. |
| functions/api/v1/publish/events/[id]/tour.ts | Converts from privileged gate to owner-scoped gate for event tour generation. |
| functions/api/v1/publish/events/[id]/tour.test.ts | Tests owner-scoped 403 for event tour generation. |
| functions/api/v1/publish/events/[id]/image.ts | Converts from privileged gate to owner-scoped gate for event image upload. |
| functions/api/v1/publish/events/[id]/image.test.ts | Tests owner-scoped 403 for event image upload and seeds FK owners. |
| functions/api/v1/publish/events/[id].ts | Implements D1: edit vs publish-tier gates; claims owner on approve; stamps can_edit. |
| functions/api/v1/publish/events/[id].test.ts | Adds multi-role tests for D1 (editor claim, author own-approve, contributor edit-only). |
| functions/api/v1/publish/events.ts | Makes queue readable; gates create on content.create; stamps can_edit; protects feed upsert. |
| functions/api/v1/publish/events.test.ts | Updates tests for read-open and create-owns behavior + feed upsert protection. |
| functions/api/v1/publish/datasets/[id]/retract.ts | Gates dataset retract as publish-tier (content.publish.*). |
| functions/api/v1/publish/datasets/[id]/publish.ts | Gates dataset publish as publish-tier (content.publish.*). |
| functions/api/v1/publish/datasets/[id].ts | Makes dataset detail read-open; stamps can_edit/can_publish; gates PUT with edit capability. |
| functions/api/v1/publish/datasets.ts | Makes dataset list read-open; stamps can_edit/can_publish; gates create on content.create. |
| functions/api/v1/publish/datasets.test.ts | Adds broad read-open/write-own tests, reviewer demotion checks, contributor publish denial. |
| functions/api/v1/publish/blog/generate.ts | Gates blog draft generation on content.create. |
| functions/api/v1/publish/blog/generate.test.ts | Updates tests to allow publisher-role authoring helper access. |
| functions/api/v1/publish/blog/[id].ts | Stamps can_edit on GET; gates PUT by ownership+edit capability; gates publish by ownership+publish capability. |
| functions/api/v1/publish/blog.ts | Makes blog list read-open with can_edit stamping; gates create on content.create. |
| functions/api/v1/publish/blog.test.ts | Adds tests for create-owns and owner-scoped edit/publish 403s. |
| functions/api/v1/publish/analytics.ts | Removes privileged backend gate; makes analytics read-only for all publishers. |
| functions/api/v1/publish/analytics.test.ts | Updates tests for analytics read access. |
| functions/api/v1/_lib/publisher-store.ts | Updates role taxonomy docs; re-exports assignable roles from shared matrix; defaults new logins to contributor; rewires isPrivileged/isAdmin via capabilities. |
| functions/api/v1/_lib/publisher-store.test.ts | Updates defaults tests for contributor role. |
| functions/api/v1/_lib/events-store.ts | Adds owner_id to event row/insert; adds canMutateEvent/canReviewEvent/claimEventOwner. |
| functions/api/v1/_lib/events-store.test.ts | Updates toPublicEvent fixtures for new owner_id field. |
| functions/api/v1/_lib/dataset-mutations.ts | Splits read-open (getDatasetById) from write-gated (getDatasetForPublisher); adds canMutateDataset; removes list scoping. |
| functions/api/v1/_lib/dataset-mutations.test.ts | Updates list semantics tests and adds read-open/write-own helper tests. |
| functions/api/v1/_lib/capabilities.ts | Adds server-side can and canOwnOrAny adapter over shared matrix. |
| functions/api/v1/_lib/capabilities.test.ts | Tests capability resolution (incl. legacy role aliases) and ownership composition helper. |
| functions/api/v1/_lib/blog-store.ts | Adds canMutateBlogPost helper using ownership+capability composition. |
| docs/PUBLISHER_ROLES_PLAN.md | Adds/updates canonical plan doc as implemented reference and decision record. |
| docs/BACKEND_MODULES.md | Adds capabilities.ts to backend module documentation list. |
| CLAUDE.md | Updates module map with publisher role matrix and revised page access notes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Addresses PR review comments where copy, docs, and comments still described the pre-final access model: - hero read-only notice + CLAUDE.md said the featured hero is admin-only, but hero.manage is granted to editors (and service). - events-store canMutateEvent and the events-model can_edit comment said unclaimed events are open to any active publisher, but a null owner requires content.edit.any (editor/admin/service) via canOwnOrAny. - localizedRole in the Users tab switched on the normalized role, so a genuinely unknown/future role would be mislabelled as reviewer; switch on the raw string with explicit legacy aliases and a verbatim default, matching pages/me.ts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude <noreply@anthropic.com>
… error Two read-all/write-own refinements from PR review: - The event detail pane hid an attached agency video entirely when the caller could not edit, breaking the read half of read-all/write-own. Render the video frame for everyone; gate only the Remove control on edit rights. Adds a regression test. - The blog (un)publish gate returned forbidden_owner for every failure, including an owner who lacks a publishing capability (e.g. a contributor). Return forbidden_role in that case and reserve forbidden_owner for non-owners without content.publish.any. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude <noreply@anthropic.com>
- featured-hero 403 message named only editor/admin; hero.manage is also held by service. Name all three so API consumers aren't misled. - tour.ts and image.ts event-write gates carried the same stale 'unclaimed events are open' comment fixed earlier in events-store; correct them to note unclaimed events require content.edit.any. - users.ts had its own localizedRole that mapped legacy publisher/ readonly to the canonical Author/Reviewer labels, diverging from the exported localizedRole in pages/me.ts (which preserves the legacy Publisher/Read-only labels). Import the shared one and drop the duplicate so role naming is consistent across the portal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude <noreply@anthropic.com>
… labels An account still stored with the legacy role string showed two different role names: the profile chip + sidebar footer (me.ts localizedRole) rendered the retired 'Publisher' / 'Read-only' labels, while the Users tab showed the normalized 'Author' / 'Reviewer'. Since those roles were renamed (migration 0039) and no longer exist as a concept, the old names should never surface. Map the legacy publisher/readonly strings to the canonical Author/Reviewer labels so every surface agrees, even while an un-migrated row still carries the legacy string. Retire the now-dead role.publisher / role.readonly locale keys + explanations. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude <noreply@anthropic.com>
A reviewer (read-only) saw the full Import UI — dropzone, method cards, template download — with no indication that importing would fail, since import creates draft datasets and reviewers lack content.create. Gate the page the same progressive way it already handles the feature toggle: render synchronously, then swap in a restricted card once /me resolves as a role without content.create (reviewer). Roles that can create (contributor / author / editor / admin / service) are unaffected. Fail-open on a /me fetch error — the server stays the authoritative gate, and the future bulk-import endpoint must enforce the same capability (noted in a comment). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude <noreply@anthropic.com>
A reviewer saw "+ New tour" and "New post" buttons and, on Tours,
clicking dumped the raw 403 JSON envelope
({"error":"forbidden_role","message":"…"}) onto the page.
- Gate the New-tour and New-post buttons on content.create (the same
capability Events already gates on), so reviewers don't see a create
affordance that only 403s. Progressive + fail-open on the /me fetch —
the server stays the authoritative gate.
- Fix the tour API client's errorLabel: parse the server's
{ error, message } envelope and surface the human message instead of
the raw JSON body, so any create/publish/retract/delete error reads
cleanly as a backstop.
Datasets' New button is intentionally left for a follow-up: its tests
use a URL-agnostic fetch mock with exact call-sequence assertions, so
adding a /me gate there needs test rework.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p
Signed-off-by: Claude <noreply@anthropic.com>
Completes the create-surface consistency pass: Datasets now hides its New-draft button for a reviewer (no content.create), matching Events, Tours, Blog, and Import. Datasets' list tests chain mockResolvedValueOnce on fetchFn and assert exact call counts/order, so the role probe runs on a dedicated meFetchFn seam (not fetchFn) and is progressive + fail-open: the button shows by default and is dropped only when /me resolves as a non-authoring role. Existing list-call-count assertions are therefore untouched; added reviewer/author gate tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude <noreply@anthropic.com>
Addresses Copilot's fourth review round: - import.ts ran fetchFeatures() and the /me role gate as two independent fire-and-forget probes, so a later-resolving role gate could overwrite the feature-disabled card (or vice versa) depending on timing. Sequence them: check the feature first and only run the role gate when the feature is enabled, so the disabled card wins. Mock fetchFeatures in the test so the sequenced chain is deterministic (it otherwise does a live no-store read). - blog.ts POST docstring said 'Open to any active publisher' — it gates on content.create; reviewers 403. - blog/[id].ts header said only the author 'or an admin' may edit — edit uses content.edit.any (author's own, or editor/admin/service), and publish uses the content.publish tier. - PUBLISHER_ROLES_PLAN.md implementation note still said datasets/blog create-button hiding was left to the server 403; every create surface now gates client-side on content.create. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude <noreply@anthropic.com>
First-login provisioning previously auto-promoted whole trusted domains to admin (a pre-roles default that would silently make every colleague an admin), and defaulted other logins to contributor. Tighten to least-privilege: - untrusted-domain login -> reviewer / pending (was contributor) - trusted-domain login -> reviewer / active (was admin; now auto-approved but read-only, promoted from the Users tab as needed) - service token -> service / active (unchanged) - dev-bypass -> admin / active (unchanged) To keep a fresh deploy operable without the domain-wide-admin hack, add a bootstrap: the first human on a deploy with no active admin is provisioned admin/active. It excludes service tokens, and because the last-active-admin guardrail keeps the admin count from ever reaching zero through normal operations, it cannot re-fire as an escalation path. Gated on 'no active admin' (not 'empty table') so a service-token-first deploy still bootstraps its first human. Updates the taxonomy comment, publisher-store tests (seed an admin for the steady-state default cases; add bootstrap cases), and the SELF_HOSTING / PUBLISHER_ROLES_PLAN (D4) / CATALOG_PUBLISHING_TOOLS docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude <noreply@anthropic.com>
Copilot's fifth review round — three accuracy fixes, no behavior change: - migrations/catalog/0038 comment said unclaimed events are 'open for any active publisher to act on'; the enforced rule (canMutateEvent / canReviewEvent) requires content.edit.any to edit an unclaimed event and content.publish.any to claim/review it. Comment-only; the additive-migrations check and schema snapshot are unaffected. - blog/generate.test.ts title claimed generation is 'open to any active publisher'; it gates on content.create (reviewers refused). - blog-edit.test.ts title said 'create is open'; the editor renders with no client-side role gate, but the server enforces content.create on save. Retitled to say so. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude <noreply@anthropic.com>
Summary
Reworks publisher authorization from an effectively-binary (admin vs. everyone) model into an explicit role → capability matrix with five WordPress-style roles, and applies a consistent read-all / write-own access model across datasets, events, and blog. Delivered in reviewable commits.
Access model (datasets / events / blog)
author_id), events by approval or manual creation (migration0038addscurrent_events.owner_id).can_edit/can_publishdrive the portal controls.Read-only insights
Five-role capability model (
docs/PUBLISHER_ROLES_PLAN.md)Replaces
isPrivileged/isAdminbinary checks with a shared matrix (src/types/publisher-roles.ts+functions/…/capabilities.ts):readonly, previously a no-op)Rollout (all behind commits
roles R1…R5):isPrivileged→operator.manage,isAdmin→users.manage).0039renamespublisher→author,readonly→reviewer; Reviewer enforced (create endpoints gatecontent.create); Editor enabled; hero →hero.manage(editors); default new login →contributor.content.publish.*); events approval reconciliation (D1: approving an unclaimed feed event now requirescontent.publish.any= editor/admin).can_publish, events New-event gated oncontent.create.Decisions D1–D5 resolved as recommended in the plan doc.
Resulting publisher (author) tab access
Tests
Capability matrix asserted as an exhaustive truth table; per-endpoint gate tests across all five roles (create/edit/publish/approve, ownership composition, cross-owner 403s); portal component tests. Full
type-checkchain green (types, i18n, locales, doc-coverage, migrations-additive, protocol-schemas). 2065 publisher + backend tests pass.Notes
/api/feedback-admin(separate Access gate) — may 403 for non-admins; left as-is.🤖 Generated with Claude Code
https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p