Wire real permissions into collab tokens and revocation (#53)
All checks were successful
CD / Build and push images (push) Successful in 3m4s
CI / Lint, typecheck, test (push) Successful in 2m27s
CI / Auth e2e pack (push) Successful in 3m6s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Opus 4.8 2026-07-09 18:48:42 +02:00
parent b1f2105a2e
commit 9d288b2ad0
14 changed files with 298 additions and 30 deletions

View File

@ -154,6 +154,18 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/collab.spec.ts 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 - name: Reset login rate limit before offline pack
run: | run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \

View File

@ -5,6 +5,8 @@
* Fixture matrix (documented in apps/web/e2e/README.md): * Fixture matrix (documented in apps/web/e2e/README.md):
* fixture-admin active, Site Admin * fixture-admin active, Site Admin
* fixture-user active, regular account * 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 * fixture-pending registered but e-mail not verified
* *
* All fixture accounts share the password below they exist only on * All fixture accounts share the password below they exist only on
@ -58,6 +60,12 @@ interface FixtureUser {
const FIXTURES: FixtureUser[] = [ const FIXTURES: FixtureUser[] = [
{ username: 'fixture-admin', displayName: 'Fixture Admin', status: 'ACTIVE', isSiteAdmin: true }, { username: 'fixture-admin', displayName: 'Fixture Admin', status: 'ACTIVE', isSiteAdmin: true },
{ username: 'fixture-user', displayName: 'Fixture User', status: 'ACTIVE', isSiteAdmin: false }, { username: 'fixture-user', displayName: 'Fixture User', status: 'ACTIVE', isSiteAdmin: false },
{
username: 'fixture-editor',
displayName: 'Fixture Editor',
status: 'ACTIVE',
isSiteAdmin: false,
},
{ {
username: 'fixture-pending', username: 'fixture-pending',
displayName: 'Fixture Pending', displayName: 'Fixture Pending',

View File

@ -21,6 +21,7 @@ import { Label, Pond, Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { PondPermissionCache } from '../permissions/pond-permission-cache'; import { PondPermissionCache } from '../permissions/pond-permission-cache';
import { PondAccessNotifier } from '../ponds/pond-access-notifier.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SearchProvider } from '../search/search.provider'; import { SearchProvider } from '../search/search.provider';
@ -50,6 +51,7 @@ export class LabelsService {
private readonly permissionCache: PondPermissionCache, private readonly permissionCache: PondPermissionCache,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
private readonly search: SearchProvider, private readonly search: SearchProvider,
private readonly accessNotifier: PondAccessNotifier,
) { ) {
this.logger.setContext(LabelsService.name); this.logger.setContext(LabelsService.name);
} }
@ -215,6 +217,8 @@ export class LabelsService {
// Label grants cover descendants — a moved subtree changes their reach. // Label grants cover descendants — a moved subtree changes their reach.
this.permissionCache.invalidate(pondId); 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'); this.logger.info({ labelId, pondId, parentId, userId: user.id }, 'audit: label moved');
return this.viewOf(label); return this.viewOf(label);
} }
@ -250,6 +254,9 @@ export class LabelsService {
}); });
this.permissionCache.invalidate(pondId); 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). // Those pages lost a label → their search entries change (#49).
for (const pageId of affectedPageIds) await this.search.indexPage(pageId); for (const pageId of affectedPageIds) await this.search.indexPage(pageId);
this.logger.info({ labelId, pondId, userId: user.id, force }, 'audit: label deleted'); this.logger.info({ labelId, pondId, userId: user.id, force }, 'audit: label deleted');
@ -287,14 +294,20 @@ export class LabelsService {
create: { pageId, labelId }, create: { pageId, labelId },
update: {}, 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) await this.search.indexPage(pageId); // labels are a search field (#49)
return this.pageLabels(user, pageId); return this.pageLabels(user, pageId);
} }
/** Remove a label from a page (idempotent). */ /** Remove a label from a page (idempotent). */
async unassign(_user: User, pageId: string, labelId: string): Promise<void> { async unassign(_user: User, pageId: string, labelId: string): Promise<void> {
await this.requireLivePage(pageId); const page = await this.requireLivePage(pageId);
await this.prisma.pageLabel.deleteMany({ where: { pageId, labelId } }); 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) await this.search.indexPage(pageId); // labels are a search field (#49)
} }
} }

View File

