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 \
|
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%';" | \
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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`. */
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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}`)
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
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();
|
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)', () => {});
|
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user