From 9d288b2ad07d3cf3f20da0b0eef7815e2c4f6838 Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Thu, 9 Jul 2026 18:48:42 +0200 Subject: [PATCH] Wire real permissions into collab tokens and revocation (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live editing now obeys the same rules as REST: the collab-token mode comes from the shared grant resolution, anonymous visitors can join public pages, and revoking write access flips a running session to read-only within seconds. - Anonymous public tokens: `GET /pages/:id/collab-token` is `@Public()` but still permission-guarded, so a logged-out visitor gets an `ro` token where a `public` grant makes the page readable (404 otherwise). The token's `userId` is nullable (shared schema + collab context) for anonymous subjects. - Prompt revocation: the pond-level NOTIFY (#39) now also fires on label tree/assignment changes (LabelsService move/remove/assign/unassign), and the collab server closes the *actual* WebSocket instead of only sending an application-level close message. Hocuspocus' `closeConnections` leaves the socket open so the client only re-checks on its ~30s message timeout; `closeDocumentConnections` drops the socket so the client reconnects and re-authenticates with a freshly-resolved token at once — the "within seconds" downgrade the milestone promises. - Tests: the #52 fixture matrix gains anonymous cases (public grant → `ro`, none → 404); a collab db test proves an editor downgraded to reader goes read-only on reconnect (its post-downgrade edits no longer reach a peer); a new browser `collab-permissions` pack covers the read-only participant and the live downgrade end to end (new plain `fixture-editor` account). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- .gitea/workflows/ci.yml | 12 ++ apps/api/prisma/seed.ts | 8 ++ apps/api/src/labels/labels.service.ts | 15 +- .../api/src/pages/collab-token.e2e.db.test.ts | 7 +- apps/api/src/pages/pages.controller.ts | 14 +- apps/api/src/pages/pages.service.ts | 11 +- .../permissions/permissions.e2e.db.test.ts | 19 +++ apps/collab/src/access-listener.db.test.ts | 58 +++++++- apps/collab/src/index.ts | 4 +- apps/collab/src/server.ts | 26 +++- apps/web/e2e/README.md | 11 +- apps/web/e2e/collab-permissions.spec.ts | 130 ++++++++++++++++++ apps/web/e2e/collab.spec.ts | 9 +- packages/shared/src/collab-token.ts | 4 +- 14 files changed, 298 insertions(+), 30 deletions(-) create mode 100644 apps/web/e2e/collab-permissions.spec.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e88a1ac..1c7395f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -154,6 +154,18 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/collab.spec.ts + # Two contexts per test (owner + a second regular account) and grant + # changes → reset the login rate limit first (see note above). + - name: Reset login rate limit before collab-permissions pack + run: | + echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ + pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" + + - name: Run collab-permissions pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/collab-permissions.spec.ts + - name: Reset login rate limit before offline pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index fff0961..ecb1d1e 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -5,6 +5,8 @@ * Fixture matrix (documented in apps/web/e2e/README.md): * fixture-admin active, Site Admin * fixture-user active, regular account + * fixture-editor active, regular account (a second non-admin for the + * collab permission packs: reader/editor of another's pond) * fixture-pending registered but e-mail not verified * * All fixture accounts share the password below — they exist only on @@ -58,6 +60,12 @@ interface FixtureUser { const FIXTURES: FixtureUser[] = [ { username: 'fixture-admin', displayName: 'Fixture Admin', status: 'ACTIVE', isSiteAdmin: true }, { username: 'fixture-user', displayName: 'Fixture User', status: 'ACTIVE', isSiteAdmin: false }, + { + username: 'fixture-editor', + displayName: 'Fixture Editor', + status: 'ACTIVE', + isSiteAdmin: false, + }, { username: 'fixture-pending', displayName: 'Fixture Pending', diff --git a/apps/api/src/labels/labels.service.ts b/apps/api/src/labels/labels.service.ts index 322cd89..203fac7 100644 --- a/apps/api/src/labels/labels.service.ts +++ b/apps/api/src/labels/labels.service.ts @@ -21,6 +21,7 @@ import { Label, Pond, Prisma, User } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import { PondPermissionCache } from '../permissions/pond-permission-cache'; +import { PondAccessNotifier } from '../ponds/pond-access-notifier.service'; import { PrismaService } from '../prisma/prisma.service'; import { SearchProvider } from '../search/search.provider'; @@ -50,6 +51,7 @@ export class LabelsService { private readonly permissionCache: PondPermissionCache, private readonly logger: PinoLogger, private readonly search: SearchProvider, + private readonly accessNotifier: PondAccessNotifier, ) { this.logger.setContext(LabelsService.name); } @@ -215,6 +217,8 @@ export class LabelsService { // Label grants cover descendants — a moved subtree changes their reach. this.permissionCache.invalidate(pondId); + // Inherited access changed → revalidate live collab sessions (#53, #39). + await this.accessNotifier.notifyAccessChanged(pondId); this.logger.info({ labelId, pondId, parentId, userId: user.id }, 'audit: label moved'); return this.viewOf(label); } @@ -250,6 +254,9 @@ export class LabelsService { }); this.permissionCache.invalidate(pondId); + // A deleted label (and any grants on it) no longer reaches its former pages + // → revalidate live collab sessions (#53, #39). + await this.accessNotifier.notifyAccessChanged(pondId); // Those pages lost a label → their search entries change (#49). for (const pageId of affectedPageIds) await this.search.indexPage(pageId); this.logger.info({ labelId, pondId, userId: user.id, force }, 'audit: label deleted'); @@ -287,14 +294,20 @@ export class LabelsService { create: { pageId, labelId }, update: {}, }); + this.permissionCache.invalidate(page.pondId); + // The page now inherits any grants on this label → revalidate live sessions. + await this.accessNotifier.notifyAccessChanged(page.pondId); await this.search.indexPage(pageId); // labels are a search field (#49) return this.pageLabels(user, pageId); } /** Remove a label from a page (idempotent). */ async unassign(_user: User, pageId: string, labelId: string): Promise { - await this.requireLivePage(pageId); + const page = await this.requireLivePage(pageId); await this.prisma.pageLabel.deleteMany({ where: { pageId, labelId } }); + this.permissionCache.invalidate(page.pondId); + // The page no longer inherits grants on this label → revalidate live sessions. + await this.accessNotifier.notifyAccessChanged(page.pondId); await this.search.indexPage(pageId); // labels are a search field (#49) } } diff --git a/apps/api/src/pages/collab-token.e2e.db.test.ts b/apps/api/src/pages/collab-token.e2e.db.test.ts index c6c190c..1612955 100644 --- a/apps/api/src/pages/collab-token.e2e.db.test.ts +++ b/apps/api/src/pages/collab-token.e2e.db.test.ts @@ -88,8 +88,11 @@ describe.skipIf(!hasTestDb)('collab token (e2e, issue #34)', () => { await app.close(); }); - it('requires authentication', async () => { - await api().get(`/api/v1/pages/${pageId}/collab-token`).expect(401); + it('hides a private page from an anonymous visitor (404, not 401 — issue #53)', async () => { + // The endpoint is public so anonymous visitors can reach public pages, but + // without a `public` grant this page is unreadable and its existence stays + // hidden — the same 404 an authenticated non-member gets. + await api().get(`/api/v1/pages/${pageId}/collab-token`).expect(404); }); it('issues a valid rw token to a member and encodes the right claims', async () => { diff --git a/apps/api/src/pages/pages.controller.ts b/apps/api/src/pages/pages.controller.ts index cea0270..4c66fc4 100644 --- a/apps/api/src/pages/pages.controller.ts +++ b/apps/api/src/pages/pages.controller.ts @@ -26,7 +26,7 @@ import { } from '@dorfteich/shared'; import type { Response } from 'express'; -import { AuthedRequest } from '../auth/auth.guard'; +import { AuthedRequest, Public } from '../auth/auth.guard'; import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { AuthenticatedOnly, @@ -65,14 +65,20 @@ export class PagesController { return this.pages.getState(request.user!, id); } - /** Short-lived collaboration token for the collab server (issue #34). */ + /** + * Short-lived collaboration token for the collab server (issue #34). + * `@Public()` so an anonymous visitor to a public page can obtain a token + * (issue #53); the permission guard still enforces read access (404 when no + * grant makes the page readable) and downgrades non-writers to `ro`. + */ @Get('pages/:id/collab-token') - @RequiresPagePermission('read', { idParam: 'id' }) // readers get an `ro` token + @Public() + @RequiresPagePermission('read', { idParam: 'id' }) // readers (incl. public) get an `ro` token async collabToken( @Param('id') id: string, @Req() request: AuthedRequest, ): Promise { - return this.pages.issueCollabToken(request.user!, id); + return this.pages.issueCollabToken(request.user ?? null, id); } /** Markdown export (issue #30) — downloads `.md`. */ diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 3548e36..7e8d540 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -186,19 +186,24 @@ export class PagesService { * runs in the api — the guard requires read access, and the collab server * never sees session cookies (ADR 0003). `mode` is `rw` for who may write * the page per the real grant resolution (issue #52) and `ro` otherwise. + * + * `user` is `null` for an anonymous visitor on a public page (issue #53): the + * guard has already granted read access via a `public` grant, so they receive + * an `ro` token with a `null` subject. */ - async issueCollabToken(user: User, id: string): Promise { + async issueCollabToken(user: User | null, id: string): Promise { const page = await this.findLivePage(id); const canWrite = await this.permissions.canAccessPage(user, page, 'write'); const mode = canWrite ? 'rw' : 'ro'; + const userId = user?.id ?? null; const token = signCollabToken( - { userId: user.id, pageId: page.id, mode }, + { userId, pageId: page.id, mode }, this.config.env.COLLAB_TOKEN_SECRET, COLLAB_TOKEN_TTL_SECONDS, ); // Debug level, and deliberately without the token value (issue #34). - this.logger.debug({ pageId: page.id, userId: user.id, mode }, 'issued collab token'); + this.logger.debug({ pageId: page.id, userId, mode }, 'issued collab token'); return { token, mode, expiresInSeconds: COLLAB_TOKEN_TTL_SECONDS }; } diff --git a/apps/api/src/permissions/permissions.e2e.db.test.ts b/apps/api/src/permissions/permissions.e2e.db.test.ts index 5c83b18..08ebe2e 100644 --- a/apps/api/src/permissions/permissions.e2e.db.test.ts +++ b/apps/api/src/permissions/permissions.e2e.db.test.ts @@ -179,6 +179,25 @@ describe.skipIf(!hasTestDb)('permission enforcement (e2e, issue #52)', () => { expect((rw.body as { mode: string }).mode).toBe('rw'); }); + it('an anonymous visitor gets a ro token only where a public grant exists (issue #53)', async () => { + // No public grant yet → an anonymous request (no cookie) is a 404, hiding + // the page's existence just like any unauthorized read. + await api().get(`/api/v1/pages/${pageId}/collab-token`).expect(404); + + // A public reader grant opens the page to everyone, including logged-out + // visitors, who then receive a read-only token with a null subject. + const publicGrant = await createGrant(grantInput({ subjectType: 'public', role: 'reader' })); + const ro = await api().get(`/api/v1/pages/${pageId}/collab-token`).expect(200); + expect((ro.body as { mode: string }).mode).toBe('ro'); + + // Revoke it again so the rest of the matrix keeps its private baseline. + await api() + .delete(`/api/v1/ponds/${pondId}/grants/${publicGrant}`) + .set('Cookie', cookies.owner!) + .expect(204); + await api().get(`/api/v1/pages/${pageId}/collab-token`).expect(404); + }); + it('editor edits pages but cannot manage members or labels', async () => { await api() .patch(`/api/v1/pages/${pageId}`) diff --git a/apps/collab/src/access-listener.db.test.ts b/apps/collab/src/access-listener.db.test.ts index f6e5021..b32aeb4 100644 --- a/apps/collab/src/access-listener.db.test.ts +++ b/apps/collab/src/access-listener.db.test.ts @@ -9,7 +9,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import * as Y from 'yjs'; import { createAccessListener } from './access-listener.js'; -import { createCollabServer } from './server.js'; +import { closeDocumentConnections, createCollabServer } from './server.js'; import { freePort } from './testing/free-port.js'; import { InMemoryPagePersistence } from './testing/fake-persistence.js'; import { collabTestDatabaseUrlOrUndefined } from './testing/test-db.js'; @@ -84,7 +84,7 @@ describe.skipIf(!url)('access listener (DB-backed)', () => { createClient: () => new Client({ connectionString: url }), pool, openDocumentNames: () => [...server.hocuspocus.documents.keys()], - closeConnections: (name) => server.hocuspocus.closeConnections(name), + closeConnections: (name) => closeDocumentConnections(server.hocuspocus, name), logger, reconnectDelayMs: 100, }); @@ -103,7 +103,17 @@ describe.skipIf(!url)('access listener (DB-backed)', () => { await pool.end(); }); - function connect(name: string): { + function connect( + name: string, + // A string reuses one token; a function is re-invoked on every (re)connect, + // exactly as the web client re-fetches from the api — which is how a + // downgrade to `ro` takes effect on reconnect (issue #53). + token: string | (() => string) = signCollabToken( + { userId, pageId: name, mode: 'rw' }, + secret, + 60, + ), + ): { provider: HocuspocusProvider; doc: Y.Doc; synced: () => boolean; @@ -117,12 +127,13 @@ describe.skipIf(!url)('access listener (DB-backed)', () => { url: wsUrl, name, document: doc, - token: signCollabToken({ userId, pageId: name, mode: 'rw' }, secret, 60), + token, onSynced: () => { synced = true; }, onClose: () => { closeCount += 1; + synced = false; // becomes true again once the reconnect re-syncs }, }); return { @@ -151,6 +162,45 @@ describe.skipIf(!url)('access listener (DB-backed)', () => { client.destroy(); }); + it('downgrades a live editor to read-only on reconnect within seconds (issue #53)', async () => { + // An always-writable observer and a participant whose access is revoked + // mid-session. The participant's token function starts `rw`, then flips to + // `ro` — mirroring the api re-resolving the grant after the change. + const observer = connect(pageId); + let mode: 'rw' | 'ro' = 'rw'; + const participant = connect(pageId, () => + signCollabToken({ userId, pageId, mode }, secret, 60), + ); + await waitFor(observer.synced); + await waitFor(participant.synced); + + // While still an editor, the participant's writes reach the observer. + participant.doc.getText('t').insert(0, 'before '); + await waitFor(() => observer.doc.getText('t').toString().includes('before')); + + // Access is revoked: the participant's next token resolves to `ro`. The + // pond-level NOTIFY closes every live connection to the page — both the + // participant and the observer — so each reconnects within seconds; the + // participant re-authenticates read-only, the observer stays read-write. + const closesBefore = participant.closeCount(); + mode = 'ro'; + await pool.query(`SELECT pg_notify('${POND_ACCESS_CHANGED_CHANNEL}', $1)`, [pondId]); + await waitFor(() => participant.closeCount() > closesBefore, 10000); + await waitFor(observer.synced, 15000); // both reconnected and re-synced + await waitFor(participant.synced, 15000); + + // Read-only now: the server rejects the participant's updates, so a post + // downgrade edit never reaches the observer… + participant.doc.getText('t').insert(0, 'FORBIDDEN '); + // …while the observer's edits still stream down to the (readable) participant. + observer.doc.getText('t').insert(0, 'OWNER '); + await waitFor(() => participant.doc.getText('t').toString().includes('OWNER'), 15000); + expect(observer.doc.getText('t').toString()).not.toContain('FORBIDDEN'); + + observer.destroy(); + participant.destroy(); + }, 30000); + it('leaves sessions of unrelated ponds untouched', async () => { const client = connect(pageId); await waitFor(client.synced); diff --git a/apps/collab/src/index.ts b/apps/collab/src/index.ts index 87cf436..866ac6d 100644 --- a/apps/collab/src/index.ts +++ b/apps/collab/src/index.ts @@ -6,7 +6,7 @@ import { createPool, pingDatabase } from './db.js'; import { createLogger } from './logger.js'; import { PostgresPagePersistence } from './persistence.js'; import { createRestoreListener } from './restore-listener.js'; -import { createCollabServer } from './server.js'; +import { closeDocumentConnections, createCollabServer } from './server.js'; import { PostgresSessionRegistry } from './session-registry.js'; import { PostgresVersionStore } from './version-store.js'; @@ -44,7 +44,7 @@ async function bootstrap(): Promise { createClient: () => new Client({ connectionString: env.DATABASE_URL }), pool, openDocumentNames: () => [...server.hocuspocus.documents.keys()], - closeConnections: (documentName) => server.hocuspocus.closeConnections(documentName), + closeConnections: (documentName) => closeDocumentConnections(server.hocuspocus, documentName), logger, }); diff --git a/apps/collab/src/server.ts b/apps/collab/src/server.ts index ae866f3..33ff49b 100644 --- a/apps/collab/src/server.ts +++ b/apps/collab/src/server.ts @@ -1,4 +1,4 @@ -import { Server } from '@hocuspocus/server'; +import { Server, type Hocuspocus } from '@hocuspocus/server'; import { MAX_PAGE_DOCUMENT_BYTES } from '@dorfteich/shared'; import { verifyCollabToken } from '@dorfteich/shared/token-crypto'; import type { Logger } from 'pino'; @@ -10,7 +10,8 @@ import type { VersionStore } from './version-store.js'; /** Per-connection context returned by onAuthenticate and used by later hooks. */ export interface CollabContext { - userId: string; + /** `null` for an anonymous visitor on a public page (issue #53). */ + userId: string | null; mode: 'rw' | 'ro'; } @@ -47,6 +48,27 @@ export interface CollabErrorMessage { limitBytes: number; } +/** Hocuspocus' "Reset Connection" WebSocket close code — the client reconnects. */ +const RESET_CONNECTION_CODE = 4205; + +/** + * Force every live session on a document to re-validate access *now* (issue + * #53). Hocuspocus' own `closeConnections` only sends an application-level + * close message: the client detaches the document but keeps the socket open and + * re-checks access lazily, after its ~30s message timeout. This closes the + * underlying WebSocket instead, so the client reconnects and re-authenticates + * with a freshly-minted token within seconds — turning a downgraded editor + * read-only, or dropping a reader whose access was revoked, right away + * (permissions.md §Performance: "revoking write access closes live sessions"). + */ +export function closeDocumentConnections(hocuspocus: Hocuspocus, documentName: string): void { + const document = hocuspocus.documents.get(documentName); + if (!document) return; + for (const connection of document.getConnections()) { + connection.webSocket.close(RESET_CONNECTION_CODE, 'Reset Connection'); + } +} + /** * The Hocuspocus collaboration server (ADR 0003). It authenticates every * connection with the api-minted token (#34) and is the writer of page state: diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md index e8230c6..5ca2464 100644 --- a/apps/web/e2e/README.md +++ b/apps/web/e2e/README.md @@ -42,11 +42,12 @@ Seeded by `pnpm --filter @dorfteich/api db:seed` (idempotent — re-running never duplicates). Shared password: `fixture passwort 123`. Fixtures exist only on dev machines and disposable CI/Test databases. -| Username | State | Purpose | -| ----------------- | ------------------- | ------------------------------------ | -| `fixture-admin` | active, Site Admin | admin UI/permissions cases | -| `fixture-user` | active | regular journeys, settings, sessions | -| `fixture-pending` | e-mail not verified | unverified-login cases | +| Username | State | Purpose | +| ----------------- | ------------------- | ---------------------------------------------------------------------------------------- | +| `fixture-admin` | active, Site Admin | admin UI/permissions cases | +| `fixture-user` | active | regular journeys, settings, sessions | +| `fixture-editor` | active | second regular account for the collab-permissions pack (reader/editor of another's pond) | +| `fixture-pending` | e-mail not verified | unverified-login cases | ## Content fixtures diff --git a/apps/web/e2e/collab-permissions.spec.ts b/apps/web/e2e/collab-permissions.spec.ts new file mode 100644 index 0000000..af2e639 --- /dev/null +++ b/apps/web/e2e/collab-permissions.spec.ts @@ -0,0 +1,130 @@ +import { expect, test } from '@playwright/test'; +import type { APIRequestContext, BrowserContext, Page } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +/** + * Live-collab permission behaviour in the real browser stack (issue #53): a + * reader holds a read-only collab token (the editor surface is not writable, + * yet live edits still stream in), and revoking an editor's write access flips + * their running session to read-only within seconds — the milestone's + * revocation promise. The precise revocation *mechanism* (a real WebSocket + * close so the client reconnects and re-authenticates at once) is unit-proven + * against a real Hocuspocus server in + * `apps/collab/src/access-listener.db.test.ts`. + * + * These need a second regular (non site-admin) account so one user can be a + * plain reader/editor of another user's pond: fixture-user owns the pond and is + * its Pond Admin, fixture-editor is the participant whose access we vary. + */ + +async function userId(api: APIRequestContext): Promise { + const me = await api.get('/api/v1/auth/me'); + return ((await me.json()) as { id: string }).id; +} + +/** Creates a fresh shared pond with one page, owned by `owner`. */ +async function sharedPondWithPage( + owner: BrowserContext, +): Promise<{ pondId: string; pondSlug: string; pageSlug: string }> { + const pondRes = await owner.request.post('/api/v1/ponds', { + data: { name: `Collab Perms ${Date.now()}` }, + }); + const pond = (await pondRes.json()) as { id: string; slug: string }; + const pageRes = await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, { + data: { title: `Shared Page ${Date.now()}` }, + }); + const page = (await pageRes.json()) as { slug: string }; + return { pondId: pond.id, pondSlug: pond.slug, pageSlug: page.slug }; +} + +/** Grants `role` on the pond to `subjectId`; returns the new grant's id. */ +async function grant( + owner: BrowserContext, + pondId: string, + subjectId: string, + role: 'reader' | 'editor', +): Promise { + const res = await owner.request.post(`/api/v1/ponds/${pondId}/grants`, { + data: { subjectType: 'user', subjectId, role, scopeType: 'pond', effect: 'allow' }, + }); + if (!res.ok()) throw new Error(`grant ${role} failed: ${res.status()} ${await res.text()}`); + return ((await res.json()) as { id: string }).id; +} + +/** Opens the page in edit mode and waits for the live connection to be up. */ +async function openConnectedEditor( + context: BrowserContext, + pondSlug: string, + slug: string, +): Promise { + const page = await context.newPage(); + await page.goto(`/p/${pondSlug}/${slug}`); + // Select by class, not button text: the fixture-editor account's display + // name ("Fixture Editor") would also match an /edit/i name filter. + await page.locator('.editor-page__mode-toggle').click(); + await expect(page.locator('.editor-connection')).toHaveAttribute('data-status', 'connected', { + timeout: 15000, + }); + return page; +} + +test('a reader participant sees live edits but cannot type (issue #53)', async ({ browser }) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const reader = await contextForUser(browser, BASE_URL, 'fixture-editor'); + const readerId = await userId(reader.request); + const { pondId, pondSlug, pageSlug } = await sharedPondWithPage(owner); + await grant(owner, pondId, readerId, 'reader'); + + const ownerPage = await openConnectedEditor(owner, pondSlug, pageSlug); + const readerPage = await openConnectedEditor(reader, pondSlug, pageSlug); + + // The reader holds an `ro` token, so the editor surface is not editable even + // in "edit" mode (mode toggle is available to everyone; writing is gated). + await expect(readerPage.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'false'); + + // But live changes from the owner still stream in — read-only, not offline. + await ownerPage.locator('.ProseMirror').click(); + await ownerPage.keyboard.type('owner writes for the reader '); + await expect(readerPage.locator('.ProseMirror')).toContainText('owner writes for the reader', { + timeout: 10000, + }); + + await owner.close(); + await reader.close(); +}); + +test('downgrading a live editor to reader flips the session to read-only (issue #53)', async ({ + browser, +}) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const editor = await contextForUser(browser, BASE_URL, 'fixture-editor'); + const editorUserId = await userId(editor.request); + const { pondId, pondSlug, pageSlug } = await sharedPondWithPage(owner); + const editorGrantId = await grant(owner, pondId, editorUserId, 'editor'); + + const editorPage = await openConnectedEditor(editor, pondSlug, pageSlug); + // As an editor they may type. + await expect(editorPage.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true'); + await editorPage.locator('.ProseMirror').click(); + await editorPage.keyboard.type('typed while still an editor '); + + // The Pond Admin downgrades them: add a reader grant first (so access never + // fully lapses), then remove the editor grant. Each grant change emits the + // pond-level NOTIFY; the collab server closes the affected socket and the + // client reconnects, re-acquiring a token that now resolves to `ro`. + await grant(owner, pondId, editorUserId, 'reader'); + const del = await owner.request.delete(`/api/v1/ponds/${pondId}/grants/${editorGrantId}`); + expect(del.status()).toBe(204); + + // Within seconds the running session becomes read-only — no full reload, no + // manual action by the downgraded user. + await expect(editorPage.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'false', { + timeout: 15000, + }); + + await owner.close(); + await editor.close(); +}); diff --git a/apps/web/e2e/collab.spec.ts b/apps/web/e2e/collab.spec.ts index 4f5d9e2..0abc1d0 100644 --- a/apps/web/e2e/collab.spec.ts +++ b/apps/web/e2e/collab.spec.ts @@ -121,9 +121,6 @@ test('remote carets and the presence strip reflect participants (#37)', async ({ await admin.close(); }); -// Read-only participants (live changes visible, typing blocked, reason shown) -// need a real read-only grant to obtain a `ro` collab token. Under the interim -// access model seeing and modifying coincide, so no user is issued a `ro` token -// yet — the `ro` UI is implemented but only becomes reachable with #53, where -// this live assertion belongs. -test.fixme('read-only participants see changes but cannot type (needs #53)', () => {}); +// Read-only participants (live changes visible, typing blocked) and the live +// read-write→read-only downgrade need real grants, so they live in the +// `collab-permissions` pack (issue #53) alongside the grant setup they require. diff --git a/packages/shared/src/collab-token.ts b/packages/shared/src/collab-token.ts index 5adfae8..13fba9b 100644 --- a/packages/shared/src/collab-token.ts +++ b/packages/shared/src/collab-token.ts @@ -42,7 +42,9 @@ export interface PageRestoreRequest { /** The application claims carried by a collaboration token. */ export const collabTokenClaimsSchema = z.object({ - userId: z.string().min(1), + // `null` for an anonymous visitor holding a public read-only token (issue + // #53); a user id for a signed-in participant (used for presence/versioning). + userId: z.string().min(1).nullable(), pageId: z.string().min(1), mode: collabTokenModeSchema, });