@ -88,8 +88,11 @@ describe.skipIf(!hasTestDb)('collab token (e2e, issue #34)', () => {
await app.close(); await app.close();
}); });
it('requires authentication', async () => { it('hides a private page from an anonymous visitor (404, not 401 — issue #53)', async () => {
await api().get(`/api/v1/pages/${pageId}/collab-token`).expect(401); // 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 () => { it('issues a valid rw token to a member and encodes the right claims', async () => {

View File

@ -26,7 +26,7 @@ import {
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import type { Response } from 'express'; 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 { ZodValidationPipe } from '../common/zod-validation.pipe';
import { import {
AuthenticatedOnly, AuthenticatedOnly,
@ -65,14 +65,20 @@ export class PagesController {
return this.pages.getState(request.user!, id); 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') @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( async collabToken(
@Param('id') id: string, @Param('id') id: string,
@Req() request: AuthedRequest, @Req() request: AuthedRequest,
): Promise<CollabTokenResponse> { ): Promise<CollabTokenResponse> {
return this.pages.issueCollabToken(request.user!, id); return this.pages.issueCollabToken(request.user ?? null, id);
} }
/** Markdown export (issue #30) — downloads `<slug>.md`. */ /** Markdown export (issue #30) — downloads `<slug>.md`. */

View File

@ -186,19 +186,24 @@ export class PagesService {
* runs in the api the guard requires read access, and the collab server * 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 * 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. * 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<CollabTokenResponse> { async issueCollabToken(user: User | null, id: string): Promise<CollabTokenResponse> {
const page = await this.findLivePage(id); const page = await this.findLivePage(id);
const canWrite = await this.permissions.canAccessPage(user, page, 'write'); const canWrite = await this.permissions.canAccessPage(user, page, 'write');
const mode = canWrite ? 'rw' : 'ro'; const mode = canWrite ? 'rw' : 'ro';
const userId = user?.id ?? null;
const token = signCollabToken( const token = signCollabToken(
{ userId: user.id, pageId: page.id, mode }, { userId, pageId: page.id, mode },
this.config.env.COLLAB_TOKEN_SECRET, this.config.env.COLLAB_TOKEN_SECRET,
COLLAB_TOKEN_TTL_SECONDS, COLLAB_TOKEN_TTL_SECONDS,
); );
// Debug level, and deliberately without the token value (issue #34). // 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 }; return { token, mode, expiresInSeconds: COLLAB_TOKEN_TTL_SECONDS };
} }

View File

@ -179,6 +179,25 @@ describe.skipIf(!hasTestDb)('permission enforcement (e2e, issue #52)', () => {
expect((rw.body as { mode: string }).mode).toBe('rw'); 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 () => { it('editor edits pages but cannot manage members or labels', async () => {
await api() await api()
.patch(`/api/v1/pages/${pageId}`) .patch(`/api/v1/pages/${pageId}`)

View File

@ -9,7 +9,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { createAccessListener } from './access-listener.js'; 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 { freePort } from './testing/free-port.js';
import { InMemoryPagePersistence } from './testing/fake-persistence.js'; import { InMemoryPagePersistence } from './testing/fake-persistence.js';
import { collabTestDatabaseUrlOrUndefined } from './testing/test-db.js'; import { collabTestDatabaseUrlOrUndefined } from './testing/test-db.js';
@ -84,7 +84,7 @@ describe.skipIf(!url)('access listener (DB-backed)', () => {
createClient: () => new Client({ connectionString: url }), createClient: () => new Client({ connectionString: url }),
pool, pool,
openDocumentNames: () => [...server.hocuspocus.documents.keys()], openDocumentNames: () => [...server.hocuspocus.documents.keys()],
closeConnections: (name) => server.hocuspocus.closeConnections(name), closeConnections: (name) => closeDocumentConnections(server.hocuspocus, name),
logger, logger,
reconnectDelayMs: 100, reconnectDelayMs: 100,
}); });
@ -103,7 +103,17 @@ describe.skipIf(!url)('access listener (DB-backed)', () => {
await pool.end(); 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; provider: HocuspocusProvider;
doc: Y.Doc; doc: Y.Doc;
synced: () => boolean; synced: () => boolean;
@ -117,12 +127,13 @@ describe.skipIf(!url)('access listener (DB-backed)', () => {
url: wsUrl, url: wsUrl,
name, name,
document: doc, document: doc,
token: signCollabToken({ userId, pageId: name, mode: 'rw' }, secret, 60), token,
onSynced: () => { onSynced: () => {
synced = true; synced = true;
}, },
onClose: () => { onClose: () => {
closeCount += 1; closeCount += 1;
synced = false; // becomes true again once the reconnect re-syncs
}, },
}); });
return { return {
@ -151,6 +162,45 @@ describe.skipIf(!url)('access listener (DB-backed)', () => {
client.destroy(); 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 () => { it('leaves sessions of unrelated ponds untouched', async () => {
const client = connect(pageId); const client = connect(pageId);
await waitFor(client.synced); await waitFor(client.synced);

View File

@ -6,7 +6,7 @@ import { createPool, pingDatabase } from './db.js';
import { createLogger } from './logger.js'; import { createLogger } from './logger.js';
import { PostgresPagePersistence } from './persistence.js'; import { PostgresPagePersistence } from './persistence.js';
import { createRestoreListener } from './restore-listener.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 { PostgresSessionRegistry } from './session-registry.js';
import { PostgresVersionStore } from './version-store.js'; import { PostgresVersionStore } from './version-store.js';
@ -44,7 +44,7 @@ async function bootstrap(): Promise<void> {
createClient: () => new Client({ connectionString: env.DATABASE_URL }), createClient: () => new Client({ connectionString: env.DATABASE_URL }),
pool, pool,
openDocumentNames: () => [...server.hocuspocus.documents.keys()], openDocumentNames: () => [...server.hocuspocus.documents.keys()],
closeConnections: (documentName) => server.hocuspocus.closeConnections(documentName), closeConnections: (documentName) => closeDocumentConnections(server.hocuspocus, documentName),
logger, logger,
}); });

View File

@ -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 { MAX_PAGE_DOCUMENT_BYTES } from '@dorfteich/shared';
import { verifyCollabToken } from '@dorfteich/shared/token-crypto'; import { verifyCollabToken } from '@dorfteich/shared/token-crypto';
import type { Logger } from 'pino'; 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. */ /** Per-connection context returned by onAuthenticate and used by later hooks. */
export interface CollabContext { export interface CollabContext {
userId: string; /** `null` for an anonymous visitor on a public page (issue #53). */
userId: string | null;
mode: 'rw' | 'ro'; mode: 'rw' | 'ro';
} }
@ -47,6 +48,27 @@ export interface CollabErrorMessage {
limitBytes: number; 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 * The Hocuspocus collaboration server (ADR 0003). It authenticates every
* connection with the api-minted token (#34) and is the writer of page state: * connection with the api-minted token (#34) and is the writer of page state:

View File

@ -42,11 +42,12 @@ Seeded by `pnpm --filter @dorfteich/api db:seed` (idempotent — re-running
never duplicates). Shared password: `fixture passwort 123`. Fixtures exist never duplicates). Shared password: `fixture passwort 123`. Fixtures exist
only on dev machines and disposable CI/Test databases. only on dev machines and disposable CI/Test databases.
| Username | State | Purpose | | Username | State | Purpose |
| ----------------- | ------------------- | ------------------------------------ | | ----------------- | ------------------- | ---------------------------------------------------------------------------------------- |
| `fixture-admin` | active, Site Admin | admin UI/permissions cases | | `fixture-admin` | active, Site Admin | admin UI/permissions cases |
| `fixture-user` | active | regular journeys, settings, sessions | | `fixture-user` | active | regular journeys, settings, sessions |
| `fixture-pending` | e-mail not verified | unverified-login cases | | `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 ## Content fixtures

View File

@ -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<string> {
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<string> {
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<Page> {
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();
});

View File

@ -121,9 +121,6 @@ test('remote carets and the presence strip reflect participants (#37)', async ({
await admin.close(); await admin.close();
}); });
// Read-only participants (live changes visible, typing blocked, reason shown) // Read-only participants (live changes visible, typing blocked) and the live
// need a real read-only grant to obtain a `ro` collab token. Under the interim // read-write→read-only downgrade need real grants, so they live in the
// access model seeing and modifying coincide, so no user is issued a `ro` token // `collab-permissions` pack (issue #53) alongside the grant setup they require.
// 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)', () => {});

View File

@ -42,7 +42,9 @@ export interface PageRestoreRequest {
/** The application claims carried by a collaboration token. */ /** The application claims carried by a collaboration token. */
export const collabTokenClaimsSchema = z.object({ 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), pageId: z.string().min(1),
mode: collabTokenModeSchema, mode: collabTokenModeSchema,
}); });