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
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:
parent
b1f2105a2e
commit
9d288b2ad0
@ -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%';" | \
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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<CollabTokenResponse> {
|
||||
return this.pages.issueCollabToken(request.user!, id);
|
||||
return this.pages.issueCollabToken(request.user ?? null, id);
|
||||
}
|
||||
|
||||
/** Markdown export (issue #30) — downloads `<slug>.md`. */
|
||||
|
||||
@ -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<CollabTokenResponse> {
|
||||
async issueCollabToken(user: User | null, id: string): Promise<CollabTokenResponse> {
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@ -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}`)
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<void> {
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -43,9 +43,10 @@ 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-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
|
||||
|
||||
130
apps/web/e2e/collab-permissions.spec.ts
Normal file
130
apps/web/e2e/collab-permissions.spec.ts
Normal 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();
|
||||
});
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user