From a15bae7f59da45945628d954abfb2384a6c7f38c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 21:02:44 +0000 Subject: [PATCH 01/25] publisher: make the whole dataset catalog visible; keep writes owner-scoped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude --- .../api/v1/_lib/dataset-mutations.test.ts | 42 +++++- functions/api/v1/_lib/dataset-mutations.ts | 74 ++++++++-- functions/api/v1/_lib/publisher-store.ts | 9 +- functions/api/v1/publish/datasets.test.ts | 116 +++++++++++++++- functions/api/v1/publish/datasets.ts | 8 +- functions/api/v1/publish/datasets/[id].ts | 20 ++- locales/en.json | 2 + src/ui/publisher/pages/dataset-detail.test.ts | 14 ++ src/ui/publisher/pages/dataset-detail.ts | 129 ++++++++++-------- src/ui/publisher/pages/dataset-edit.test.ts | 12 ++ src/ui/publisher/pages/dataset-edit.ts | 11 ++ src/ui/publisher/pages/datasets.test.ts | 18 +++ src/ui/publisher/pages/datasets.ts | 34 ++++- src/ui/publisher/types.ts | 8 ++ 14 files changed, 400 insertions(+), 97 deletions(-) diff --git a/functions/api/v1/_lib/dataset-mutations.test.ts b/functions/api/v1/_lib/dataset-mutations.test.ts index fd1fc318f..aa167b391 100644 --- a/functions/api/v1/_lib/dataset-mutations.test.ts +++ b/functions/api/v1/_lib/dataset-mutations.test.ts @@ -5,8 +5,11 @@ * - createDataset on a valid body inserts a draft (published_at NULL). * - createDataset returns 400 + errors for invalid body. * - Slug is derived from the title when missing, with collision suffix. - * - listDatasetsForPublisher honors the admin vs publisher filter. + * - listDatasetsForPublisher returns the whole catalog to every role + * (reads are open; writes stay owner-scoped). * - listDatasetsForPublisher's status filter (draft/published/retracted). + * - canMutateDataset / getDatasetById enforce the read-open, + * write-owner-scoped split. * - updateDataset patches fields, leaves others alone, invalidates KV * when the row is currently public. * - publishDataset stamps published_at + invalidates KV; rejects when @@ -17,10 +20,12 @@ import { describe, expect, it } from 'vitest' import type { PublisherRow } from './publisher-store' import { + canMutateDataset, createDataset, deleteDataset, DELETE_EMBEDDING_JOB_NAME, EMBED_JOB_NAME, + getDatasetById, getDatasetForPublisher, isEmbedConfigured, listDatasetsForPublisher, @@ -181,12 +186,12 @@ describe('listDatasetsForPublisher', () => { expect(datasets).toHaveLength(3) }) - it('filters to own rows for a publisher-role account', async () => { + it('returns the whole catalog to a publisher-role account (reads are open)', async () => { const { env } = setupEnv() await seed3(env) const { datasets } = await listDatasetsForPublisher(env.CATALOG_DB!, PUBLISHER) - expect(datasets).toHaveLength(2) - for (const d of datasets) expect(d.publisher_id).toBe(PUBLISHER.id) + // A community publisher now sees every row, not just its own two. + expect(datasets).toHaveLength(3) }) it('honors ?status=draft|published|retracted', async () => { @@ -208,6 +213,35 @@ describe('listDatasetsForPublisher', () => { }) }) +describe('canMutateDataset', () => { + it('lets a publisher mutate only its own rows', () => { + expect(canMutateDataset(PUBLISHER, { publisher_id: PUBLISHER.id })).toBe(true) + expect(canMutateDataset(PUBLISHER, { publisher_id: ADMIN.id })).toBe(false) + expect(canMutateDataset(PUBLISHER, { publisher_id: null })).toBe(false) + }) + + it('lets a privileged (admin) caller mutate any row', () => { + expect(canMutateDataset(ADMIN, { publisher_id: PUBLISHER.id })).toBe(true) + expect(canMutateDataset(ADMIN, { publisher_id: null })).toBe(true) + }) +}) + +describe('getDatasetById', () => { + it('reads any row regardless of owner (reads are open)', async () => { + const { env } = setupEnv() + const created = await createDataset(env, ADMIN, { + title: 'Admin dataset', + format: 'video/mp4', + }) + expect(created.ok).toBe(true) + if (!created.ok) return + // Owned by ADMIN, but a community publisher can still read it. + const row = await getDatasetById(env.CATALOG_DB!, created.dataset.id) + expect(row?.id).toBe(created.dataset.id) + expect(await getDatasetById(env.CATALOG_DB!, 'does-not-exist')).toBeNull() + }) +}) + describe('getDatasetForPublisher', () => { it('respects the role-aware filter', async () => { const { env } = setupEnv() diff --git a/functions/api/v1/_lib/dataset-mutations.ts b/functions/api/v1/_lib/dataset-mutations.ts index e08b97632..b6eebf1fc 100644 --- a/functions/api/v1/_lib/dataset-mutations.ts +++ b/functions/api/v1/_lib/dataset-mutations.ts @@ -7,9 +7,22 @@ * upsert, role-aware visibility filters. * * Authorisation model: - * - `admin` and the synthetic `service` role see all rows. - * - `publisher` / `readonly` (and any role we don't recognise) see - * only rows where `publisher_id = caller.id`. + * - Reads (list + single-row fetch) are open to every + * authenticated publisher: everyone sees the whole node catalog. + * - Writes (update / publish / retract / delete / preview / reindex) + * stay owner-scoped: `admin` and the synthetic `service` role may + * mutate any row; `publisher` / `readonly` (and any role we don't + * recognise) may mutate only rows where `publisher_id = caller.id`. + * + * The split is enforced by two helpers: + * - `getDatasetById` — unscoped read used by the list + detail + * endpoints. + * - `getDatasetForPublisher` — the owner-scoped gate the mutation + * handlers pre-check with; returns null (→ 404) for a row the + * caller may not mutate. + * `canMutateDataset` exposes the same rule to the route layer so the + * list / detail responses can stamp a per-row `can_edit` flag the + * portal uses to show or hide the Edit / Retract / Delete controls. * * Phase 1a is metadata-only: `data_ref` / `thumbnail_ref` / * `legend_ref` / `caption_ref` are bare strings supplied by the @@ -124,16 +137,33 @@ export interface DraftCreateFailure { export type DraftCreateOutcome = DraftCreateResult | DraftCreateFailure /** - * Apply the role-aware visibility predicate to an existing query. + * Apply the role-aware *mutation* predicate to an existing query. * Returns the WHERE fragment + binds to splice into the caller's * SQL. The fragment is a no-op (`'1=1'`) for privileged callers so - * the caller can keep the SQL shape uniform. + * the caller can keep the SQL shape uniform. Reads no longer use + * this (the whole catalog is visible to every publisher); it now + * only gates the owner-scoped write path via `getDatasetForPublisher`. */ function publisherScope(publisher: PublisherRow): { sql: string; binds: unknown[] } { if (isPrivileged(publisher)) return { sql: '1=1', binds: [] } return { sql: 'publisher_id = ?', binds: [publisher.id] } } +/** + * Whether `publisher` may mutate (edit / publish / retract / delete / + * preview / reindex) the given row. Privileged callers may mutate + * anything; everyone else only their own rows. This is the single + * source of truth for the write rule — the mutation gate + * (`getDatasetForPublisher`) and the route layer's `can_edit` flag + * both derive from it. + */ +export function canMutateDataset( + publisher: PublisherRow, + row: Pick, +): boolean { + return isPrivileged(publisher) || row.publisher_id === publisher.id +} + /** * Collapse undefined / null / empty / whitespace-only strings to * NULL on the way to D1. Used by Phase 3d's `celestial_body` @@ -156,16 +186,17 @@ export interface ListOptions { export async function listDatasetsForPublisher( db: D1Database, - publisher: PublisherRow, + // Every authenticated publisher sees the whole node catalog, so the + // list is no longer owner-scoped. The caller is retained in the + // signature because the route layer still needs it to stamp the + // per-row `can_edit` flag (see `canMutateDataset`), and to keep the + // call sites stable. + _publisher: PublisherRow, options: ListOptions = {}, ): Promise<{ datasets: DatasetRow[]; next_cursor: string | null }> { const where: string[] = [] const binds: unknown[] = [] - const scope = publisherScope(publisher) - where.push(scope.sql) - binds.push(...scope.binds) - if (options.status === 'draft') { where.push('published_at IS NULL AND retracted_at IS NULL') } else if (options.status === 'published') { @@ -180,9 +211,12 @@ export async function listDatasetsForPublisher( } const limit = Math.min(Math.max(options.limit ?? 50, 1), 200) + // With no status/cursor filter the predicate list is empty, so omit + // the WHERE clause entirely rather than emit a dangling `WHERE`. + const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : '' const sql = ` SELECT * FROM datasets - WHERE ${where.join(' AND ')} + ${whereClause} ORDER BY id ASC LIMIT ? ` @@ -197,6 +231,24 @@ export async function listDatasetsForPublisher( return { datasets, next_cursor } } +/** + * Unscoped single-row read by id. Backs the list + detail read + * endpoints, which are open to every authenticated publisher. Returns + * null only when the row genuinely does not exist. Mutation handlers + * must not use this — they gate on `getDatasetForPublisher` so the + * owner boundary is preserved. + */ +export async function getDatasetById( + db: D1Database, + id: string, +): Promise { + const row = await db + .prepare('SELECT * FROM datasets WHERE id = ? LIMIT 1') + .bind(id) + .first() + return row ?? null +} + export async function getDatasetForPublisher( db: D1Database, publisher: PublisherRow, diff --git a/functions/api/v1/_lib/publisher-store.ts b/functions/api/v1/_lib/publisher-store.ts index fa4a8ef6a..2b3dfab04 100644 --- a/functions/api/v1/_lib/publisher-store.ts +++ b/functions/api/v1/_lib/publisher-store.ts @@ -10,10 +10,13 @@ * Role taxonomy (canonical privilege source of truth is `role`; * `is_admin` is a synced legacy mirror of `role === 'admin'`): * - * - `admin` — full administrator (sees all rows, mutates + * - `admin` — full administrator (mutates any row + the * operator-scoped resources, manages users). - * - `publisher` — the secondary authoring role; sees only its own - * rows, cannot manage users or operator resources. + * - `publisher` — the secondary authoring role; can read the whole + * node catalog but may only mutate its own rows, and + * cannot manage users or operator resources. (Reads + * are open to every publisher; the owner scope lives + * on the write path — see `dataset-mutations.ts`.) * - `readonly` — reviewer (unprivileged today; reviewer semantics * land later). * - `service` — machine credential / service token. diff --git a/functions/api/v1/publish/datasets.test.ts b/functions/api/v1/publish/datasets.test.ts index 462f3601d..d8c8e8020 100644 --- a/functions/api/v1/publish/datasets.test.ts +++ b/functions/api/v1/publish/datasets.test.ts @@ -36,6 +36,17 @@ const ADMIN: PublisherRow = { created_at: '2026-01-01T00:00:00.000Z', } +// A second, non-privileged publisher — used to exercise the +// read-open / write-owner-scoped split. +const PUBLISHER: PublisherRow = { + ...ADMIN, + id: 'PUB-COMM', + email: 'comm@example.com', + display_name: 'Community', + role: 'publisher', + is_admin: 0, +} + function ctxWithPublisher

(opts: { env: Record url?: string @@ -43,6 +54,9 @@ function ctxWithPublisher

(opts: { headers?: Record body?: unknown params?: Record + /** The authenticated publisher the middleware would have attached. + * Defaults to ADMIN so the existing tests are unaffected. */ + publisher?: PublisherRow }) { const url = opts.url ?? 'https://localhost/api/v1/publish/datasets' const headers = new Headers(opts.headers ?? {}) @@ -57,7 +71,7 @@ function ctxWithPublisher

(opts: { request, env: opts.env, params: (opts.params ?? {}) as { [K in P]: string | string[] }, - data: { publisher: ADMIN }, + data: { publisher: opts.publisher ?? ADMIN }, waitUntil: () => {}, passThroughOnException: () => {}, next: async () => new Response(null), @@ -67,12 +81,14 @@ function ctxWithPublisher

(opts: { function setupEnv() { const sqlite = seedFixtures({ count: 0 }) - sqlite - .prepare( - `INSERT INTO publishers (id, email, display_name, role, is_admin, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - ) - .run(ADMIN.id, ADMIN.email, ADMIN.display_name, ADMIN.role, ADMIN.is_admin, ADMIN.status, ADMIN.created_at) + for (const p of [ADMIN, PUBLISHER]) { + sqlite + .prepare( + `INSERT INTO publishers (id, email, display_name, role, is_admin, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(p.id, p.email, p.display_name, p.role, p.is_admin, p.status, p.created_at) + } return { sqlite, env: { @@ -231,6 +247,92 @@ describe('GET / PUT /api/v1/publish/datasets/{id}', () => { }) }) +describe('read-open / write-owner-scoped (whole-catalog visibility)', () => { + /** Seed one admin-owned row + one community-owned row. */ + async function seedMixed() { + const { env, sqlite } = setupEnv() + const adminRow = await readJson<{ dataset: { id: string } }>( + await onRequestPost( + ctxWithPublisher({ env, method: 'POST', body: { title: 'Admin owned', format: 'video/mp4' } }), + ), + ) + const commRow = await readJson<{ dataset: { id: string } }>( + await onRequestPost( + ctxWithPublisher({ + env, + method: 'POST', + body: { title: 'Community owned', format: 'video/mp4' }, + publisher: PUBLISHER, + }), + ), + ) + return { env, sqlite, adminId: adminRow.dataset.id, commId: commRow.dataset.id } + } + + it('GET list shows a community publisher the whole catalog with per-row can_edit', async () => { + const { env, adminId, commId } = await seedMixed() + const res = await onRequestGet(ctxWithPublisher({ env, publisher: PUBLISHER })) + expect(res.status).toBe(200) + const body = await readJson<{ + datasets: Array<{ id: string; can_edit: boolean }> + }>(res) + // Both rows are visible, not just the community publisher's own. + expect(body.datasets.map(d => d.id).sort()).toEqual([adminId, commId].sort()) + const byId = new Map(body.datasets.map(d => [d.id, d.can_edit])) + expect(byId.get(commId)).toBe(true) // own row → editable + expect(byId.get(adminId)).toBe(false) // someone else's → view-only + }) + + it('GET list marks every row can_edit for a privileged (admin) caller', async () => { + const { env, adminId, commId } = await seedMixed() + const res = await onRequestGet(ctxWithPublisher({ env, publisher: ADMIN })) + const body = await readJson<{ datasets: Array<{ id: string; can_edit: boolean }> }>(res) + for (const d of body.datasets) expect(d.can_edit).toBe(true) + expect(body.datasets.map(d => d.id).sort()).toEqual([adminId, commId].sort()) + }) + + it('GET detail lets a community publisher read another owner’s row with can_edit=false', async () => { + const { env, adminId } = await seedMixed() + const res = await datasetGet( + ctxWithPublisher<'id'>({ env, params: { id: adminId }, publisher: PUBLISHER }), + ) + expect(res.status).toBe(200) + const body = await readJson<{ dataset: { id: string; can_edit: boolean } }>(res) + expect(body.dataset.id).toBe(adminId) + expect(body.dataset.can_edit).toBe(false) + }) + + it('PUT stays owner-scoped: a community publisher gets 404 editing another owner’s row', async () => { + const { env, adminId } = await seedMixed() + const res = await datasetPut( + ctxWithPublisher<'id'>({ + env, + method: 'PUT', + body: { title: 'Hijack attempt' }, + params: { id: adminId }, + publisher: PUBLISHER, + }), + ) + expect(res.status).toBe(404) + }) + + it('PUT succeeds on the community publisher’s own row', async () => { + const { env, commId } = await seedMixed() + const res = await datasetPut( + ctxWithPublisher<'id'>({ + env, + method: 'PUT', + body: { title: 'Renamed by owner' }, + params: { id: commId }, + publisher: PUBLISHER, + }), + ) + expect(res.status).toBe(200) + const body = await readJson<{ dataset: { title: string } }>(res) + expect(body.dataset.title).toBe('Renamed by owner') + }) +}) + describe('POST /api/v1/publish/datasets/{id}/publish', () => { async function seedReady() { const { env, sqlite } = setupEnv() diff --git a/functions/api/v1/publish/datasets.ts b/functions/api/v1/publish/datasets.ts index 251772623..1c9cfa6af 100644 --- a/functions/api/v1/publish/datasets.ts +++ b/functions/api/v1/publish/datasets.ts @@ -19,6 +19,7 @@ import type { PublisherData } from './_middleware' import { writeDatasetAudit } from '../_lib/audit-store' import { getNodeIdentity } from '../_lib/catalog-store' import { + canMutateDataset, createDataset, listDatasetsForPublisher, type ListOptions, @@ -69,10 +70,15 @@ export const onRequestGet: PagesFunction = async context => { options, ) // Resolve each row's `thumbnail_ref` to a public URL so the list - // table can render a thumbnail cell (null when none / unresolvable). + // table can render a thumbnail cell (null when none / unresolvable), + // and stamp `can_edit` so the portal only shows the Edit / Retract / + // Delete controls on rows the caller may actually mutate. The whole + // catalog is now visible to every publisher, but writes stay + // owner-scoped. const withThumbnails = datasets.map(d => ({ ...d, thumbnail_url: resolveHttpAssetUrl(context.env, d.thumbnail_ref), + can_edit: canMutateDataset(publisher, d), })) return new Response(JSON.stringify({ datasets: withThumbnails, next_cursor }), { status: 200, diff --git a/functions/api/v1/publish/datasets/[id].ts b/functions/api/v1/publish/datasets/[id].ts index 4caf7f7c7..c214babe7 100644 --- a/functions/api/v1/publish/datasets/[id].ts +++ b/functions/api/v1/publish/datasets/[id].ts @@ -2,11 +2,13 @@ * /api/v1/publish/datasets/{id} * * GET → Single dataset full body (draft, published, retracted). - * Community publishers can only fetch rows they own; staff - * see anything. 404 when the row doesn't exist OR isn't - * visible — we don't distinguish, to avoid leaking the - * existence of other publishers' drafts. - * PUT → Patch metadata. Same authorisation rule as GET. + * Readable by any authenticated publisher — the whole node + * catalog is visible. 404 only when the row doesn't exist. The + * response carries `can_edit` so the portal knows whether to + * offer the mutation controls. + * PUT → Patch metadata. Owner-scoped: only the row's publisher (or a + * privileged caller) may write; others get 404 from the + * ownership gate. * * DELETE → Hard-delete a non-published row (drafts + retracted). * 409 `published` for live rows (retract first) and @@ -21,7 +23,9 @@ import type { PublisherData } from '../_middleware' import { writeDatasetAudit } from '../../_lib/audit-store' import { getDecorations } from '../../_lib/catalog-store' import { + canMutateDataset, deleteDataset, + getDatasetById, getDatasetForPublisher, updateDataset, } from '../../_lib/dataset-mutations' @@ -53,7 +57,7 @@ export const onRequestGet: PagesFunction = async context => { const id = pickId(context) if (!id) return jsonError(400, 'invalid_request', 'Missing dataset id.') const db = context.env.CATALOG_DB! - const row = await getDatasetForPublisher(db, publisher, id) + const row = await getDatasetById(db, id) if (!row) return jsonError(404, 'not_found', `Dataset ${id} not found.`) const decorations = (await getDecorations(db, [id])).get(id) // Resolve the raw `data_ref` (`r2:` or a bare URL) to a @@ -70,7 +74,9 @@ export const onRequestGet: PagesFunction = async context => { const legendUrl = resolveHttpAssetUrl(context.env, row.legend_ref) return new Response( JSON.stringify({ - dataset: row, + // `can_edit` rides on the dataset object (as it does in the list + // response) so the portal reads `dataset.can_edit` uniformly. + dataset: { ...row, can_edit: canMutateDataset(publisher, row) }, data_url: dataUrl, thumbnail_url: thumbnailUrl, legend_url: legendUrl, diff --git a/locales/en.json b/locales/en.json index 627d90235..2a3c77cb3 100644 --- a/locales/en.json +++ b/locales/en.json @@ -960,6 +960,8 @@ "publisher.datasets.action.edit.aria": "Edit dataset {title}", "publisher.datasets.action.retract": "Retract", "publisher.datasets.action.retract.aria": "Retract dataset {title}", + "publisher.datasets.action.view": "View", + "publisher.datasets.action.view.aria": "View dataset {title}", "publisher.datasets.col.actions": "Actions", "publisher.datasets.col.format": "Format", "publisher.datasets.col.slug": "Slug", diff --git a/src/ui/publisher/pages/dataset-detail.test.ts b/src/ui/publisher/pages/dataset-detail.test.ts index 0a295c35b..248c12d4b 100644 --- a/src/ui/publisher/pages/dataset-detail.test.ts +++ b/src/ui/publisher/pages/dataset-detail.test.ts @@ -247,6 +247,20 @@ describe('renderDatasetDetailPage', () => { expect(edit?.textContent).toBe('Edit') }) + it('hides Edit / Preview / Retract when the caller cannot edit the row', async () => { + const fetchFn = vi.fn().mockResolvedValue(detailResponse(dataset({ can_edit: false }))) + await renderDatasetDetailPage(mount, '01ABC', { + fetchFn: fetchFn as unknown as typeof fetch, + }) + // Read-only view: the row still renders, but none of the + // owner-scoped mutation affordances appear. + expect(mount.querySelector('.publisher-detail-edit')).toBeNull() + expect(mount.querySelector('.publisher-detail-preview')).toBeNull() + expect(mount.querySelector('.publisher-detail-title')?.textContent).toBe( + 'Sea Surface Temperature Anomaly — April 2026', + ) + }) + it('Edit button delegates to routerNavigate on a plain click', async () => { const fetchFn = vi.fn().mockResolvedValue(detailResponse(dataset())) const routerNavigate = vi.fn<(path: string) => void>() diff --git a/src/ui/publisher/pages/dataset-detail.ts b/src/ui/publisher/pages/dataset-detail.ts index c03bd9ad0..5f9038a78 100644 --- a/src/ui/publisher/pages/dataset-detail.ts +++ b/src/ui/publisher/pages/dataset-detail.ts @@ -153,67 +153,76 @@ function renderHeader(d: PublisherDatasetDetail, hooks: HeaderHooks): HTMLElemen titleRow.appendChild(transcodingBadge) } - const editHref = `/publish/datasets/${encodeURIComponent(d.id)}/edit` - const editLink = document.createElement('a') - editLink.className = 'publisher-button publisher-button-secondary publisher-detail-edit' - editLink.href = editHref - editLink.textContent = t('publisher.datasetDetail.editAction') - if (hooks.routerNavigate) { - editLink.addEventListener('click', event => { - // Plain left-click + no modifier → SPA navigation. Anything - // else (cmd-click, middle-click, etc.) falls through to the - // browser so the publisher can still open the edit form in a - // new tab. - if ( - event.button === 0 && - !event.metaKey && - !event.ctrlKey && - !event.shiftKey && - !event.altKey - ) { - event.preventDefault() - hooks.routerNavigate!(editHref) - } - }) - } - titleRow.appendChild(editLink) - - // Preview button — mints a 15-minute signed token and surfaces - // the SPA-side `/?preview=&dataset=` URL so the - // publisher can share an unpublished draft as a live globe - // rendering. The SPA consumer (3pe/C) fetches the wire-shape - // metadata + the token-gated manifest sibling, then runs the - // dataset through the regular loader path. Hidden while - // transcoding (data_ref is empty so there'd be nothing to - // render). See `dispatchPreview` below for the rationale. - if (!d.transcoding) { - const previewBtn = document.createElement('button') - previewBtn.type = 'button' - previewBtn.className = - 'publisher-button publisher-button-secondary publisher-detail-preview' - previewBtn.textContent = t('publisher.datasetDetail.action.preview') - previewBtn.addEventListener('click', () => { - hooks.onPreview?.() - }) - titleRow.appendChild(previewBtn) - } + // Edit / Preview / Publish / Retract are all owner-scoped writes. + // Every publisher can view any row in the catalog, but the mutation + // controls only appear on rows the caller may actually change + // (`can_edit`). Absent (older payload / fixture) is treated as + // editable; the server is the authoritative gate regardless of what + // the UI shows. + const canEdit = d.can_edit !== false + if (canEdit) { + const editHref = `/publish/datasets/${encodeURIComponent(d.id)}/edit` + const editLink = document.createElement('a') + editLink.className = 'publisher-button publisher-button-secondary publisher-detail-edit' + editLink.href = editHref + editLink.textContent = t('publisher.datasetDetail.editAction') + if (hooks.routerNavigate) { + editLink.addEventListener('click', event => { + // Plain left-click + no modifier → SPA navigation. Anything + // else (cmd-click, middle-click, etc.) falls through to the + // browser so the publisher can still open the edit form in a + // new tab. + if ( + event.button === 0 && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ) { + event.preventDefault() + hooks.routerNavigate!(editHref) + } + }) + } + titleRow.appendChild(editLink) + + // Preview button — mints a 15-minute signed token and surfaces + // the SPA-side `/?preview=&dataset=` URL so the + // publisher can share an unpublished draft as a live globe + // rendering. The SPA consumer (3pe/C) fetches the wire-shape + // metadata + the token-gated manifest sibling, then runs the + // dataset through the regular loader path. Hidden while + // transcoding (data_ref is empty so there'd be nothing to + // render). See `dispatchPreview` below for the rationale. + if (!d.transcoding) { + const previewBtn = document.createElement('button') + previewBtn.type = 'button' + previewBtn.className = + 'publisher-button publisher-button-secondary publisher-detail-preview' + previewBtn.textContent = t('publisher.datasetDetail.action.preview') + previewBtn.addEventListener('click', () => { + hooks.onPreview?.() + }) + titleRow.appendChild(previewBtn) + } - // Drafts and retracted rows surface a "Publish" affordance; - // published rows surface "Retract". The route handlers accept - // re-publishing a retracted row (it clears retracted_at and - // re-stamps published_at) so the same button does double duty. - // - // While a row is transcoding (Phase 3pd video upload in flight) - // the Publish button is gated: data_ref is empty until the GHA - // workflow finishes, so the publish-readiness validator would - // reject anyway. Disabling here gives the publisher a clearer - // signal than "submit-then-error." - if (status === 'published') { - titleRow.appendChild(renderActionButton('retract', hooks.onAction)) - } else { - titleRow.appendChild( - renderActionButton('publish', hooks.onAction, { disabled: !!d.transcoding }), - ) + // Drafts and retracted rows surface a "Publish" affordance; + // published rows surface "Retract". The route handlers accept + // re-publishing a retracted row (it clears retracted_at and + // re-stamps published_at) so the same button does double duty. + // + // While a row is transcoding (Phase 3pd video upload in flight) + // the Publish button is gated: data_ref is empty until the GHA + // workflow finishes, so the publish-readiness validator would + // reject anyway. Disabling here gives the publisher a clearer + // signal than "submit-then-error." + if (status === 'published') { + titleRow.appendChild(renderActionButton('retract', hooks.onAction)) + } else { + titleRow.appendChild( + renderActionButton('publish', hooks.onAction, { disabled: !!d.transcoding }), + ) + } } header.appendChild(titleRow) diff --git a/src/ui/publisher/pages/dataset-edit.test.ts b/src/ui/publisher/pages/dataset-edit.test.ts index 465c992cd..8b117e9d9 100644 --- a/src/ui/publisher/pages/dataset-edit.test.ts +++ b/src/ui/publisher/pages/dataset-edit.test.ts @@ -98,6 +98,18 @@ describe('renderDatasetEditPage', () => { ) }) + it('redirects a non-owner (can_edit=false) to the read-only detail page instead of the form', async () => { + const fetchFn = vi.fn().mockResolvedValue(detailResponse(dataset({ can_edit: false }))) + const navigate = vi.fn<(url: string) => void>() + await renderDatasetEditPage(mount, '01EDIT0000000000000000000', { + fetchFn: fetchFn as unknown as typeof fetch, + navigate, + }) + expect(navigate).toHaveBeenCalledWith('/publish/datasets/01EDIT0000000000000000000') + // The form is not mounted for a read-only caller. + expect(mount.querySelector('#dataset-title')).toBeNull() + }) + it('surfaces the existing data_ref via the asset uploader’s "current" line + the manual override input', async () => { const fetchFn = vi.fn().mockResolvedValue( detailResponse(dataset({ data_ref: 'r2:videos/01XYZ/master.m3u8' })), diff --git a/src/ui/publisher/pages/dataset-edit.ts b/src/ui/publisher/pages/dataset-edit.ts index 0b833f31e..850d5bae0 100644 --- a/src/ui/publisher/pages/dataset-edit.ts +++ b/src/ui/publisher/pages/dataset-edit.ts @@ -96,6 +96,17 @@ export async function renderDatasetEditPage( return } clearWarmupFlag() + // The whole catalog is visible, but editing stays owner-scoped. A + // non-owner who deep-links to `/edit` (the Edit affordance is hidden + // for them elsewhere) is bounced to the read-only detail page rather + // than shown a form whose Save would 404. `can_edit` absent (older + // payload / fixture) is treated as editable. + if (result.data.dataset.can_edit === false) { + const detailHref = `/publish/datasets/${encodeURIComponent(id)}` + if (options.navigate) options.navigate(detailHref) + else window.location.href = detailHref + return + } renderDatasetForm(content, { ...options, mode: 'edit', diff --git a/src/ui/publisher/pages/datasets.test.ts b/src/ui/publisher/pages/datasets.test.ts index 0a021c766..1eb772e5b 100644 --- a/src/ui/publisher/pages/datasets.test.ts +++ b/src/ui/publisher/pages/datasets.test.ts @@ -16,6 +16,7 @@ interface RawDataset { publisher_id: string | null legacy_id: string | null thumbnail_url?: string | null + can_edit?: boolean } function dataset(overrides: Partial = {}): RawDataset { @@ -471,6 +472,23 @@ describe('renderDatasetsPage — delete action', () => { expect(mount.querySelector('.publisher-row-delete')).toBeNull() }) + it('a row the caller cannot edit shows only a View link (no Edit/Retract/Delete)', async () => { + window.history.replaceState(null, '', '/publish/datasets?status=published') + const fetchFn = vi.fn().mockResolvedValue( + jsonResponse({ + datasets: [dataset({ id: 'OTHER', published_at: '2026-04-30T12:00:00Z', can_edit: false })], + next_cursor: null, + }), + ) + await renderDatasetsPage(mount, { fetchFn: fetchFn as unknown as typeof fetch, fetchCounts: false }) + expect(mount.querySelector('.publisher-row-edit')).toBeNull() + expect(mount.querySelector('.publisher-row-retract')).toBeNull() + expect(mount.querySelector('.publisher-row-delete')).toBeNull() + const view = mount.querySelector('.publisher-row-view') + expect(view).not.toBeNull() + expect(view?.getAttribute('href')).toBe('/publish/datasets/OTHER') + }) + it('a confirmed Retract POSTs to the retract endpoint and drops the row', async () => { window.history.replaceState(null, '', '/publish/datasets?status=published') const calls: Array<[string, RequestInit | undefined]> = [] diff --git a/src/ui/publisher/pages/datasets.ts b/src/ui/publisher/pages/datasets.ts index 94b3a5652..53ddb5c63 100644 --- a/src/ui/publisher/pages/datasets.ts +++ b/src/ui/publisher/pages/datasets.ts @@ -275,15 +275,41 @@ function renderTable( updatedCell.textContent = formatDate(d.updated_at) tr.appendChild(updatedCell) - // Actions: Edit (all rows) + Retract (published) / Delete - // (drafts & retracted). Live rows must be retracted before they - // can be deleted, which the API enforces with a 409 regardless - // of what the UI shows. + // Actions: for a row the caller can mutate, Edit (all rows) + + // Retract (published) / Delete (drafts & retracted). Live rows + // must be retracted before they can be deleted, which the API + // enforces with a 409 regardless of what the UI shows. The whole + // catalog is visible to every publisher, but writes stay + // owner-scoped — a row the caller can't mutate (`can_edit === + // false`) shows only a View link. `can_edit` absent (older + // payload / fixture) is treated as editable; the server is the + // authoritative gate regardless of what the UI shows. const actionsCell = document.createElement('td') actionsCell.className = 'publisher-cell-actions' const statusSpan = document.createElement('span') statusSpan.className = 'publisher-row-action-status' + const canEdit = d.can_edit !== false + if (!canEdit) { + const detailHrefRO = `/publish/datasets/${encodeURIComponent(d.id)}` + const viewLink = document.createElement('a') + viewLink.href = detailHrefRO + viewLink.className = 'publisher-row-action publisher-row-view' + viewLink.textContent = t('publisher.datasets.action.view') + viewLink.setAttribute('aria-label', t('publisher.datasets.action.view.aria', { title: d.title })) + if (routerNavigate) { + viewLink.addEventListener('click', event => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return + event.preventDefault() + routerNavigate(detailHrefRO) + }) + } + actionsCell.appendChild(viewLink) + tr.appendChild(actionsCell) + tbody.appendChild(tr) + continue + } + const editHref = `/publish/datasets/${encodeURIComponent(d.id)}/edit` const editLink = document.createElement('a') editLink.href = editHref diff --git a/src/ui/publisher/types.ts b/src/ui/publisher/types.ts index 9ed1790ef..36fe3eb7f 100644 --- a/src/ui/publisher/types.ts +++ b/src/ui/publisher/types.ts @@ -47,6 +47,14 @@ export interface PublisherDataset { * for a thumbnail cell in the list table. Null/absent when there's * no thumbnail or it can't be resolved (no R2 public base bound). */ thumbnail_url?: string | null + /** Whether the current caller may mutate this row (edit / retract / + * delete). The whole catalog is visible to every publisher, but + * writes stay owner-scoped, so the portal only shows the mutation + * controls when this is true. Absent on older payloads / fixtures, + * which the UI treats as editable (`can_edit !== false`) since the + * server is the authoritative gate regardless of what the UI + * shows. */ + can_edit?: boolean } /** From a1aadd53ebda2b14f5c66c1a11d1650d457c6801 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 22:26:36 +0000 Subject: [PATCH 02/25] publisher: give non-admin publishers read-only access to hero, analytics, feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude --- CLAUDE.md | 6 +- functions/api/v1/publish/analytics.test.ts | 14 +-- functions/api/v1/publish/analytics.ts | 15 ++- functions/api/v1/publish/feedback.test.ts | 10 +- functions/api/v1/publish/feedback.ts | 18 ++-- locales/en.json | 8 +- src/ui/publisher/pages/analytics.ts | 45 ++------ src/ui/publisher/pages/featured-hero.test.ts | 35 +++++- src/ui/publisher/pages/featured-hero.ts | 108 ++++++++++++++++--- src/ui/publisher/pages/feedback.ts | 42 ++------ 10 files changed, 174 insertions(+), 127 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 704a2f37b..fd84eb15f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -243,14 +243,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 (admins); non-admin publishers 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/functions/api/v1/publish/analytics.test.ts b/functions/api/v1/publish/analytics.test.ts index e33fc416d..3bddeb0aa 100644 --- a/functions/api/v1/publish/analytics.test.ts +++ b/functions/api/v1/publish/analytics.test.ts @@ -3,7 +3,7 @@ * `docs/ANALYTICS_STORAGE_AND_ADMIN_PLAN.md`). * * Coverage: - * - 503 when CATALOG_DB is unbound; 403 for publisher callers. + * - 503 when CATALOG_DB is unbound; read-only view open to publisher callers. * - 400 on unknown section / days / environment / projection. * - Section payloads computed from seeded rollup rows: overview * day series + platform/country mixes + internal exclusion, @@ -165,12 +165,14 @@ async function getData(env: Record, query: string): Promise< } describe('GET /api/v1/publish/analytics', () => { - it('503s without CATALOG_DB and 403s for publisher callers', async () => { - const { env } = setup() + it('503s without CATALOG_DB', async () => { expect((await analyticsGet(ctx({ env: {}, query: '?section=overview' }))).status).toBe(503) - expect( - (await analyticsGet(ctx({ env, publisher: PUBLISHER, query: '?section=overview' }))).status, - ).toBe(403) + }) + + it('is readable by a non-privileged (publisher-role) caller — read-only view access', async () => { + const { env } = setup() + const res = await analyticsGet(ctx({ env, publisher: PUBLISHER, query: '?section=overview' })) + expect(res.status).toBe(200) }) it('400s on invalid parameters', async () => { diff --git a/functions/api/v1/publish/analytics.ts b/functions/api/v1/publish/analytics.ts index 85a2933cd..70656569a 100644 --- a/functions/api/v1/publish/analytics.ts +++ b/functions/api/v1/publish/analytics.ts @@ -15,14 +15,14 @@ * ?layer= ('' = default Earth; omit = all) * ?projection=globe|mercator|vr|ar (omit = all) * - * Privileged callers only (staff / admin / service) — same gate as - * featured-hero. Responses are KV-cached ~5 minutes (key includes + * Read-only for any active publisher (view access); mutations + * elsewhere (the operator backfill in `analytics-export.ts`) stay + * privileged. Responses are KV-cached ~5 minutes (key includes * every filter) so a dashboard refresh storm stays cheap; rollups * only change once a day anyway. */ import type { CatalogEnv } from '../_lib/env' -import type { PublisherData } from './_middleware' import { queryDatasets, queryErrors, @@ -35,7 +35,6 @@ import { type AnalyticsFilters, } from '../_lib/analytics-query' import { addDays, yesterdayUtc } from '../_lib/analytics-export' -import { isPrivileged } from '../_lib/publisher-store' const CONTENT_TYPE = 'application/json; charset=utf-8' export const CACHE_TTL_SECONDS = 300 @@ -58,11 +57,9 @@ export const onRequestGet: PagesFunction = async context => { if (!context.env.CATALOG_DB) { return jsonError(503, 'binding_missing', 'CATALOG_DB binding is not configured on this deployment.') } - const publisher = (context.data as unknown as PublisherData).publisher - if (!isPrivileged(publisher)) { - return jsonError(403, 'forbidden_role', 'Analytics is restricted to staff, admin, and service callers.') - } - + // Read-only view is open to any active publisher (the middleware + // has already rejected pending / suspended accounts). No role gate + // here — analytics is a dashboard, not a mutation surface. const params = new URL(context.request.url).searchParams const section = params.get('section') ?? '' if (!(ALLOWED_SECTIONS as readonly string[]).includes(section)) { diff --git a/functions/api/v1/publish/feedback.test.ts b/functions/api/v1/publish/feedback.test.ts index afcd9d33e..a4571ce6a 100644 --- a/functions/api/v1/publish/feedback.test.ts +++ b/functions/api/v1/publish/feedback.test.ts @@ -98,10 +98,14 @@ function ctx(opts: { env: Record; publisher?: PublisherRow; que } describe('GET /api/v1/publish/feedback', () => { - it('503s without FEEDBACK_DB and 403s for publisher-role callers', async () => { - const { env } = setup() + it('503s without FEEDBACK_DB', async () => { expect((await feedbackGet(ctx({ env: {}, query: '?view=ai' }))).status).toBe(503) - expect((await feedbackGet(ctx({ env, publisher: PUBLISHER, query: '?view=ai' }))).status).toBe(403) + }) + + it('is readable by a non-privileged (publisher-role) caller — read-only view access', async () => { + const { env } = setup() + const res = await feedbackGet(ctx({ env, publisher: PUBLISHER, query: '?view=ai' })) + expect(res.status).toBe(200) }) it('400s on a missing or unknown view', async () => { diff --git a/functions/api/v1/publish/feedback.ts b/functions/api/v1/publish/feedback.ts index 79c9975d4..d886c9d92 100644 --- a/functions/api/v1/publish/feedback.ts +++ b/functions/api/v1/publish/feedback.ts @@ -3,11 +3,11 @@ * `/publish/feedback` portal tab (Phase C of * `docs/ANALYTICS_STORAGE_AND_ADMIN_PLAN.md`). * - * A thin privilege-gated facade over the same `_feedback-helpers` + * A thin read-only facade over the same `_feedback-helpers` * the legacy `/api/feedback-admin` endpoint uses — one data layer, - * two auth surfaces (this one rides the publish middleware + - * `isPrivileged()`; feedback-admin keeps Cloudflare Access / - * bearer-token for scripts). Views: + * two auth surfaces (this one rides the publish middleware and is + * readable by any active publisher; feedback-admin keeps Cloudflare + * Access / bearer-token for scripts). Views: * * ?view=ai&days=30&recent=100 → AI thumbs dashboard JSON * ?view=general&days=30&recent=100 → bug/feature/other JSON @@ -23,13 +23,11 @@ */ import type { CatalogEnv } from '../_lib/env' -import type { PublisherData } from './_middleware' import { fetchAiDashboard, fetchGeneralDashboard, fetchScreenshot, } from '../../_feedback-helpers' -import { isPrivileged } from '../_lib/publisher-store' const CONTENT_TYPE = 'application/json; charset=utf-8' const ALLOWED_VIEWS = ['ai', 'general', 'screenshot'] as const @@ -56,11 +54,9 @@ export const onRequestGet: PagesFunction = async context => { if (!context.env.FEEDBACK_DB) { return jsonError(503, 'binding_missing', 'FEEDBACK_DB binding is not configured on this deployment.') } - const publisher = (context.data as unknown as PublisherData).publisher - if (!isPrivileged(publisher)) { - return jsonError(403, 'forbidden_role', 'Feedback review is restricted to admin and service callers.') - } - + // Read-only view is open to any active publisher (the middleware + // has already rejected pending / suspended accounts). There are no + // mutation routes under this path — the page only reads. const params = new URL(context.request.url).searchParams const view = params.get('view') ?? '' if (!(ALLOWED_VIEWS as readonly string[]).includes(view)) { diff --git a/locales/en.json b/locales/en.json index 2a3c77cb3..1aceafa8b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -627,7 +627,6 @@ "publisher.analytics.research.tour": "Tour", "publisher.analytics.research.worstQuestions": "Worst-answered quiz questions", "publisher.analytics.research.zeroSearches": "Zero-result searches (hashed)", - "publisher.analytics.restricted": "This feature is restricted to staff members.", "publisher.analytics.section.datasets": "Dataset engagement", "publisher.analytics.section.funnel": "Tours, VR & Orbit", "publisher.analytics.section.orbit": "Orbit usage", @@ -1151,7 +1150,6 @@ "publisher.feedback.general.screenshotLabel": "Screenshot", "publisher.feedback.general.total": "Total", "publisher.feedback.loading": "Loading feedback…", - "publisher.feedback.restricted": "This feature is restricted to staff members.", "publisher.feedback.subtitle": "What visitors tell you — Orbit's AI ratings and general bug reports.", "publisher.feedback.tab.ai": "AI feedback", "publisher.feedback.tab.general": "General feedback", @@ -1246,7 +1244,11 @@ "publisher.hero.loading": "Loading…", "publisher.hero.pickDataset": "Select a dataset…", "publisher.hero.preview": "Preview · catalog card", - "publisher.hero.restricted": "Setting the hero is restricted to staff and admin publishers.", + "publisher.hero.readonly.current": "Currently featured", + "publisher.hero.readonly.headline": "Headline: {headline}", + "publisher.hero.readonly.none": "No dataset is currently featured. The catalog falls back to an auto-derived real-time dataset.", + "publisher.hero.readonly.notice": "You have view-only access. Only admin publishers can change the featured hero.", + "publisher.hero.readonly.window": "Active {start} – {end}", "publisher.hero.saved": "Hero set.", "publisher.hero.set": "Set hero", "publisher.hero.title": "“Right now” hero", diff --git a/src/ui/publisher/pages/analytics.ts b/src/ui/publisher/pages/analytics.ts index 790ccf128..54cef24c4 100644 --- a/src/ui/publisher/pages/analytics.ts +++ b/src/ui/publisher/pages/analytics.ts @@ -2,10 +2,11 @@ * /publish/analytics — the operator analytics dashboard (Phase B of * `docs/ANALYTICS_STORAGE_AND_ADMIN_PLAN.md`). * - * Privileged-only (staff / admin / service), same client-side gate - * as featured-hero (the API enforces 403 regardless). Data comes - * from `GET /api/v1/publish/analytics` — typed sections over the - * Phase A rollup tables; everything shown is a sample-weighted + * Read-only for any active publisher — the dashboard has no mutation + * controls (CSV export is built client-side), so view access is open; + * the operator backfill in `analytics-export.ts` stays privileged. + * Data comes from `GET /api/v1/publish/analytics` — typed sections + * over the Phase A rollup tables; everything shown is a sample-weighted * estimate over complete UTC days through yesterday, external * traffic only. * @@ -46,7 +47,6 @@ import { } from '../analytics-charts' -const ME_ENDPOINT = '/api/v1/publish/me' const ANALYTICS_ENDPOINT = '/api/v1/publish/analytics' /** Vendored Natural Earth 1:110m land polygons + admin-0 country * boundary lines (public domain), minified + coordinate-rounded — @@ -69,11 +69,6 @@ const ENVIRONMENTS = ['production', 'preview'] as const * export both derive the cell center by adding half this. */ const SPATIAL_BIN_DEG = 0.5 -interface MeResponse { - role: string - is_admin: boolean -} - interface Envelope { since_day: string through_day: string @@ -174,10 +169,6 @@ interface PageState { spatialProjection: string | undefined } -function clientIsPrivileged(me: MeResponse): boolean { - return me.is_admin === true || me.role === 'admin' || me.role === 'service' -} - function el( tag: K, props: Partial & { className?: string } = {}, @@ -227,29 +218,9 @@ export async function renderAnalyticsPage( shell(el('p', { className: 'publisher-loading', textContent: t('publisher.analytics.loading') })), ) - const meRes = await publisherGet(ME_ENDPOINT, { fetchFn }) - if (!meRes.ok) { - if (meRes.kind === 'session') { - if (handleSessionError({ navigate: options.navigate }) === 'navigating') return - mount.replaceChildren(shell(buildErrorCard('session'))) - return - } - const details = meRes.kind === 'server' ? { status: meRes.status, body: meRes.body } : {} - mount.replaceChildren(shell(buildErrorCard(meRes.kind, details))) - return - } - if (!clientIsPrivileged(meRes.data)) { - mount.replaceChildren( - shell( - el('h1', { textContent: t('publisher.analytics.title') }), - el('p', { - className: 'publisher-hero-restricted', - textContent: t('publisher.analytics.restricted'), - }), - ), - ) - return - } + // Analytics is a read-only dashboard, open to any active publisher. + // The per-section data reads below surface any session/server error; + // there's no separate role gate here. const state: PageState = { days: 30, diff --git a/src/ui/publisher/pages/featured-hero.test.ts b/src/ui/publisher/pages/featured-hero.test.ts index 4ca205edf..ad7c4b92a 100644 --- a/src/ui/publisher/pages/featured-hero.test.ts +++ b/src/ui/publisher/pages/featured-hero.test.ts @@ -41,12 +41,41 @@ beforeEach(() => { }) describe('renderFeaturedHeroPage', () => { - it('shows a restricted card for a non-privileged publisher', async () => { + it('shows a read-only view (no editing controls) for a non-privileged publisher with no pin', async () => { + const routes = baseRoutes() // hero: null + routes['/api/v1/publish/me'] = { body: { role: 'publisher', is_admin: false } } + await renderFeaturedHeroPage(mount, { fetchFn: mockFetch(routes) }) + // No editing controls at all. + expect(mount.querySelector('.publisher-hero-select')).toBeNull() + expect(mount.querySelector('.publisher-button')).toBeNull() + // The view-only notice + empty state render. + expect(mount.querySelector('.publisher-hero-intro')?.textContent).toContain('view-only') + expect(mount.querySelector('.publisher-hero-readonly-meta')?.textContent).toContain( + 'No dataset is currently featured', + ) + }) + + it('shows the currently-featured dataset read-only for a non-privileged publisher', async () => { const routes = baseRoutes() routes['/api/v1/publish/me'] = { body: { role: 'publisher', is_admin: false } } + routes['/api/v1/featured-hero'] = { + body: { + hero: { + datasetId: DS, + window: { start: '2026-07-01T00:00:00Z', end: '2026-07-08T00:00:00Z' }, + headline: 'Storm of the week', + }, + }, + } await renderFeaturedHeroPage(mount, { fetchFn: mockFetch(routes) }) - expect(mount.querySelector('.publisher-hero-restricted')).not.toBeNull() + // Read-only preview card with the pinned dataset's title/headline. expect(mount.querySelector('.publisher-hero-select')).toBeNull() + expect(mount.querySelector('.publisher-button')).toBeNull() + expect(mount.querySelector('.hero-panel-title')?.textContent).toBe('Storm of the week') + // Window + headline meta lines render. + const meta = Array.from(mount.querySelectorAll('.publisher-hero-readonly-meta')).map(n => n.textContent) + expect(meta.some(m => m?.includes('Active'))).toBe(true) + expect(meta.some(m => m?.includes('Storm of the week'))).toBe(true) }) it('renders the form with dataset options for a privileged publisher', async () => { @@ -180,7 +209,7 @@ describe('renderFeaturedHeroPage', () => { it('mounts inside a publisher-shell main landmark', async () => { await renderFeaturedHeroPage(mount, { fetchFn: mockFetch(baseRoutes()) }) expect(mount.querySelector('main.publisher-shell')).not.toBeNull() - // The restricted path also gets the landmark. + // The read-only (non-privileged) path also gets the landmark. const r = baseRoutes() r['/api/v1/publish/me'] = { body: { role: 'publisher', is_admin: false } } mount.replaceChildren() diff --git a/src/ui/publisher/pages/featured-hero.ts b/src/ui/publisher/pages/featured-hero.ts index fb9278d67..0139fcbbb 100644 --- a/src/ui/publisher/pages/featured-hero.ts +++ b/src/ui/publisher/pages/featured-hero.ts @@ -2,16 +2,18 @@ * /publish/featured-hero — set the "Right now" hero override (Phase C * of `docs/HERO_ADMIN_SCOPING.md`). * - * Privileged-only (staff / admin / service). The page fetches the - * caller's role (`/api/v1/publish/me`), the catalog datasets for the - * picker (`/api/v1/publish/datasets`), and the current pin - * (`/api/v1/featured-hero`), then renders a form: dataset picker, - * mandatory activation window, optional headline, a live preview of - * the real hero card, and Set / Clear buttons wired to + * The page fetches the caller's role (`/api/v1/publish/me`), the + * catalog datasets for the picker (`/api/v1/publish/datasets`), and + * the current pin (`/api/v1/featured-hero`). + * + * Privileged callers (staff / admin / service) get the editing form: + * dataset picker, mandatory activation window, optional headline, a + * live preview of the real hero card, and Set / Clear buttons wired to * `PUT` / `DELETE /api/v1/publish/featured-hero`. * - * Non-privileged callers get a restricted card (the API also enforces - * 403, but gating here avoids a fill-then-reject round-trip). + * Non-privileged (but active) publishers get a read-only view of the + * currently-featured dataset — no editing controls. The write + * endpoints stay 403 server-side regardless. * * The preview reuses the live hero-panel stylesheet so the curator * sees exactly what ships. The portal boots its own CSS bundle @@ -20,6 +22,7 @@ import { fetchFeatures, renderFeatureDisabledCard } from '../features' import { t } from '../../../i18n' +import { formatDate } from '../../../i18n/format' import { publisherGet, publisherSend, handleSessionError } from '../api' import { buildErrorCard } from '../components/error-card' import { dateTimeToIso } from '../components/dataset-form' @@ -133,14 +136,13 @@ export async function renderFeaturedHeroPage( if (!meRes.ok || !datasetsRes.ok || !heroRes.ok) return if (!clientIsPrivileged(meRes.data)) { - mount.replaceChildren( - shell( - card( - heading(t('publisher.hero.title')), - el('p', { className: 'publisher-hero-restricted', textContent: t('publisher.hero.restricted') }), - ), - ), - ) + // Read-only view: a non-admin publisher can see which dataset is + // currently featured (and its window / headline) but not change + // it. The write endpoints stay 403 server-side regardless. + renderReadOnly(mount, { + datasets: datasetsRes.data.datasets, + currentHero: heroRes.data.hero, + }) return } @@ -152,6 +154,80 @@ export async function renderFeaturedHeroPage( }) } +interface ReadOnlyState { + datasets: DatasetsResponse['datasets'] + currentHero: HeroResponse['hero'] +} + +/** Render the view-only hero surface for non-admin publishers: the + * currently-featured dataset (preview card + window + headline) or an + * empty state, with no editing controls. */ +function renderReadOnly(mount: HTMLElement, state: ReadOnlyState): void { + const { datasets, currentHero } = state + const children: HTMLElement[] = [ + heading(t('publisher.hero.title')), + el('p', { className: 'publisher-hero-intro', textContent: t('publisher.hero.readonly.notice') }), + ] + + if (currentHero) { + const ds = datasets.find(d => d.id === currentHero.datasetId) + const display = currentHero.headline?.trim() || ds?.title || currentHero.datasetId + children.push( + el('span', { className: 'publisher-field-label', textContent: t('publisher.hero.readonly.current') }), + heroPreviewCard(display, ds?.thumbnail_url ?? null), + el('p', { + className: 'publisher-hero-readonly-meta', + textContent: t('publisher.hero.readonly.window', { + start: formatWindowStamp(currentHero.window.start), + end: formatWindowStamp(currentHero.window.end), + }), + }), + ) + if (currentHero.headline?.trim()) { + children.push( + el('p', { + className: 'publisher-hero-readonly-meta', + textContent: t('publisher.hero.readonly.headline', { headline: currentHero.headline.trim() }), + }), + ) + } + } else { + children.push( + el('p', { className: 'publisher-hero-readonly-meta', textContent: t('publisher.hero.readonly.none') }), + ) + } + + mount.replaceChildren(shell(card(...children))) +} + +/** Build the live hero-panel preview card for a given title + thumb. + * Shared shape with `renderForm`'s live preview so the read-only view + * looks identical to what ships. */ +function heroPreviewCard(title: string, thumb: string | null): HTMLElement { + const preview = el('div', { className: 'publisher-hero-preview hero-panel' }) + const inner = el('div', { className: 'hero-panel-inner' }) + const cardBtn = el('div', { className: 'hero-panel-card' }) + if (thumb) cardBtn.append(el('img', { className: 'hero-panel-thumb', src: thumb, alt: '' })) + cardBtn.append( + el('span', { className: 'hero-panel-text' }, [ + el('span', { className: 'hero-panel-eyebrow', textContent: t('browse.hero.heading') }), + el('span', { className: 'hero-panel-title', textContent: title }), + el('span', { className: 'hero-panel-badge', textContent: t('browse.hero.label') }), + ]), + ) + inner.append(cardBtn) + preview.append(inner) + return preview +} + +/** Locale-aware date+time for the read-only window display. Falls back + * to the raw ISO string when unparseable. */ +function formatWindowStamp(iso: string): string { + const ms = Date.parse(iso) + if (!Number.isFinite(ms)) return iso + return formatDate(new Date(ms), { dateStyle: 'medium', timeStyle: 'short' }) +} + /** Wrap page content in the portal's `

` * landmark, matching the other portal pages (see index.ts). */ function shell(...children: HTMLElement[]): HTMLElement { diff --git a/src/ui/publisher/pages/feedback.ts b/src/ui/publisher/pages/feedback.ts index 93985b7f4..541f182a9 100644 --- a/src/ui/publisher/pages/feedback.ts +++ b/src/ui/publisher/pages/feedback.ts @@ -4,9 +4,9 @@ * * Replaces the hand-rolled HTML dashboard the legacy * `/api/feedback-admin` endpoint served (its `?action=` CSV/JSONL - * machine exports survive and are linked from here). Privileged-only, - * same client gate as featured-hero/analytics; data comes from - * `GET /api/v1/publish/feedback`, which fronts the same + * machine exports survive and are linked from here). Read-only for + * any active publisher — the page never mutates feedback; data comes + * from `GET /api/v1/publish/feedback`, which fronts the same * `_feedback-helpers` data layer the old dashboard used. * * Two views, switched by tabs: @@ -31,17 +31,11 @@ import { buildErrorCard } from '../components/error-card' import { ROUTE_CHANGE_START_EVENT } from '../router' import { renderBarSeries, renderStatTile } from '../analytics-charts' -const ME_ENDPOINT = '/api/v1/publish/me' const FEEDBACK_ENDPOINT = '/api/v1/publish/feedback' const AI_EXPORT_URL = '/api/feedback-admin?action=ai-export&include_prompt=true' const GENERAL_EXPORT_URL = '/api/feedback-admin?action=general-export' const RANGE_CHOICES = [7, 30, 90, 365] as const -interface MeResponse { - role: string - is_admin: boolean -} - interface AiRow { rating: string comment: string @@ -99,10 +93,6 @@ export interface FeedbackPageOptions { navigate?: (url: string) => void } -function clientIsPrivileged(me: MeResponse): boolean { - return me.is_admin === true || me.role === 'admin' || me.role === 'service' -} - function el( tag: K, props: Partial & { className?: string } = {}, @@ -140,29 +130,9 @@ export async function renderFeedbackPage( shell(el('p', { className: 'publisher-loading', textContent: t('publisher.feedback.loading') })), ) - const meRes = await publisherGet(ME_ENDPOINT, { fetchFn }) - if (!meRes.ok) { - if (meRes.kind === 'session') { - if (handleSessionError({ navigate: options.navigate }) === 'navigating') return - mount.replaceChildren(shell(buildErrorCard('session'))) - return - } - const details = meRes.kind === 'server' ? { status: meRes.status, body: meRes.body } : {} - mount.replaceChildren(shell(buildErrorCard(meRes.kind, details))) - return - } - if (!clientIsPrivileged(meRes.data)) { - mount.replaceChildren( - shell( - el('h1', { textContent: t('publisher.feedback.title') }), - el('p', { - className: 'publisher-hero-restricted', - textContent: t('publisher.feedback.restricted'), - }), - ), - ) - return - } + // Feedback review is a read-only surface, open to any active + // publisher. The per-view data reads below surface any session / + // server error; there's no separate role gate here. const state = { view: 'ai' as 'ai' | 'general', days: 30 as (typeof RANGE_CHOICES)[number] } const contentHost = el('section', { className: 'publisher-feedback-content' }) From 37bbdebefbe474290b832a40efdc8fb110cfd32c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 22:45:52 +0000 Subject: [PATCH 03/25] events: read-all / write-own access model with approval-based ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude --- functions/api/v1/_lib/events-store.test.ts | 2 + functions/api/v1/_lib/events-store.ts | 48 ++++++++++++++++++- functions/api/v1/publish/events.test.ts | 18 ++++--- functions/api/v1/publish/events.ts | 23 +++++---- functions/api/v1/publish/events/[id].test.ts | 44 +++++++++++++---- functions/api/v1/publish/events/[id].ts | 22 +++++++-- .../api/v1/publish/events/[id]/image.test.ts | 14 +++++- functions/api/v1/publish/events/[id]/image.ts | 10 ++-- .../api/v1/publish/events/[id]/tour.test.ts | 7 +-- functions/api/v1/publish/events/[id]/tour.ts | 10 ++-- migrations/catalog/0038_event_owner.sql | 28 +++++++++++ 11 files changed, 182 insertions(+), 44 deletions(-) create mode 100644 migrations/catalog/0038_event_owner.sql diff --git a/functions/api/v1/_lib/events-store.test.ts b/functions/api/v1/_lib/events-store.test.ts index b6522cea7..3f797a664 100644 --- a/functions/api/v1/_lib/events-store.test.ts +++ b/functions/api/v1/_lib/events-store.test.ts @@ -399,6 +399,7 @@ describe('toPublicEvent', () => { image_url: null, image_alt: null, video_embed_url: null, + owner_id: null, } expect(toPublicEvent({ ...base, image_url: 'https://img.ex/story.jpg' }).imageUrl).toBe('https://img.ex/story.jpg') // eslint-disable-next-line no-script-url @@ -435,6 +436,7 @@ describe('toPublicEvent', () => { reviewed_at: null, reviewed_by: null, inferred_fields: null, + owner_id: null, }) expect(pub.geometry).toEqual({}) expect(pub.summary).toBeUndefined() diff --git a/functions/api/v1/_lib/events-store.ts b/functions/api/v1/_lib/events-store.ts index a022b5254..70808ff9d 100644 --- a/functions/api/v1/_lib/events-store.ts +++ b/functions/api/v1/_lib/events-store.ts @@ -26,6 +26,7 @@ import { newUlid } from './ulid' import { isNocookieEmbedUrl } from './youtube-channels' +import { type PublisherRow, isPrivileged } from './publisher-store' /** KV key the public `GET /api/v1/featured-event` caches under. Shared * with the review route so an approve/reject can bust it for immediate @@ -104,6 +105,11 @@ export interface CurrentEventRow { updated_at: string reviewed_at: string | null reviewed_by: string | null + /** Durable owner (publishers.id) — the creator of a manual event, or + * the publisher who first approved a feed-proposed one. NULL while a + * proposed event is still unclaimed. Distinct from `reviewed_by` + * (the last curator to act). Gates the write path; reads are open. */ + owner_id: string | null /** JSON array of AI-filled field names ('["occurredStart","geometry"]'); * NULL when everything came from the source (slice C provenance). */ inferred_fields: string | null @@ -194,6 +200,10 @@ export interface NewCurrentEvent { imageAlt?: string | null /** Curator-picked video embed url; feeds never carry one. */ videoEmbedUrl?: string | null + /** Durable owner (publishers.id). The manual-create path sets this to + * the creating publisher; the ingest path leaves it null (a feed + * event is unclaimed until someone approves it). */ + ownerId?: string | null } /** Fields a caller supplies to {@link upsertEventDatasetLink}. */ @@ -210,7 +220,7 @@ export interface NewEventDatasetLink { const EVENT_COLUMNS = `id, origin_node, title, summary, source_name, source_url, published_at, feed_id, external_id, occurred_start, occurred_end, bbox_n, bbox_s, bbox_w, bbox_e, point_lat, point_lon, region_name, - status, created_at, updated_at, reviewed_at, reviewed_by, inferred_fields, image_url, image_alt, video_embed_url` + status, created_at, updated_at, reviewed_at, reviewed_by, inferred_fields, image_url, image_alt, video_embed_url, owner_id` const LINK_COLUMNS = `event_id, dataset_id, match_score, signals_json, status, created_at, approved_at, approved_by` @@ -260,12 +270,13 @@ export async function insertCurrentEvent( image_url: input.imageUrl ?? null, image_alt: input.imageAlt ?? null, video_embed_url: input.videoEmbedUrl ?? null, + owner_id: input.ownerId ?? null, } await db .prepare( `INSERT INTO current_events (${EVENT_COLUMNS}) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( row.id, @@ -295,6 +306,7 @@ export async function insertCurrentEvent( row.image_url, row.image_alt, row.video_embed_url, + row.owner_id, ) .run() @@ -516,6 +528,38 @@ export async function expireStaleProposedEvents( return res.meta?.changes ?? 0 } +/** + * Whether `publisher` may mutate (review / edit / add image / generate + * tour for) the given event. Mirrors the datasets rule, with the + * events twist that an unclaimed event (`owner_id === null`) is open — + * that's what lets the first approver claim it. Once owned, only the + * owner or a privileged (admin / service) caller may write. Reads are + * always open; this only gates writes. + */ +export function canMutateEvent( + publisher: PublisherRow, + event: Pick, +): boolean { + return isPrivileged(publisher) || event.owner_id == null || event.owner_id === publisher.id +} + +/** + * Claim ownership of an as-yet-unclaimed event. Sets `owner_id` only + * when it is currently NULL, so a later reviewer never steals ownership + * from the first approver. Called when a publisher approves a proposed + * event. Returns nothing — idempotent for an already-owned row. + */ +export async function claimEventOwner( + db: D1Database, + id: string, + ownerId: string, +): Promise { + await db + .prepare(`UPDATE current_events SET owner_id = ? WHERE id = ? AND owner_id IS NULL`) + .bind(ownerId, id) + .run() +} + /** * Transition an event's curator status, stamping the audit fields * (`reviewed_at` / `reviewed_by`) and `updated_at`. No-op result is the diff --git a/functions/api/v1/publish/events.test.ts b/functions/api/v1/publish/events.test.ts index f2af104b1..5f860e2be 100644 --- a/functions/api/v1/publish/events.test.ts +++ b/functions/api/v1/publish/events.test.ts @@ -2,7 +2,7 @@ * Wire-level tests for GET /api/v1/publish/events — the current-events * review queue. * - * Coverage: privileged gate (403 for publisher), the assembled + * Coverage: read-open / create-owns access model, the assembled * event+links+title shape, the `?status` filter, 400 for a bad status, * and 503 when CATALOG_DB is unbound. */ @@ -71,10 +71,10 @@ const SAMPLE = { } describe('GET /api/v1/publish/events', () => { - it('403 for a publisher-role account', async () => { + it('is readable by a publisher-role account (whole-queue read access)', async () => { const { env } = setupEnv() const res = await eventsGet(ctx({ env, publisher: PUBLISHER })) - expect(res.status).toBe(403) + expect(res.status).toBe(200) }) it('503 when CATALOG_DB is unbound', async () => { @@ -177,10 +177,16 @@ const CREATE = { } describe('POST /api/v1/publish/events', () => { - it('403 for a publisher-role account', async () => { - const { env } = setupEnv() + it('lets a publisher-role account create an event and makes them the owner', async () => { + const { env, sqlite } = setupEnv() const res = await eventsPost(postCtx({ env, publisher: PUBLISHER, body: CREATE })) - expect(res.status).toBe(403) + expect(res.status).toBe(201) + const body = JSON.parse(await res.text()) as { event: { id: string } } + // The creating publisher is stamped as owner. + const row = sqlite + .prepare(`SELECT owner_id FROM current_events WHERE id = ?`) + .get(body.event.id) as { owner_id: string | null } + expect(row.owner_id).toBe(PUBLISHER.id) }) it('400 when provenance is missing', async () => { diff --git a/functions/api/v1/publish/events.ts b/functions/api/v1/publish/events.ts index 755dd91c2..3e4e983b7 100644 --- a/functions/api/v1/publish/events.ts +++ b/functions/api/v1/publish/events.ts @@ -19,10 +19,10 @@ import type { CatalogEnv } from '../_lib/env' import type { PublisherData } from './_middleware' -import { isPrivileged } from '../_lib/publisher-store' import { writeAuditEvent } from '../_lib/audit-store' import { parseCreate, resolveOriginNode, ingestEvent, type FieldError } from '../_lib/events-ingest' import { + canMutateEvent, listCurrentEvents, listLinksForEvent, listLinksForEvents, @@ -83,10 +83,11 @@ export const onRequestGet: PagesFunction = async context => { if (!context.env.CATALOG_DB) { return jsonError(503, 'binding_missing', 'CATALOG_DB binding is not configured on this deployment.') } + // Read-only view of the whole events queue is open to any active + // publisher (the middleware has already rejected pending / suspended). + // Per-event `can_edit` below tells the portal which rows the caller + // may act on; the write endpoints enforce it independently. const publisher = (context.data as unknown as PublisherData).publisher - if (!isPrivileged(publisher)) { - return jsonError(403, 'forbidden_role', 'The events review queue is restricted to admin and service callers.') - } // `?status=` narrows to one bucket; `?status=all` lists every status // (so a curator can find + manage already-reviewed events); omitted @@ -117,6 +118,7 @@ export const onRequestGet: PagesFunction = async context => { const decorations = decorationsByEvent.get(row.id) ?? { categories: {}, keywords: [] } return { ...toPublicEvent(row, decorations), + can_edit: canMutateEvent(publisher, row), links: links.map(l => toPublicLink(l, titles.get(l.dataset_id) ?? null)), } }) @@ -140,10 +142,11 @@ export const onRequestPost: PagesFunction = async context => { if (!context.env.CATALOG_DB) { return jsonError(503, 'binding_missing', 'CATALOG_DB binding is not configured on this deployment.') } + // Any active publisher may create a manual event, and doing so makes + // them its owner (symmetric with drafting a blog / creating a + // dataset). It still starts `proposed` and needs approval to surface + // publicly, but only the creator / an admin may edit it thereafter. const publisher = (context.data as unknown as PublisherData).publisher - if (!isPrivileged(publisher)) { - return jsonError(403, 'forbidden_role', 'Creating events is restricted to admin and service callers.') - } let body: unknown try { @@ -156,7 +159,11 @@ export const onRequestPost: PagesFunction = async context => { if (!parsed.ok) return validationFailure(parsed.errors) const db = context.env.CATALOG_DB - const input: NewCurrentEvent = { ...parsed.value, originNode: await resolveOriginNode(db) } + const input: NewCurrentEvent = { + ...parsed.value, + originNode: await resolveOriginNode(db), + ownerId: publisher.id, + } // Idempotent on the feed dedupe key, runs the matcher inline so the // review queue is pre-populated — shared with the refresh route. The diff --git a/functions/api/v1/publish/events/[id].test.ts b/functions/api/v1/publish/events/[id].test.ts index 8ba1c7829..eeaee0ad9 100644 --- a/functions/api/v1/publish/events/[id].test.ts +++ b/functions/api/v1/publish/events/[id].test.ts @@ -2,7 +2,7 @@ * Wire-level tests for POST /api/v1/publish/events/:id — the curator * review-submit. * - * Coverage: privileged gate (403), 404 unknown event, 400 empty body, + * Coverage: owner-scoped write gate, 404 unknown event, 400 empty body, * 400 unknown link, event approve, per-link approve/reject, and the * `event.reviewed` audit row. */ @@ -36,12 +36,14 @@ const DS_1 = 'DS001' + 'A'.repeat(21) function setupEnv() { const sqlite = seedFixtures({ count: 2 }) - sqlite - .prepare( - `INSERT INTO publishers (id, email, display_name, role, is_admin, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - ) - .run(ADMIN.id, ADMIN.email, ADMIN.display_name, ADMIN.role, ADMIN.is_admin, ADMIN.status, ADMIN.created_at) + for (const p of [ADMIN, PUBLISHER]) { + sqlite + .prepare( + `INSERT INTO publishers (id, email, display_name, role, is_admin, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(p.id, p.email, p.display_name, p.role, p.is_admin, p.status, p.created_at) + } return { sqlite, env: { CATALOG_DB: asD1(sqlite) } } } @@ -81,11 +83,37 @@ async function seedEventWithLink(env: { CATALOG_DB: D1Database }) { } describe('POST /api/v1/publish/events/:id', () => { - it('403 for a publisher-role account', async () => { + it('lets a publisher approve an unclaimed event and claims ownership for them', async () => { const { env } = setupEnv() const id = await seedEventWithLink(env) const res = await reviewPost(ctx({ env, id, publisher: PUBLISHER, body: { event: 'approve' } })) + expect(res.status).toBe(200) + const row = await getCurrentEvent(env.CATALOG_DB, id) + expect(row!.status).toBe('approved') + expect(row!.owner_id).toBe(PUBLISHER.id) // approving claimed it + const body = JSON.parse(await res.text()) as { event: { can_edit: boolean } } + expect(body.event.can_edit).toBe(true) + }) + + it('403 forbidden_owner when a publisher reviews an event owned by someone else', async () => { + const { env } = setupEnv() + // Seed an event already owned by the admin. + const id = (await insertCurrentEvent(env.CATALOG_DB, { ...SAMPLE, ownerId: 'PUB-ADMIN' })).id + await upsertEventDatasetLink(env.CATALOG_DB, { eventId: id, datasetId: DS_0, matchScore: 0.9 }) + const res = await reviewPost(ctx({ env, id, publisher: PUBLISHER, body: { event: 'approve' } })) expect(res.status).toBe(403) + expect((JSON.parse(await res.text()) as { error: string }).error).toBe('forbidden_owner') + }) + + it('does not transfer ownership when a different owner-or-admin re-reviews', async () => { + const { env } = setupEnv() + // Event already owned by the publisher; an admin re-review must not steal it. + const id = (await insertCurrentEvent(env.CATALOG_DB, { ...SAMPLE, ownerId: PUBLISHER.id })).id + await upsertEventDatasetLink(env.CATALOG_DB, { eventId: id, datasetId: DS_0, matchScore: 0.9 }) + const res = await reviewPost(ctx({ env, id, body: { event: 'approve' } })) // ctx defaults to ADMIN + expect(res.status).toBe(200) + const row = await getCurrentEvent(env.CATALOG_DB, id) + expect(row!.owner_id).toBe(PUBLISHER.id) // unchanged }) it('404 for an unknown event', async () => { diff --git a/functions/api/v1/publish/events/[id].ts b/functions/api/v1/publish/events/[id].ts index fc6c6d75c..8f6356860 100644 --- a/functions/api/v1/publish/events/[id].ts +++ b/functions/api/v1/publish/events/[id].ts @@ -39,9 +39,10 @@ import type { CatalogEnv } from '../../_lib/env' import type { PublisherData } from '../_middleware' -import { isPrivileged } from '../../_lib/publisher-store' import { writeAuditEvent } from '../../_lib/audit-store' import { + canMutateEvent, + claimEventOwner, getCurrentEvent, listLinksForEvent, getEventDecorations, @@ -238,9 +239,6 @@ export const onRequestPost: PagesFunction = async context => { return jsonError(503, 'binding_missing', 'CATALOG_DB binding is not configured on this deployment.') } const publisher = (context.data as unknown as PublisherData).publisher - if (!isPrivileged(publisher)) { - return jsonError(403, 'forbidden_role', 'Reviewing events is restricted to admin and service callers.') - } const idParam = context.params.id const id = Array.isArray(idParam) ? idParam[0] : idParam @@ -260,6 +258,13 @@ export const onRequestPost: PagesFunction = async context => { const event = await getCurrentEvent(db, id) if (!event) return jsonError(404, 'not_found', `Event ${id} not found.`) + // Write-scope: an unclaimed event is open (approving it is how a + // publisher claims it); once owned, only the owner or an admin may + // review it further. + if (!canMutateEvent(publisher, event)) { + return jsonError(403, 'forbidden_owner', 'You can only review events you own.') + } + // Apply metadata corrections before anything else, so a same-submit // approve acts on the corrected event, and re-run the matcher so the // T/Ti/G signals score against the curator's values (statuses are @@ -321,6 +326,11 @@ export const onRequestPost: PagesFunction = async context => { if (parsed.value.event) { await setEventStatus(db, id, DECISION_TO_STATUS[parsed.value.event], publisher.id) + // Approving an unclaimed event claims it for the approver (no-op if + // it already has an owner). Reject never assigns ownership. + if (parsed.value.event === 'approve') { + await claimEventOwner(db, id, publisher.id) + } } for (const link of parsed.value.links) { await setLinkStatus(db, id, link.datasetId, DECISION_TO_STATUS[link.decision], publisher.id) @@ -351,7 +361,9 @@ export const onRequestPost: PagesFunction = async context => { const links = await listLinksForEvent(db, id) return new Response( JSON.stringify({ - event: updated ? toPublicEvent(updated, decorations) : null, + event: updated + ? { ...toPublicEvent(updated, decorations), can_edit: canMutateEvent(publisher, updated) } + : null, links: links.map(l => ({ datasetId: l.dataset_id, score: l.match_score, diff --git a/functions/api/v1/publish/events/[id]/image.test.ts b/functions/api/v1/publish/events/[id]/image.test.ts index ddefcda8c..41394ea01 100644 --- a/functions/api/v1/publish/events/[id]/image.test.ts +++ b/functions/api/v1/publish/events/[id]/image.test.ts @@ -42,6 +42,15 @@ function makeBucket() { function setupEnv() { const sqlite = seedFixtures({ count: 0 }) + // Seed publisher rows so an event's owner_id FK resolves. + for (const p of [ADMIN, PUBLISHER]) { + sqlite + .prepare( + `INSERT INTO publishers (id, email, display_name, role, is_admin, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(p.id, p.email, p.display_name, p.role, p.is_admin, p.status, p.created_at) + } const { bucket, puts } = makeBucket() const env = { CATALOG_DB: asD1(sqlite), CATALOG_R2: bucket, MOCK_R2: 'true' } return { sqlite, env, puts } @@ -81,11 +90,12 @@ const toB64 = (bytes: Uint8Array): string => Buffer.from(bytes).toString('base64 const PNG_BODY = { contentType: 'image/png', dataBase64: toB64(pngBytes()) } describe('POST /api/v1/publish/events/:id/image', () => { - it('is 403 for a publisher-role account', async () => { + it('is 403 forbidden_owner when a publisher targets an event owned by someone else', async () => { const { env } = setupEnv() - const id = (await insertCurrentEvent(env.CATALOG_DB, SAMPLE)).id + const id = (await insertCurrentEvent(env.CATALOG_DB, { ...SAMPLE, ownerId: 'PUB-ADMIN' })).id const res = await imagePost(ctx({ env, id, publisher: PUBLISHER, body: PNG_BODY })) expect(res.status).toBe(403) + expect((JSON.parse(await res.text()) as { error: string }).error).toBe('forbidden_owner') }) it('is 404 for an unknown event', async () => { diff --git a/functions/api/v1/publish/events/[id]/image.ts b/functions/api/v1/publish/events/[id]/image.ts index 9769669cf..3eeffdc0f 100644 --- a/functions/api/v1/publish/events/[id]/image.ts +++ b/functions/api/v1/publish/events/[id]/image.ts @@ -25,11 +25,10 @@ import type { CatalogEnv } from '../../../_lib/env' import type { PublisherData } from '../../_middleware' -import { isPrivileged } from '../../../_lib/publisher-store' import { writeAuditEvent } from '../../../_lib/audit-store' import { sha256Hex, validateImagePayload } from '../../../_lib/image-upload' import { isR2PublicConfigured, resolveHttpAssetUrl } from '../../../_lib/r2-public-url' -import { applyEventEdits, bustFeaturedEventCache, getCurrentEvent } from '../../../_lib/events-store' +import { applyEventEdits, bustFeaturedEventCache, canMutateEvent, getCurrentEvent } from '../../../_lib/events-store' const CONTENT_TYPE = 'application/json; charset=utf-8' @@ -55,9 +54,6 @@ export const onRequestPost: PagesFunction = async context => { return jsonError(503, 'r2_unconfigured', 'R2 public reads are not configured on this deployment.') } const publisher = (context.data as unknown as PublisherData).publisher - if (!isPrivileged(publisher)) { - return jsonError(403, 'forbidden_role', 'Uploading event images is restricted to admin and service callers.') - } const idParam = context.params.id const id = Array.isArray(idParam) ? idParam[0] : idParam @@ -66,6 +62,10 @@ export const onRequestPost: PagesFunction = async context => { const db = context.env.CATALOG_DB const event = await getCurrentEvent(db, id) if (!event) return jsonError(404, 'not_found', `Event ${id} not found.`) + // Owner-scoped write (unclaimed events are open; see canMutateEvent). + if (!canMutateEvent(publisher, event)) { + return jsonError(403, 'forbidden_owner', 'You can only edit events you own.') + } let body: { contentType?: unknown; dataBase64?: unknown; altText?: unknown } try { diff --git a/functions/api/v1/publish/events/[id]/tour.test.ts b/functions/api/v1/publish/events/[id]/tour.test.ts index 11f84f2ba..a02fc9d6d 100644 --- a/functions/api/v1/publish/events/[id]/tour.test.ts +++ b/functions/api/v1/publish/events/[id]/tour.test.ts @@ -2,7 +2,7 @@ * Wire-level tests for POST /api/v1/publish/events/:id/tour — the * generate-a-tour-draft action. * - * Coverage: privileged gate (403), 404 unknown event, 400 when no + * Coverage: owner-scoped write gate, 404 unknown event, 400 when no * visible dataset pairings exist, the approved-links-beat-proposed * stop selection, the happy path (201, D1 tour row, R2 draft blob * whose tasks include the event's flyTo/setTime/captions), template @@ -105,11 +105,12 @@ async function readJson(res: Response): Promise { } describe('POST /api/v1/publish/events/:id/tour', () => { - it('403 for a publisher-role account', async () => { + it('403 forbidden_owner when a publisher targets an event owned by someone else', async () => { const { env } = setupEnv() - const id = (await insertCurrentEvent(env.CATALOG_DB, SAMPLE)).id + const id = (await insertCurrentEvent(env.CATALOG_DB, { ...SAMPLE, ownerId: 'PUB-ADMIN' })).id const res = await tourPost(ctx({ env, id, publisher: PUBLISHER })) expect(res.status).toBe(403) + expect((JSON.parse(await res.text()) as { error: string }).error).toBe('forbidden_owner') }) it('404 for an unknown event', async () => { diff --git a/functions/api/v1/publish/events/[id]/tour.ts b/functions/api/v1/publish/events/[id]/tour.ts index ebb9e1d50..1d7137b01 100644 --- a/functions/api/v1/publish/events/[id]/tour.ts +++ b/functions/api/v1/publish/events/[id]/tour.ts @@ -20,9 +20,8 @@ import type { CatalogEnv } from '../../../_lib/env' import type { EnrichEnv } from '../../../_lib/events-enrich' import type { PublisherData } from '../../_middleware' import { getEffectiveFeatures } from '../../../_lib/node-settings-store' -import { isPrivileged } from '../../../_lib/publisher-store' import { writeAuditEvent } from '../../../_lib/audit-store' -import { getCurrentEvent, listLinksForEvent } from '../../../_lib/events-store' +import { canMutateEvent, getCurrentEvent, listLinksForEvent } from '../../../_lib/events-store' import { resolveHttpAssetUrl } from '../../../_lib/r2-public-url' import { createDraftTour, writeTourDraftJson } from '../../../_lib/tour-mutations' import { @@ -100,9 +99,6 @@ export const onRequestPost: PagesFunction = async return jsonError(503, 'binding_missing', 'CATALOG_DB binding is not configured on this deployment.') } const publisher = (context.data as unknown as PublisherData).publisher - if (!isPrivileged(publisher)) { - return jsonError(403, 'forbidden_role', 'Generating event tours is restricted to admin and service callers.') - } // Cross-feature coupling: the middleware gates this path on `events` // (its prefix), but the handler CREATES a tour draft — that needs @@ -125,6 +121,10 @@ export const onRequestPost: PagesFunction = async const db = context.env.CATALOG_DB const event = await getCurrentEvent(db, id) if (!event) return jsonError(404, 'not_found', `Event ${id} not found.`) + // Owner-scoped write (unclaimed events are open; see canMutateEvent). + if (!canMutateEvent(publisher, event)) { + return jsonError(403, 'forbidden_owner', 'You can only build tours for events you own.') + } const datasets = await resolveStopDatasets(db, id, ref => resolveHttpAssetUrl(context.env, ref)) if (datasets.length === 0) { diff --git a/migrations/catalog/0038_event_owner.sql b/migrations/catalog/0038_event_owner.sql new file mode 100644 index 000000000..f238b074f --- /dev/null +++ b/migrations/catalog/0038_event_owner.sql @@ -0,0 +1,28 @@ +-- 0038_event_owner.sql — ownership for current events. +-- +-- Publishers get the same "read all, write own" access to events that +-- they have to datasets. Events, unlike datasets, are often machine- +-- created by feed connectors (proposed, unowned), so ownership is +-- assigned by action rather than at insert: +-- +-- - A publisher who manually creates an event (the "+ New event" +-- drawer) owns it immediately. +-- - A publisher who approves an as-yet-unclaimed proposed event +-- becomes its owner. +-- +-- Once `owner_id` is set, only that owner (or an admin / service +-- caller) may mutate the event; an unclaimed event (owner_id IS NULL) +-- is open for any active publisher to act on, which is what lets the +-- first approver claim it. Read access is unconditional — the whole +-- events queue is visible to every active publisher. +-- +-- Distinct from `reviewed_by` (the last curator to act, set on every +-- review including a reject): `owner_id` is the durable owner, set once +-- and not overwritten by a later reviewer. +-- +-- Additive only: one nullable column + a lookup index. + +ALTER TABLE current_events ADD COLUMN owner_id TEXT REFERENCES publishers(id); + +-- "Which events does this publisher own?" — the write-scope lookup. +CREATE INDEX idx_current_events_owner ON current_events(owner_id); From e3753f69808b320968431a2fc896f1776c6d4269 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 22:51:40 +0000 Subject: [PATCH 04/25] blog: read-all / write-own access model (author-scoped writes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude --- functions/api/v1/_lib/blog-store.ts | 16 +++++- functions/api/v1/publish/blog.test.ts | 45 ++++++++++++++--- functions/api/v1/publish/blog.ts | 33 ++++++++----- functions/api/v1/publish/blog/[id].ts | 49 ++++++++++++------- .../api/v1/publish/blog/generate.test.ts | 18 ++++--- functions/api/v1/publish/blog/generate.ts | 6 +-- 6 files changed, 116 insertions(+), 51 deletions(-) diff --git a/functions/api/v1/_lib/blog-store.ts b/functions/api/v1/_lib/blog-store.ts index c6e91c805..a69ea4c04 100644 --- a/functions/api/v1/_lib/blog-store.ts +++ b/functions/api/v1/_lib/blog-store.ts @@ -13,7 +13,7 @@ */ import { newUlid } from './ulid' -import type { PublisherRow } from './publisher-store' +import { type PublisherRow, isPrivileged } from './publisher-store' /** Bounds keep the stored payload sane; the portal enforces the same * limits client-side. */ @@ -109,6 +109,20 @@ function parseDatasetIds(raw: string | null): string[] { } } +/** + * Whether `publisher` may mutate (edit / publish / unpublish / delete) + * the given post. Mirrors the datasets rule: the post's author, or a + * privileged (admin / service) caller. Reads are open to every active + * publisher; this only gates writes. `drafting a post makes them an + * owner` is already true — `insertBlogPost` stamps `author_id`. + */ +export function canMutateBlogPost( + publisher: PublisherRow, + row: Pick, +): boolean { + return isPrivileged(publisher) || row.author_id === publisher.id +} + export function toPublicPost(row: BlogPostRow): BlogPostPublic { return { id: row.id, diff --git a/functions/api/v1/publish/blog.test.ts b/functions/api/v1/publish/blog.test.ts index e2345bdc8..2c98b3251 100644 --- a/functions/api/v1/publish/blog.test.ts +++ b/functions/api/v1/publish/blog.test.ts @@ -38,12 +38,14 @@ const DS_1 = 'DS001' + 'A'.repeat(21) function setupEnv() { const sqlite = seedFixtures({ count: 2 }) - sqlite - .prepare( - `INSERT INTO publishers (id, email, display_name, role, is_admin, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - ) - .run(ADMIN.id, ADMIN.email, ADMIN.display_name, ADMIN.role, ADMIN.is_admin, ADMIN.status, ADMIN.created_at) + for (const p of [ADMIN, PUBLISHER]) { + sqlite + .prepare( + `INSERT INTO publishers (id, email, display_name, role, is_admin, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(p.id, p.email, p.display_name, p.role, p.is_admin, p.status, p.created_at) + } return { sqlite, env: { CATALOG_DB: asD1(sqlite), CATALOG_KV: makeKV() } } } @@ -93,10 +95,37 @@ async function createDraft(env: Record, body: Record { - it('POST is 403 for a publisher-role account', async () => { + it('lets a publisher-role account create a draft and stamps them as author', async () => { const { env } = setupEnv() const res = await createPost(ctx({ env, method: 'POST', publisher: PUBLISHER, body: VALID })) - expect(res.status).toBe(403) + expect(res.status).toBe(201) + const { post } = await readJson<{ post: { id: string } }>(res) + // The author is the creating publisher, and it's editable by them. + const list = await authorList(ctx({ env, publisher: PUBLISHER })) + const { posts } = await readJson<{ posts: Array<{ id: string; authorId: string; can_edit: boolean }> }>(list) + const mine = posts.find(p => p.id === post.id)! + expect(mine.authorId).toBe(PUBLISHER.id) + expect(mine.can_edit).toBe(true) + }) + + it('a publisher cannot edit or publish a post authored by someone else (403 forbidden_owner)', async () => { + const { env } = setupEnv() + // ADMIN authors a post; PUBLISHER may read it but not mutate it. + const post = await createDraft(env) // ctx defaults to ADMIN + const listed = await authorList(ctx({ env, publisher: PUBLISHER })) + const { posts } = await readJson<{ posts: Array<{ id: string; can_edit: boolean }> }>(listed) + expect(posts.find(p => p.id === post.id)!.can_edit).toBe(false) // visible, not editable + + const edit = await updateOne( + ctx({ env, method: 'PUT', publisher: PUBLISHER, params: { id: post.id }, body: VALID }), + ) + expect(edit.status).toBe(403) + expect((await readJson<{ error: string }>(edit)).error).toBe('forbidden_owner') + + const pub = await transition( + ctx({ env, method: 'POST', publisher: PUBLISHER, params: { id: post.id }, body: { action: 'publish' } }), + ) + expect(pub.status).toBe(403) }) it('create derives a slug, stores the draft, and audits blog.create', async () => { diff --git a/functions/api/v1/publish/blog.ts b/functions/api/v1/publish/blog.ts index 147576040..5d76c4c1d 100644 --- a/functions/api/v1/publish/blog.ts +++ b/functions/api/v1/publish/blog.ts @@ -3,21 +3,23 @@ * `docs/CURRENT_EVENTS_PLAN.md` §7 companion work). * * GET → All posts (drafts included), newest-updated first; optional - * `?status=draft|published`. Any signed-in publisher may read - * the authoring list. + * `?status=draft|published`. Readable by any active publisher; + * each post carries `can_edit` so the portal knows whether to + * offer the authoring controls. * POST → Create a draft post. Body: `{ title, bodyMd, summary?, - * datasetIds?, eventId? }`. Privileged-only (admin / service), - * 400 `{ errors }` for body problems, audit-logged - * (`blog.create`). Posts are born `draft`; publishing is a - * separate action on `/publish/blog/:id` — nothing - * auto-publishes. + * datasetIds?, eventId? }`. Open to any active publisher — + * drafting a post makes them its author/owner (only they, or an + * admin, may edit it thereafter). 400 `{ errors }` for body + * problems, audit-logged (`blog.create`). Posts are born + * `draft`; publishing is a separate action on + * `/publish/blog/:id` — nothing auto-publishes. */ import type { CatalogEnv } from '../_lib/env' import type { PublisherData } from './_middleware' -import { isPrivileged } from '../_lib/publisher-store' import { writeAuditEvent } from '../_lib/audit-store' import { + canMutateBlogPost, insertBlogPost, listBlogPosts, toPublicPost, @@ -46,8 +48,16 @@ export const onRequestGet: PagesFunction = async context => { } status = statusParam } + const publisher = (context.data as unknown as PublisherData).publisher const rows = await listBlogPosts(context.env.CATALOG_DB, { status }) - return new Response(JSON.stringify({ posts: rows.map(toPublicPost) }), { + // Stamp per-post `can_edit` so the portal only offers the authoring + // controls on posts the caller may mutate (their own, or any as an + // admin). The whole list is readable regardless. + const posts = rows.map(row => ({ + ...toPublicPost(row), + can_edit: canMutateBlogPost(publisher, row), + })) + return new Response(JSON.stringify({ posts }), { status: 200, headers: { 'Content-Type': CONTENT_TYPE, 'Cache-Control': 'private, no-store' }, }) @@ -57,10 +67,9 @@ export const onRequestPost: PagesFunction = async context => { if (!context.env.CATALOG_DB) { return jsonError(503, 'binding_missing', 'CATALOG_DB binding is not configured on this deployment.') } + // Any active publisher may create a draft; `insertBlogPost` stamps + // them as author, and only the author (or an admin) may edit it after. const publisher = (context.data as unknown as PublisherData).publisher - if (!isPrivileged(publisher)) { - return jsonError(403, 'forbidden_role', 'Creating blog posts is restricted to admin and service callers.') - } let body: unknown try { diff --git a/functions/api/v1/publish/blog/[id].ts b/functions/api/v1/publish/blog/[id].ts index 8c0929444..68af3bc12 100644 --- a/functions/api/v1/publish/blog/[id].ts +++ b/functions/api/v1/publish/blog/[id].ts @@ -2,14 +2,16 @@ * /api/v1/publish/blog/:id — single-post authoring operations * (Phase 3d). * - * GET → The post, drafts included (any signed-in publisher). + * GET → The post, drafts included (any active publisher). Carries + * `can_edit` so the portal knows whether to offer authoring. * PUT → Update content fields (`{ title, bodyMd, summary?, * datasetIds?, eventId? }`). The slug never changes — published - * URLs stay stable across edits. Privileged-only. + * URLs stay stable across edits. Owner-scoped: only the post's + * author (or an admin) may edit. * POST → `{ action: 'publish' | 'unpublish' }` — the status * transition. Publish is idempotent and keeps the first * publish time; unpublish returns the post to draft. - * Privileged-only. + * Owner-scoped, same rule as PUT. * * Every write is audit-logged (`blog.update` / `blog.publish` / * `blog.unpublish`) and busts the public blog caches so the change is @@ -18,10 +20,11 @@ import type { CatalogEnv } from '../../_lib/env' import type { PublisherData } from '../_middleware' -import { isPrivileged } from '../../_lib/publisher-store' +import type { PublisherRow } from '../../_lib/publisher-store' import { writeAuditEvent, type AuditAction } from '../../_lib/audit-store' import { bustBlogCache, + canMutateBlogPost, getBlogPost, publishBlogPost, toPublicPost, @@ -45,22 +48,26 @@ function paramId(context: Parameters>[0]): strin return id || null } -function okPost(row: Parameters[0]): Response { - return new Response(JSON.stringify({ post: toPublicPost(row) }), { - status: 200, - headers: { 'Content-Type': CONTENT_TYPE, 'Cache-Control': 'private, no-store' }, - }) +function okPost(row: Parameters[0], publisher: PublisherRow): Response { + return new Response( + JSON.stringify({ post: { ...toPublicPost(row), can_edit: canMutateBlogPost(publisher, row) } }), + { + status: 200, + headers: { 'Content-Type': CONTENT_TYPE, 'Cache-Control': 'private, no-store' }, + }, + ) } export const onRequestGet: PagesFunction = async context => { if (!context.env.CATALOG_DB) { return jsonError(503, 'binding_missing', 'CATALOG_DB binding is not configured on this deployment.') } + const publisher = (context.data as unknown as PublisherData).publisher const id = paramId(context) if (!id) return jsonError(400, 'invalid_request', 'Missing post id.') const row = await getBlogPost(context.env.CATALOG_DB, id) if (!row) return jsonError(404, 'not_found', `Post ${id} not found.`) - return okPost(row) + return okPost(row, publisher) } export const onRequestPut: PagesFunction = async context => { @@ -68,12 +75,17 @@ export const onRequestPut: PagesFunction = async context => { return jsonError(503, 'binding_missing', 'CATALOG_DB binding is not configured on this deployment.') } const publisher = (context.data as unknown as PublisherData).publisher - if (!isPrivileged(publisher)) { - return jsonError(403, 'forbidden_role', 'Editing blog posts is restricted to admin and service callers.') - } const id = paramId(context) if (!id) return jsonError(400, 'invalid_request', 'Missing post id.') + // Owner-scoped: only the author (or an admin) may edit. 404 for an + // unknown post; 403 for one the caller doesn't own. + const current = await getBlogPost(context.env.CATALOG_DB, id) + if (!current) return jsonError(404, 'not_found', `Post ${id} not found.`) + if (!canMutateBlogPost(publisher, current)) { + return jsonError(403, 'forbidden_owner', 'You can only edit blog posts you authored.') + } + let body: unknown try { body = await context.request.json() @@ -102,7 +114,7 @@ export const onRequestPut: PagesFunction = async context => { // A published post's content just changed — refresh the public reads. if (row.status === 'published') await bustBlogCache(context.env.CATALOG_KV, row.slug) - return okPost(row) + return okPost(row, publisher) } export const onRequestPost: PagesFunction = async context => { @@ -110,9 +122,6 @@ export const onRequestPost: PagesFunction = async context => { return jsonError(503, 'binding_missing', 'CATALOG_DB binding is not configured on this deployment.') } const publisher = (context.data as unknown as PublisherData).publisher - if (!isPrivileged(publisher)) { - return jsonError(403, 'forbidden_role', 'Publishing blog posts is restricted to admin and service callers.') - } const id = paramId(context) if (!id) return jsonError(400, 'invalid_request', 'Missing post id.') @@ -129,6 +138,10 @@ export const onRequestPost: PagesFunction = async context => { const existing = await getBlogPost(context.env.CATALOG_DB, id) if (!existing) return jsonError(404, 'not_found', `Post ${id} not found.`) + // Owner-scoped: only the author (or an admin) may (un)publish. + if (!canMutateBlogPost(publisher, existing)) { + return jsonError(403, 'forbidden_owner', 'You can only publish blog posts you authored.') + } const row = action === 'publish' @@ -147,5 +160,5 @@ export const onRequestPost: PagesFunction = async context => { }) await bustBlogCache(context.env.CATALOG_KV, row.slug) - return okPost(row) + return okPost(row, publisher) } diff --git a/functions/api/v1/publish/blog/generate.test.ts b/functions/api/v1/publish/blog/generate.test.ts index d7f0286fa..e0cab33c1 100644 --- a/functions/api/v1/publish/blog/generate.test.ts +++ b/functions/api/v1/publish/blog/generate.test.ts @@ -59,12 +59,14 @@ function aiStub(reply: unknown = { response: DRAFT_REPLY }) { function setupEnv(ai?: ReturnType) { const sqlite = seedFixtures({ count: 2 }) - sqlite - .prepare( - `INSERT INTO publishers (id, email, display_name, role, is_admin, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - ) - .run(ADMIN.id, ADMIN.email, ADMIN.display_name, ADMIN.role, ADMIN.is_admin, ADMIN.status, ADMIN.created_at) + for (const p of [ADMIN, PUBLISHER]) { + sqlite + .prepare( + `INSERT INTO publishers (id, email, display_name, role, is_admin, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(p.id, p.email, p.display_name, p.role, p.is_admin, p.status, p.created_at) + } // A node profile so the prompt grounding is observable. sqlite .prepare( @@ -108,10 +110,10 @@ async function readJson(res: Response): Promise { } describe('POST /api/v1/publish/blog/generate', () => { - it('403 for a publisher-role account', async () => { + it('is allowed for a publisher-role account (authoring helper, open to any active publisher)', async () => { const { env } = setupEnv(aiStub()) const res = await generate(ctx({ env, publisher: PUBLISHER, body: { datasetIds: [DS_0] } })) - expect(res.status).toBe(403) + expect(res.status).toBe(200) }) it('400 when no datasets are selected (or none are visible)', async () => { diff --git a/functions/api/v1/publish/blog/generate.ts b/functions/api/v1/publish/blog/generate.ts index 795920da5..1ced309c6 100644 --- a/functions/api/v1/publish/blog/generate.ts +++ b/functions/api/v1/publish/blog/generate.ts @@ -24,7 +24,6 @@ import type { CatalogEnv } from '../../_lib/env' import type { EnrichEnv } from '../../_lib/events-enrich' import type { PublisherData } from '../_middleware' -import { isPrivileged } from '../../_lib/publisher-store' import { writeAuditEvent } from '../../_lib/audit-store' import { getNodeProfile } from '../../_lib/node-profile-store' import { getEffectiveFeatures } from '../../_lib/node-settings-store' @@ -56,10 +55,9 @@ export const onRequestPost: PagesFunction = async contex if (!context.env.CATALOG_DB) { return jsonError(503, 'binding_missing', 'CATALOG_DB binding is not configured on this deployment.') } + // Authoring helper — open to any active publisher (they're drafting + // their own post; the companion tour it may create is owned by them). const publisher = (context.data as unknown as PublisherData).publisher - if (!isPrivileged(publisher)) { - return jsonError(403, 'forbidden_role', 'Generating blog drafts is restricted to admin and service callers.') - } let body: GenerateBody try { From 3abb87b79e690c5d5afcd7ad9f2574260671c095 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 23:05:48 +0000 Subject: [PATCH 05/25] publisher portal: surface Events + Blog to all publishers, gate writes per-ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude --- locales/en.json | 4 +- .../components/events/event-detail.ts | 67 ++++++----- .../components/events/events-model.ts | 5 + src/ui/publisher/components/sidebar.test.ts | 21 ++-- src/ui/publisher/components/sidebar.ts | 4 +- src/ui/publisher/pages/blog-edit.test.ts | 28 ++++- src/ui/publisher/pages/blog-edit.ts | 105 ++++++++++++------ src/ui/publisher/pages/blog.ts | 80 +++++-------- src/ui/publisher/pages/events.test.ts | 32 +++++- src/ui/publisher/pages/events.ts | 33 +++--- 10 files changed, 235 insertions(+), 144 deletions(-) diff --git a/locales/en.json b/locales/en.json index 1aceafa8b..ad383185e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -764,7 +764,7 @@ "publisher.blog.picker.searchPlaceholder": "Search published datasets…", "publisher.blog.publish": "Publish", "publisher.blog.published": "Published — live at /blog/{slug}.", - "publisher.blog.restricted": "Blog authoring is restricted to admin publishers.", + "publisher.blog.readonly.notice": "You have view-only access to this post — only its author can edit it.", "publisher.blog.save": "Save draft", "publisher.blog.saved": "Saved.", "publisher.blog.status.draft": "Draft", @@ -1065,12 +1065,12 @@ "publisher.events.queue.rowAria": "{title}, {category}, {count} datasets to review", "publisher.events.queue.toReview": "{count} datasets to review", "publisher.events.queue.uncategorized": "uncategorized", + "publisher.events.readonly.notice": "You have view-only access to this event — only its owner can review or edit it.", "publisher.events.refresh": "Refresh feed", "publisher.events.refreshError": "Couldn't reach the feed. Please try again.", "publisher.events.refreshResult": "Imported {created} new · {refreshed} refreshed · {failed} skipped.", "publisher.events.refreshing": "Refreshing…", "publisher.events.reject": "Reject", - "publisher.events.restricted": "Reviewing events is restricted to admin publishers.", "publisher.events.saved": "Saved.", "publisher.events.signal.geo": "Geo", "publisher.events.signal.temporal": "Time", diff --git a/src/ui/publisher/components/events/event-detail.ts b/src/ui/publisher/components/events/event-detail.ts index fced03a1d..c5368dea6 100644 --- a/src/ui/publisher/components/events/event-detail.ts +++ b/src/ui/publisher/components/events/event-detail.ts @@ -515,8 +515,10 @@ function renderMetadataEdit(event: ReviewEvent, cb: EventDetailCallbacks): HTMLE return wrap } -/** One dataset pairing row: name · Match Badge · ✓ / ✕ icon buttons. */ -function renderLinkRow(eventId: string, link: ReviewLink, cb: EventDetailCallbacks): HTMLElement { +/** One dataset pairing row: name · Match Badge · ✓ / ✕ icon buttons. + * When `canEdit` is false the row is display-only (no decision + * buttons) — the read half of read-all / write-own. */ +function renderLinkRow(eventId: string, link: ReviewLink, cb: EventDetailCallbacks, canEdit = true): HTMLElement { const row = el('div', `publisher-events-pairing publisher-events-pairing-${link.status}`) const name = el('span', 'publisher-events-pairing-name') name.textContent = link.datasetTitle ?? link.datasetId @@ -573,12 +575,10 @@ function renderLinkRow(eventId: string, link: ReviewLink, cb: EventDetailCallbac const approveBtn = iconBtn('approve') const rejectBtn = iconBtn('reject') - row.append( - name, - badgeEl, - el('span', 'publisher-events-pairing-actions', [approveBtn, rejectBtn]), - rowStatus, - ) + const actions = canEdit + ? el('span', 'publisher-events-pairing-actions', [approveBtn, rejectBtn]) + : el('span', 'publisher-events-pairing-actions') + row.append(name, badgeEl, actions, rowStatus) return row } @@ -586,6 +586,13 @@ function renderLinkRow(eventId: string, link: ReviewLink, cb: EventDetailCallbac export function renderEventDetail(event: ReviewEvent, cb: EventDetailCallbacks): HTMLElement { const pane = el('div', 'publisher-events-detail') + // Read-all / write-own: every publisher sees the full detail, but the + // review + edit affordances only appear on events the caller may + // mutate (its owner / an admin / an unclaimed event). `can_edit` + // absent (older payload / fixture) is treated as editable; the server + // enforces every write regardless. + const canEdit = event.can_edit !== false + // --- Header: title + status badge --- const badgeEl = badge(event.status) pane.append( @@ -595,6 +602,10 @@ export function renderEventDetail(event: ReviewEvent, cb: EventDetailCallbacks): ]), ) + if (!canEdit) { + pane.append(el('p', 'publisher-events-readonly-notice', [t('publisher.events.readonly.notice')])) + } + // --- Event-level decision (the heavy tier) — placed directly under the // title so a curator can triage (Approve / Reject) and clean the queue // without scrolling past the media, meta strip, and dataset pairings. @@ -635,13 +646,15 @@ export function renderEventDetail(event: ReviewEvent, cb: EventDetailCallbacks): approveEvent.addEventListener('click', () => submitEvent('approve')) rejectEvent.addEventListener('click', () => submitEvent('reject')) - pane.append( - el('div', 'publisher-events-decision', [ - el('p', 'publisher-events-decision-prompt', [t('publisher.events.decision.prompt')]), - el('div', 'publisher-events-decision-actions', [approveEvent, rejectEvent]), - decisionStatus, - ]), - ) + if (canEdit) { + pane.append( + el('div', 'publisher-events-decision', [ + el('p', 'publisher-events-decision-prompt', [t('publisher.events.decision.prompt')]), + el('div', 'publisher-events-decision-actions', [approveEvent, rejectEvent]), + decisionStatus, + ]), + ) + } // --- Story image (feed enclosure / og:image) — rendered so the // curator vets it alongside the text; approving the event approves @@ -655,22 +668,23 @@ export function renderEventDetail(event: ReviewEvent, cb: EventDetailCallbacks): img.loading = 'lazy' // A dead image link should vanish, not show a broken-image glyph. img.addEventListener('error', () => img.remove()) - pane.append(img, renderImageUpload(event, cb, 'replace')) - } else { + pane.append(img) + if (canEdit) pane.append(renderImageUpload(event, cb, 'replace')) + } else if (canEdit) { // The publisher's own photo is always an option, located or not. pane.append(renderImageUpload(event, cb, 'upload')) } // --- Attached agency video (the picked YouTube embed) — framed for // the curator to vet, with a Remove control. Independent of the image. - if (event.videoEmbedUrl && isNocookieEmbedUrl(event.videoEmbedUrl)) { + if (event.videoEmbedUrl && isNocookieEmbedUrl(event.videoEmbedUrl) && canEdit) { pane.append(renderAttachedVideo(event, cb)) } // --- Suggested media (task: media suggestion engine) — image // sources while imageless, the agency-YouTube source while videoless; // each pick writes through the review endpoint's edits. - const suggest = renderMediaSuggestions(event, cb) + const suggest = canEdit ? renderMediaSuggestions(event, cb) : null if (suggest) pane.append(suggest) // --- Meta strip: source / first observed / detail --- @@ -707,7 +721,7 @@ export function renderEventDetail(event: ReviewEvent, cb: EventDetailCallbacks): // Location is constrained to the same regions.ts vocabulary the // enrichment uses (offered via a datalist); the backend re-runs the // matcher so the pairing signals score the corrected values. - pane.append(renderMetadataEdit(event, cb)) + if (canEdit) pane.append(renderMetadataEdit(event, cb)) // --- Locator: live map slot, coordinates as text fallback --- const point = locatorPoint(event.geometry) @@ -734,7 +748,7 @@ export function renderEventDetail(event: ReviewEvent, cb: EventDetailCallbacks): const bulkStatus = el('span', 'publisher-events-bulk-status') bulkStatus.setAttribute('role', 'status') - const targets = autoPairTargets(event) + const targets = canEdit ? autoPairTargets(event) : [] if (targets.length > 0) { const bulkBtn = document.createElement('button') bulkBtn.type = 'button' @@ -810,7 +824,7 @@ export function renderEventDetail(event: ReviewEvent, cb: EventDetailCallbacks): tourBtn.disabled = false }) }) - headActions.append(tourBtn) + if (canEdit) headActions.append(tourBtn) // Generating a tour needs the tours feature (the endpoint checks it // too) — hide the action when the toggle is off. The map is // module-cached, so this resolves without a network round-trip. @@ -826,7 +840,7 @@ export function renderEventDetail(event: ReviewEvent, cb: EventDetailCallbacks): addBtn.className = 'publisher-button publisher-button-small publisher-events-add-btn' addBtn.textContent = t('publisher.events.addDataset') addBtn.setAttribute('aria-expanded', 'false') - headActions.append(addBtn) + if (canEdit) headActions.append(addBtn) pairings.append(head, bulkStatus, tourStatus) @@ -837,7 +851,7 @@ export function renderEventDetail(event: ReviewEvent, cb: EventDetailCallbacks): rowsHost.append(el('p', 'publisher-events-nolinks', [t('publisher.events.noLinks')])) return } - for (const link of event.links) rowsHost.append(renderLinkRow(event.id, link, cb)) + for (const link of event.links) rowsHost.append(renderLinkRow(event.id, link, cb, canEdit)) } rebuildRows() @@ -929,7 +943,10 @@ export function renderEventDetail(event: ReviewEvent, cb: EventDetailCallbacks): }) }) - pairings.append(addPanel, rowsHost) + // The add-dataset panel is a write affordance — only mount it (and its + // toggle) for editable events; read-only sees just the pairing rows. + if (canEdit) pairings.append(addPanel, rowsHost) + else pairings.append(rowsHost) pane.append(pairings) return pane diff --git a/src/ui/publisher/components/events/events-model.ts b/src/ui/publisher/components/events/events-model.ts index 6cb61d5d6..1e6ee4bac 100644 --- a/src/ui/publisher/components/events/events-model.ts +++ b/src/ui/publisher/components/events/events-model.ts @@ -64,6 +64,11 @@ export interface ReviewEvent { * generated tour frames it; independent of the story image. */ videoEmbedUrl?: string links: ReviewLink[] + /** Whether the caller may review/edit this event (its owner, an + * admin, or — for an as-yet-unclaimed event — any active publisher, + * since approving claims it). Absent (older payload / fixture) is + * treated as editable; the server is the authoritative gate. */ + can_edit?: boolean } export interface EventsResponse { diff --git a/src/ui/publisher/components/sidebar.test.ts b/src/ui/publisher/components/sidebar.test.ts index a91353d58..755fb49a5 100644 --- a/src/ui/publisher/components/sidebar.test.ts +++ b/src/ui/publisher/components/sidebar.test.ts @@ -37,13 +37,16 @@ describe('renderSidebar', () => { it('renders the non-admin nav in grouped order', () => { renderSidebar(host, router) - // Admin-only items (Feeds/Events/Blog/Node profile/Team) are hidden. + // Events and Blog are visible to every publisher (read-all / write-own); + // admin-only items (Feeds/Node profile/Team) stay hidden. expect(linkLabels(host)).toEqual([ 'Overview', 'Datasets', 'Workflows', 'Import', + 'Events', 'Right now', + 'Blog', 'Tours', 'Analytics', 'Feedback', @@ -63,18 +66,19 @@ describe('renderSidebar', () => { renderSidebar(host, router, { isAdmin: true }) const labels = linkLabels(host) expect(labels).toContain('Feeds') - expect(labels).toContain('Events') - expect(labels).toContain('Blog') expect(labels).toContain('Node profile') expect(labels).toContain('Team') }) - it('hides admin-only links by default', () => { + it('hides admin-only links by default but shows Events + Blog to every publisher', () => { renderSidebar(host, router) const labels = linkLabels(host) expect(labels).not.toContain('Feeds') - expect(labels).not.toContain('Events') + expect(labels).not.toContain('Node profile') expect(labels).not.toContain('Team') + // Events + Blog are no longer admin-only. + expect(labels).toContain('Events') + expect(labels).toContain('Blog') }) it('hides links whose feature toggle is off', () => { @@ -111,12 +115,13 @@ describe('renderSidebar', () => { expect(linkLabels(host)).toContain('Tours') }) - it('renders the events badge only when the count is positive and admin', () => { - renderSidebar(host, router, { isAdmin: true, eventsBadge: 8 }) + it('renders the events badge only when the count is positive (visible to every publisher)', () => { + // Events is no longer admin-only, so the badge shows for any publisher. + renderSidebar(host, router, { eventsBadge: 8 }) const badge = host.querySelector('.publisher-nav-badge') expect(badge?.textContent).toBe('8') - renderSidebar(host, router, { isAdmin: true, eventsBadge: 0 }) + renderSidebar(host, router, { eventsBadge: 0 }) expect(host.querySelector('.publisher-nav-badge')).toBeNull() }) diff --git a/src/ui/publisher/components/sidebar.ts b/src/ui/publisher/components/sidebar.ts index 5adb02b3f..e4fefc171 100644 --- a/src/ui/publisher/components/sidebar.ts +++ b/src/ui/publisher/components/sidebar.ts @@ -81,9 +81,9 @@ const NAV_GROUPS: ReadonlyArray = [ labelKey: 'publisher.nav.group.newsroom', items: [ { path: '/publish/feeds', labelKey: 'publisher.nav.feeds', adminOnly: true, feature: 'events' }, - { path: '/publish/events', labelKey: 'publisher.nav.events', adminOnly: true, feature: 'events', badge: 'events' }, + { path: '/publish/events', labelKey: 'publisher.nav.events', feature: 'events', badge: 'events' }, { path: '/publish/featured-hero', labelKey: 'publisher.nav.featuredHero', feature: 'hero' }, - { path: '/publish/blog', labelKey: 'publisher.nav.blog', adminOnly: true, feature: 'blog' }, + { path: '/publish/blog', labelKey: 'publisher.nav.blog', feature: 'blog' }, { path: '/publish/tours', labelKey: 'publisher.nav.tours', feature: 'tours' }, ], }, diff --git a/src/ui/publisher/pages/blog-edit.test.ts b/src/ui/publisher/pages/blog-edit.test.ts index d0cbf22bc..f309beb99 100644 --- a/src/ui/publisher/pages/blog-edit.test.ts +++ b/src/ui/publisher/pages/blog-edit.test.ts @@ -524,15 +524,37 @@ describe('renderBlogEditPage', () => { expect(notes.toLowerCase()).toContain('add a location') }) - it('shows the restricted card for a non-privileged caller', async () => { + it('lets any active publisher open the New-post editor (create is open)', async () => { const mount = document.createElement('div') const fetchFn = vi.fn(async (input: RequestInfo | URL) => { const url = typeof input === 'string' ? input : (input as Request).url - const body = url.includes('/publish/me') ? { role: 'publisher', is_admin: false } : {} + // No postId → only feature/catalog/events reads; return empty lists. + const body = url.includes('/publish/datasets') || url.includes('/publish/events') ? { datasets: [], events: [], posts: [] } : {} return { ok: true, status: 200, type: 'basic', json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response }) await renderBlogEditPage(mount, { fetchFn }) - expect(mount.querySelector('.publisher-blog-restricted')).toBeTruthy() + // The form renders — no restricted wall. + expect(mount.querySelector('#blog-title')).toBeTruthy() + expect(mount.querySelector('.publisher-blog-restricted')).toBeNull() + }) + + it('renders a read-only view (no form) for a post the caller does not own', async () => { + const mount = document.createElement('div') + const post = { + id: 'P9', slug: 'someone-elses', title: 'Not mine', summary: null, + bodyMd: '# Hello\n\nBody text.', datasetIds: [], eventId: null, status: 'draft', + publishedAt: null, tourId: null, coverImageUrl: null, coverImageAlt: null, + can_edit: false, + } + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : (input as Request).url + const body = url.includes('/publish/blog/P9') ? { post } : { datasets: [], events: [] } + return { ok: true, status: 200, type: 'basic', json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response + }) + await renderBlogEditPage(mount, { fetchFn, postId: 'P9' }) + // View-only: the post title + notice render, but no editing form. expect(mount.querySelector('#blog-title')).toBeNull() + expect(mount.querySelector('.publisher-blog-readonly-notice')).toBeTruthy() + expect(mount.querySelector('.publisher-blog-preview')?.textContent).toContain('Body text.') }) }) diff --git a/src/ui/publisher/pages/blog-edit.ts b/src/ui/publisher/pages/blog-edit.ts index 32f6c148a..403d36f94 100644 --- a/src/ui/publisher/pages/blog-edit.ts +++ b/src/ui/publisher/pages/blog-edit.ts @@ -46,11 +46,6 @@ import { resolveRegion } from '../../../data/regions' import { renderMarkdown } from '../../../services/markdownRenderer' import type { PublisherDataset } from '../types' -interface MeResponse { - role: string - is_admin: boolean -} - interface PostWire { id: string slug: string @@ -64,9 +59,11 @@ interface PostWire { tourId: string | null coverImageUrl: string | null coverImageAlt: string | null + /** Whether the caller may edit this post (author or admin). Absent + * on create / older payloads → treated as editable. */ + can_edit?: boolean } -const ME_ENDPOINT = '/api/v1/publish/me' const BLOG_ENDPOINT = '/api/v1/publish/blog' // Approved only: the public post drops a citation whose event isn't // approved, and generation grounds itself in the event's text — the @@ -84,10 +81,6 @@ export interface BlogEditPageOptions { postId?: string } -function clientIsPrivileged(me: MeResponse): boolean { - return me.is_admin === true || me.role === 'admin' || me.role === 'service' -} - function el( tag: K, props: Partial & { className?: string } = {}, @@ -174,6 +167,59 @@ function withResolvedGeometry(ev: ReviewEvent): ReviewEvent { return { ...ev, geometry: { ...g, boundingBox: { n, s, w, e } } } } +/** View-only rendering of a post the caller doesn't own — the + * read half of read-all / write-own. Title + status + a sanitized + * markdown render of the body, plus the public link when published. + * No form, no Save. */ +function renderReadOnlyPost( + mount: HTMLElement, + post: PostWire, + navigate: (url: string) => void, +): void { + const back = el('button', { + type: 'button', + className: 'publisher-button publisher-blog-back', + textContent: t('publisher.blog.editor.back'), + }) + back.addEventListener('click', () => navigate('/publish/blog')) + + const head = el('div', { className: 'publisher-blog-header' }, [ + el('div', { className: 'publisher-page-titles' }, [ + el('h2', { className: 'publisher-card-heading', textContent: post.title }), + el('span', { + className: `publisher-blog-badge publisher-blog-badge-${post.status}`, + textContent: + post.status === 'published' + ? t('publisher.blog.status.published') + : t('publisher.blog.status.draft'), + }), + ]), + ]) + + const notice = el('p', { + className: 'publisher-blog-readonly-notice', + textContent: t('publisher.blog.readonly.notice'), + }) + + const bodyView = el('div', { className: 'publisher-blog-preview publisher-markdown-body' }) + // renderMarkdown runs marked + sanitizeMarkdownHtml (XSS-tested). + bodyView.innerHTML = renderMarkdown(post.bodyMd) + + const children: HTMLElement[] = [back, head, notice] + if (post.status === 'published') { + const view = el('a', { + className: 'publisher-row-action publisher-blog-view-link', + href: `/blog/${encodeURIComponent(post.slug)}`, + textContent: t('publisher.blog.list.view'), + }) + view.target = '_blank' + view.rel = 'noopener' + children.push(view) + } + children.push(bodyView) + mount.replaceChildren(shell(card(...children))) +} + export async function renderBlogEditPage(mount: HTMLElement, options: BlogEditPageOptions = {}): Promise { const features = await fetchFeatures() if (!features.blog) { @@ -184,38 +230,29 @@ export async function renderBlogEditPage(mount: HTMLElement, options: BlogEditPa const navigate = options.navigate ?? ((url: string) => { window.location.href = url }) mount.replaceChildren(shell(el('p', { className: 'publisher-loading', textContent: t('publisher.blog.loading') }))) - const [meRes, postRes] = await Promise.all([ - publisherGet(ME_ENDPOINT, { fetchFn }), - options.postId - ? publisherGet<{ post: PostWire }>(`${BLOG_ENDPOINT}/${encodeURIComponent(options.postId)}`, { fetchFn }) - : Promise.resolve(null), - ]) - for (const res of [meRes, postRes]) { - if (!res || res.ok) continue - if (res.kind === 'session') { + const postRes = options.postId + ? await publisherGet<{ post: PostWire }>(`${BLOG_ENDPOINT}/${encodeURIComponent(options.postId)}`, { fetchFn }) + : null + if (postRes && !postRes.ok) { + if (postRes.kind === 'session') { if (handleSessionError({ navigate: options.navigate }) === 'navigating') return mount.replaceChildren(shell(buildErrorCard('session'))) return } - const details = res.kind === 'server' ? { status: res.status, body: res.body } : {} - mount.replaceChildren(shell(buildErrorCard(res.kind, details))) - return - } - if (!meRes.ok || (postRes && !postRes.ok)) return - - if (!clientIsPrivileged(meRes.data)) { - mount.replaceChildren( - shell( - card( - el('h2', { className: 'publisher-card-heading', textContent: t('publisher.blog.editor.title') }), - el('p', { className: 'publisher-blog-restricted', textContent: t('publisher.blog.restricted') }), - ), - ), - ) + const details = postRes.kind === 'server' ? { status: postRes.status, body: postRes.body } : {} + mount.replaceChildren(shell(buildErrorCard(postRes.kind, details))) return } const existing = postRes?.ok ? postRes.data.post : null + + // Read-all / write-own: any active publisher may open the editor to + // create a draft, but a post authored by someone else is view-only. + // `can_edit` absent (create / older payload) is treated as editable. + if (existing && existing.can_edit === false) { + renderReadOnlyPost(mount, existing, navigate) + return + } let postId = existing?.id ?? null let postStatus: 'draft' | 'published' = existing?.status ?? 'draft' // The AI-generated companion tour (tours-row id). Set by a diff --git a/src/ui/publisher/pages/blog.ts b/src/ui/publisher/pages/blog.ts index 64720b2b8..31f599eba 100644 --- a/src/ui/publisher/pages/blog.ts +++ b/src/ui/publisher/pages/blog.ts @@ -3,10 +3,10 @@ * `docs/CURRENT_EVENTS_PLAN.md` §7). * * Lists every post (drafts included, newest-updated first) with a - * status badge, linking each row into the editor - * (`/publish/blog/:id/edit`) plus a "New post" button - * (`/publish/blog/new`). Privileged-gated client-side — the API - * enforces 403 on writes independently. Mirrors `tours.ts`. + * status badge. Readable by any active publisher; each row's authoring + * controls are gated on `can_edit` (the post's author, or an admin) — + * others get a read-only View link. Writes are enforced server-side + * regardless. Mirrors `tours.ts`. */ import { fetchFeatures, renderFeatureDisabledCard } from '../features' @@ -14,11 +14,6 @@ import { t } from '../../../i18n' import { publisherGet, handleSessionError } from '../api' import { buildErrorCard } from '../components/error-card' -interface MeResponse { - role: string - is_admin: boolean -} - export interface BlogPostListItem { id: string slug: string @@ -26,13 +21,16 @@ export interface BlogPostListItem { status: 'draft' | 'published' updatedAt: string publishedAt: string | null + /** Whether the caller may edit/publish this post (author or admin). + * Absent (older payload / fixture) is treated as editable; the + * server is the authoritative gate. */ + can_edit?: boolean } interface BlogListResponse { posts: BlogPostListItem[] } -const ME_ENDPOINT = '/api/v1/publish/me' const BLOG_ENDPOINT = '/api/v1/publish/blog' export interface BlogPageOptions { @@ -40,10 +38,6 @@ export interface BlogPageOptions { navigate?: (url: string) => void } -function clientIsPrivileged(me: MeResponse): boolean { - return me.is_admin === true || me.role === 'admin' || me.role === 'service' -} - function el( tag: K, props: Partial & { className?: string } = {}, @@ -76,32 +70,15 @@ export async function renderBlogPage(mount: HTMLElement, options: BlogPageOption const navigate = options.navigate ?? ((url: string) => { window.location.href = url }) mount.replaceChildren(shell(el('p', { className: 'publisher-loading', textContent: t('publisher.blog.loading') }))) - const [meRes, listRes] = await Promise.all([ - publisherGet(ME_ENDPOINT, { fetchFn }), - publisherGet(BLOG_ENDPOINT, { fetchFn }), - ]) - for (const res of [meRes, listRes]) { - if (res.ok) continue - if (res.kind === 'session') { + const listRes = await publisherGet(BLOG_ENDPOINT, { fetchFn }) + if (!listRes.ok) { + if (listRes.kind === 'session') { if (handleSessionError({ navigate: options.navigate }) === 'navigating') return mount.replaceChildren(shell(buildErrorCard('session'))) return } - const details = res.kind === 'server' ? { status: res.status, body: res.body } : {} - mount.replaceChildren(shell(buildErrorCard(res.kind, details))) - return - } - if (!meRes.ok || !listRes.ok) return - - if (!clientIsPrivileged(meRes.data)) { - mount.replaceChildren( - shell( - card( - el('h2', { className: 'publisher-card-heading', textContent: t('publisher.blog.title') }), - el('p', { className: 'publisher-blog-restricted', textContent: t('publisher.blog.restricted') }), - ), - ), - ) + const details = listRes.kind === 'server' ? { status: listRes.status, body: listRes.body } : {} + mount.replaceChildren(shell(buildErrorCard(listRes.kind, details))) return } @@ -162,21 +139,24 @@ export async function renderBlogPage(mount: HTMLElement, options: BlogPageOption titleCell.append(titleStack) tr.append(titleCell) tr.append(el('td', { className: 'publisher-cell-updated', textContent: post.updatedAt.slice(0, 10) })) - // Actions: Edit (all rows, opens the editor) + View (published - // only — a public page to link to). Consistent action pills with - // the other list pages. + // Actions: Edit for posts the caller authored (or admin); others + // get read-only access via the title link + the public View link + // when published. Writes are enforced server-side regardless. const actionsCell = el('td', { className: 'publisher-cell-actions' }) - const edit = el('a', { - className: 'publisher-row-action publisher-row-edit', - href: `/publish/blog/${encodeURIComponent(post.id)}/edit`, - textContent: t('publisher.blog.list.edit'), - }) - edit.addEventListener('click', e => { - if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return - e.preventDefault() - navigate(`/publish/blog/${encodeURIComponent(post.id)}/edit`) - }) - actionsCell.append(edit) + const canEdit = post.can_edit !== false + if (canEdit) { + const edit = el('a', { + className: 'publisher-row-action publisher-row-edit', + href: `/publish/blog/${encodeURIComponent(post.id)}/edit`, + textContent: t('publisher.blog.list.edit'), + }) + edit.addEventListener('click', e => { + if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return + e.preventDefault() + navigate(`/publish/blog/${encodeURIComponent(post.id)}/edit`) + }) + actionsCell.append(edit) + } if (post.status === 'published') { const view = el('a', { className: 'publisher-row-action publisher-blog-view-link', diff --git a/src/ui/publisher/pages/events.test.ts b/src/ui/publisher/pages/events.test.ts index adda2a6dc..b159a4602 100644 --- a/src/ui/publisher/pages/events.test.ts +++ b/src/ui/publisher/pages/events.test.ts @@ -58,16 +58,36 @@ beforeEach(() => { }) describe('renderEventsPage', () => { - it('shows the restricted card for a non-privileged publisher without hitting the events API', async () => { + it('a non-admin publisher reads the queue but gets no feed-Refresh button', async () => { const routes = baseRoutes() routes['/api/v1/publish/me'] = { body: { role: 'publisher', is_admin: false } } const fetchFn = mockFetch(routes) await renderEventsPage(mount, { fetchFn }) - expect(mount.querySelector('.publisher-events-restricted')).not.toBeNull() - expect(mount.querySelector('.publisher-events-card')).toBeNull() - // Gate happens before the events fetch — a 403 there must not surface - // as a generic error card. - expect(fetchFn.mock.calls.some(c => String(c[0]).includes('/publish/events'))).toBe(false) + // No restricted wall — the queue renders. + expect(mount.querySelector('.publisher-events-restricted')).toBeNull() + expect(mount.querySelector('.publisher-events-queue-title')?.textContent).toBe('Hurricane makes landfall') + // The events queue IS fetched (read is open). + expect(fetchFn.mock.calls.some(c => String(c[0]).includes('/publish/events'))).toBe(true) + // The feed-Refresh action is admin-only; only New event shows. + const toolbarButtons = mount.querySelectorAll('.publisher-events-toolbar button') + expect(toolbarButtons).toHaveLength(1) + expect(toolbarButtons[0].textContent).toBe('New event') + }) + + it('a non-admin publisher sees an owned event read-only when can_edit is false', async () => { + const routes = baseRoutes() + routes['/api/v1/publish/me'] = { body: { role: 'publisher', is_admin: false } } + const ev = oneEvent() + ;(ev.events[0] as { can_edit?: boolean }).can_edit = false + routes['/api/v1/publish/events'] = { body: ev } + await renderEventsPage(mount, { fetchFn: mockFetch(routes) }) + // The detail renders, but the approve/reject decision controls do not. + expect(mount.querySelector('.publisher-events-detail-title')?.textContent).toBe('Hurricane makes landfall') + expect(mount.querySelector('.publisher-events-decision-approve')).toBeNull() + expect(mount.querySelector('.publisher-events-readonly-notice')).not.toBeNull() + // The pairing row shows, but without its ✓ / ✕ decision buttons. + expect(mount.querySelector('.publisher-events-pairing-name')?.textContent).toBe('Live Storm') + expect(mount.querySelector('.publisher-events-icon-btn')).toBeNull() }) it('renders the selected event detail with source, title, and a pairing row', async () => { diff --git a/src/ui/publisher/pages/events.ts b/src/ui/publisher/pages/events.ts index 5d0abd6f6..cc9d26ea4 100644 --- a/src/ui/publisher/pages/events.ts +++ b/src/ui/publisher/pages/events.ts @@ -59,8 +59,10 @@ interface LocatorHolder { } /** Internal page state: the public options plus the locator holder that - * survives across re-renders within one page session. */ -type TriageState = EventsPageOptions & { locator: LocatorHolder } + * survives across re-renders within one page session, and whether the + * caller is an admin (gates the feed-Refresh action; the review + * controls themselves gate per-event on `can_edit`). */ +type TriageState = EventsPageOptions & { locator: LocatorHolder; isAdmin: boolean } function clientIsPrivileged(me: MeResponse): boolean { return me.is_admin === true || me.role === 'admin' || me.role === 'service' @@ -100,17 +102,15 @@ export async function renderEventsPage(mount: HTMLElement, options: EventsPageOp mount.replaceChildren(shell(buildErrorCard(meRes.kind, details))) return } - if (!clientIsPrivileged(meRes.data)) { - mount.replaceChildren( - shell(card( - el('h2', 'publisher-card-heading', [t('publisher.events.title')]), - el('p', 'publisher-events-restricted', [t('publisher.events.restricted')]), - )), - ) - return - } - - await loadAndRenderQueue(mount, { fetchFn, navigate: options.navigate, locator: { dispose: null } }) + // The whole events queue is readable by any active publisher. Writes + // (approve / reject / edit / tour) gate per-event on `can_edit`; the + // feed-Refresh action stays admin-only (it drives the feed connectors). + await loadAndRenderQueue(mount, { + fetchFn, + navigate: options.navigate, + locator: { dispose: null }, + isAdmin: clientIsPrivileged(meRes.data), + }) } /** Fetch the queue at `status` and render the triage view. `selectId` @@ -282,10 +282,15 @@ function renderTopbar( filters.append(btn) } + // Feed Refresh is admin-only (it runs the feed connectors); every + // publisher can still compose a manual event via New event. + const toolbar = state.isAdmin + ? el('div', 'publisher-events-toolbar', [refreshBtn, newBtn]) + : el('div', 'publisher-events-toolbar', [newBtn]) const bar = el('div', 'publisher-events-topbar', [ el('div', 'publisher-events-topbar-head', [ el('h2', 'publisher-card-heading', [t('publisher.events.title')]), - el('div', 'publisher-events-toolbar', [refreshBtn, newBtn]), + toolbar, ]), el('p', 'publisher-events-intro', [t('publisher.events.subtitle')]), filters, From bb5dc51e2ef4ba35d5dc2458c4d22e9590ce28f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 15:13:11 +0000 Subject: [PATCH 06/25] docs: add publisher roles & capabilities scoping plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_013o4qNmREEsEsZ3mjUdHa1p Signed-off-by: Claude --- docs/PUBLISHER_ROLES_PLAN.md | 373 +++++++++++++++++++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 docs/PUBLISHER_ROLES_PLAN.md diff --git a/docs/PUBLISHER_ROLES_PLAN.md b/docs/PUBLISHER_ROLES_PLAN.md new file mode 100644 index 000000000..8aa49a0ca --- /dev/null +++ b/docs/PUBLISHER_ROLES_PLAN.md @@ -0,0 +1,373 @@ +# Publisher roles & capabilities — scoping + +**Status:** draft for review +**Last reviewed:** 2026-07-15 +**Owner:** catalog / publisher-portal track +**Supersedes when:** Phases R1–R5 below ship and the role/capability +matrix is folded into `CATALOG_PUBLISHING_TOOLS.md` + the +`publisher-store.ts` taxonomy comment. + +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 unchanged (untrusted-domain logins → + `contributor`/`pending`? — **Decision D4**, §8; today it's + `publisher`/`pending`). + +### 7.4 Portal UI + +- **Users tab** (`pages/users.ts`) — the role `