Skip to content

publisher: role-based access — read-all/write-own + WordPress-style five-role capability model#283

Merged
Hackshaven merged 25 commits into
mainfrom
claude/admiring-planck-rcqxur
Jul 15, 2026
Merged

publisher: role-based access — read-all/write-own + WordPress-style five-role capability model#283
Hackshaven merged 25 commits into
mainfrom
claude/admiring-planck-rcqxur

Conversation

@Hackshaven

@Hackshaven Hackshaven commented Jul 14, 2026

Copy link
Copy Markdown
Member

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)

  • The whole node catalog + events queue + blog list are readable by every active publisher; writes stay owner-scoped.
  • Ownership: datasets/tours by creation, blog by drafting (author_id), events by approval or manual creation (migration 0038 adds current_events.owner_id).
  • Per-row can_edit / can_publish drive the portal controls.

Read-only insights

  • Featured Hero, Analytics, Feedback moved from "visible but 403" to genuinely read-only for publishers; their mutations stay privileged.

Five-role capability model (docs/PUBLISHER_ROLES_PLAN.md)

Replaces isPrivileged/isAdmin binary checks with a shared matrix (src/types/publisher-roles.ts + functions/…/capabilities.ts):

Role Capability charter
Admin Everything incl. users + operator settings
Editor Publish/edit any content + hero; no users/operator
Author Create + publish/edit own content
Contributor Create + edit own drafts; can't self-publish
Reviewer Read-only (was readonly, previously a no-op)

Rollout (all behind commits roles R1…R5):

  • R1 capability layer, behavior-preserving (isPrivilegedoperator.manage, isAdminusers.manage).
  • R2/R3 migration 0039 renames publisher→author, readonly→reviewer; Reviewer enforced (create endpoints gate content.create); Editor enabled; hero → hero.manage (editors); default new login → contributor.
  • R4 Contributor no-self-publish (publish/retract/approve gate content.publish.*); events approval reconciliation (D1: approving an unclaimed feed event now requires content.publish.any = editor/admin).
  • R5 portal: five-role Users tab, hero editable for editors, contributor Publish-button hiding via can_publish, events New-event gated on content.create.

Decisions D1–D5 resolved as recommended in the plan doc.

Resulting publisher (author) tab access

  • Full (own rows): Datasets, Events, Blog, Workflows, Import, Tours, Overview, Account
  • Read-only: Featured Hero, Analytics, Feedback
  • Admin/operator-only: Feeds, Node profile, Team

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-check chain green (types, i18n, locales, doc-coverage, migrations-additive, protocol-schemas). 2065 publisher + backend tests pass.

Notes

  • Feedback CSV/JSONL export links still target the legacy /api/feedback-admin (separate Access gate) — may 403 for non-admins; left as-is.
  • Datasets/blog "New" buttons rely on the server 403 for reviewers rather than a UI hide (a per-page capability fetch collided with fetch-sequence tests); events/hero/users gate in the UI.

🤖 Generated with Claude Code

https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p

…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>
@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

🖼️ Visual report

68 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.

Scene Viewport Change
browse-search-active desktop 1.59% (20666 px)
publish-datasets desktop 0.15% (1914 px)
publish-dataset-new desktop 0.15% (1914 px)
publish-dataset-edit-thumbnail desktop 0.15% (1914 px)
publish-workflows desktop 0.15% (1914 px)
publish-workflow-new desktop 0.15% (1914 px)
publish-tours desktop 0.22% (2825 px)
publish-me desktop 0.23% (2966 px)
admin-users desktop 2.38% (30847 px)
publish-datasets-empty desktop 0.15% (1914 px)
publish-datasets-error desktop 0.15% (1914 px)
publish-workflows-empty desktop 0.15% (1914 px)
+4 more
Shots with problems
Scene Viewport Errors Failed req a11y
tools-menu desktop 1 2 0
publish-workflows desktop 1 0 0
publish-tours desktop 1 1 0
publish-featured-hero desktop 1 1 0
publish-datasets-error desktop 1 1 0
publish-workflows mobile 1 0 0
publish-tours mobile 1 1 0
publish-featured-hero mobile 1 1 0
publish-datasets-error mobile 1 1 0

Full report → visual-report artifact

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>
claude added 3 commits July 14, 2026 22:45
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>
@Hackshaven Hackshaven changed the title publisher: make the whole dataset catalog visible; keep writes owner-scoped publisher: role-based access — read-all / write-own across datasets, events, blog (+ read-only insights) Jul 14, 2026
claude added 6 commits July 15, 2026 15:13
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>
@Hackshaven Hackshaven changed the title publisher: role-based access — read-all / write-own across datasets, events, blog (+ read-only insights) publisher: role-based access — read-all/write-own + WordPress-style five-role capability model Jul 15, 2026
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>
@Hackshaven Hackshaven self-assigned this Jul 15, 2026
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread locales/en.json Outdated
Comment thread functions/api/v1/publish/featured-hero.ts Outdated
Comment thread src/ui/publisher/pages/users.ts Outdated
Comment thread functions/api/v1/_lib/events-store.ts
Comment thread src/ui/publisher/components/events/events-model.ts Outdated
Comment thread src/ui/publisher/components/events/event-detail.ts
Comment thread functions/api/v1/publish/blog/[id].ts
Comment thread CLAUDE.md Outdated
claude added 2 commits July 15, 2026 20:29
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>
@Hackshaven
Hackshaven requested a review from Copilot July 15, 2026 20:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 75 out of 75 changed files in this pull request and generated 4 comments.

Comment thread functions/api/v1/publish/featured-hero.ts
Comment thread functions/api/v1/publish/events/[id]/tour.ts Outdated
Comment thread functions/api/v1/publish/events/[id]/image.ts Outdated
Comment thread src/ui/publisher/pages/users.ts Outdated
- 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 83 out of 83 changed files in this pull request and generated 4 comments.

Comment thread src/ui/publisher/pages/import.ts
Comment thread functions/api/v1/publish/blog.ts Outdated
Comment thread functions/api/v1/publish/blog/[id].ts Outdated
Comment thread docs/PUBLISHER_ROLES_PLAN.md Outdated
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 AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 85 out of 85 changed files in this pull request and generated 3 comments.

Comment thread migrations/catalog/0038_event_owner.sql Outdated
Comment thread functions/api/v1/publish/blog/generate.test.ts Outdated
Comment thread src/ui/publisher/pages/blog-edit.test.ts Outdated
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>
@Hackshaven
Hackshaven merged commit e7c0b72 into main Jul 15, 2026
17 checks passed
@Hackshaven
Hackshaven deleted the claude/admiring-planck-rcqxur branch July 15, 2026 22:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants