Implements Option 2 of #93. The restricted branch of broadcastPageMoved previously resolved its audience twice — emitToAuthorizedUsers and emitDeleteToUnauthorized each ran an independent fetchSockets + getUserIdsWithPageAccess — leaving a race window between the two snapshots where a socket could receive both the move and the delete (leak) or neither (lost compensating delete). - ws.service.ts: add emitMoveWithRestrictionSplit() that takes ONE socket snapshot and ONE authorization resolution, then partitions the room: authorized users get the moveTreeNode, everyone else (unauthorized + anonymous) get the compensating deleteTreeNode. Disjoint + complete by construction. Remove the now-unused emitToAuthorizedUsers / emitDeleteToUnauthorized; keep private broadcastToAuthorizedUsers (still used by emitRestrictedAwareToSpace). - ws-tree.service.ts: broadcastPageMoved restricted branch now drives move + delete from the single method. - specs: assert the single method is used and that fetchSockets / getUserIdsWithPageAccess are each called exactly once (single snapshot); re-route ws-service.spec to emitTreeEvent after the method removal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
170 lines
6.6 KiB
TypeScript
170 lines
6.6 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
|
import { WsService } from './ws.service';
|
|
import {
|
|
PageMovedEvent,
|
|
TreeNodeSnapshot,
|
|
TreeUpdateSnapshot,
|
|
} from '../database/listeners/page.listener';
|
|
|
|
@Injectable()
|
|
export class WsTreeService {
|
|
constructor(
|
|
private readonly wsService: WsService,
|
|
private readonly pagePermissionRepo: PagePermissionRepo,
|
|
) {}
|
|
|
|
// Server-origin tree broadcasts. Built from thin node snapshots carried in the
|
|
// domain events (variant A) so no DB read happens here — this avoids the
|
|
// in-transaction visibility race. Payload shapes mirror what the client
|
|
// receiver (`use-tree-socket.ts`) consumes.
|
|
|
|
async broadcastPageCreated(page: TreeNodeSnapshot): Promise<void> {
|
|
await this.wsService.emitTreeEvent(page.spaceId, page.id, {
|
|
operation: 'addTreeNode',
|
|
spaceId: page.spaceId,
|
|
payload: {
|
|
parentId: page.parentPageId ?? null,
|
|
// Receivers place by `position` among already-loaded siblings, not by
|
|
// this absolute index (sender's loaded set differs from receivers').
|
|
index: 0,
|
|
data: {
|
|
id: page.id,
|
|
slugId: page.slugId,
|
|
name: page.title ?? '',
|
|
title: page.title,
|
|
icon: page.icon,
|
|
position: page.position,
|
|
spaceId: page.spaceId,
|
|
parentPageId: page.parentPageId,
|
|
hasChildren: false,
|
|
children: [],
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
// Rename / icon change: patch the in-tree node's title/icon on every client in
|
|
// the space. Routed through the restriction-aware `emitTreeEvent` so a
|
|
// restricted page's new title/icon never leaks to sockets that can't see it.
|
|
// The payload mirrors the client `UpdateEvent` shape consumed by
|
|
// `applyUpdateOne` (entity ["pages"], `id`, `payload.title` / `payload.icon`);
|
|
// only the fields that actually changed are sent (the snapshot omits the rest).
|
|
async broadcastPageUpdated(node: TreeUpdateSnapshot): Promise<void> {
|
|
await this.wsService.emitTreeEvent(node.spaceId, node.id, {
|
|
operation: 'updateOne',
|
|
spaceId: node.spaceId,
|
|
entity: ['pages'],
|
|
id: node.id,
|
|
payload: {
|
|
slugId: node.slugId,
|
|
parentPageId: node.parentPageId,
|
|
// Only include changed fields; an absent field leaves the client node
|
|
// untouched (applyUpdateOne checks `!== undefined` per field).
|
|
...(node.title !== undefined ? { title: node.title } : {}),
|
|
...(node.icon !== undefined ? { icon: node.icon } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
async broadcastPageDeleted(page: TreeNodeSnapshot): Promise<void> {
|
|
await this.wsService.emitTreeEvent(page.spaceId, page.id, {
|
|
operation: 'deleteTreeNode',
|
|
spaceId: page.spaceId,
|
|
payload: {
|
|
node: {
|
|
id: page.id,
|
|
slugId: page.slugId,
|
|
parentPageId: page.parentPageId ?? null,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
async broadcastPageMoved(event: PageMovedEvent): Promise<void> {
|
|
const { node } = event;
|
|
|
|
const movePayload = {
|
|
operation: 'moveTreeNode',
|
|
spaceId: node.spaceId,
|
|
payload: {
|
|
id: node.id,
|
|
parentId: node.parentPageId ?? null,
|
|
oldParentId: event.oldParentId ?? null,
|
|
// See broadcastPageCreated: receivers place by `position`, not index.
|
|
index: 0,
|
|
position: node.position,
|
|
pageData: {
|
|
id: node.id,
|
|
slugId: node.slugId,
|
|
title: node.title,
|
|
icon: node.icon,
|
|
position: node.position,
|
|
spaceId: node.spaceId,
|
|
parentPageId: node.parentPageId ?? null,
|
|
hasChildren: event.hasChildren,
|
|
},
|
|
},
|
|
};
|
|
|
|
// Decide the node's restricted state ONCE, fresh (uncached), and drive BOTH
|
|
// the move broadcast and the compensating delete from this single decision.
|
|
//
|
|
// Why not just emitTreeEvent for the move? emitTreeEvent gates the move on
|
|
// the CACHED spaceHasRestrictions (30s TTL, never invalidated). In the window
|
|
// right after a space gets its FIRST restriction, that cache still says
|
|
// "no restrictions" → emitTreeEvent would fan the move out to the WHOLE room
|
|
// (including unauthorized users) while the delete below (computed from the
|
|
// UNCACHED hasRestrictedAncestor) also fires. An unauthorized user then gets
|
|
// BOTH, and if the delete lands first it is a no-op and the later move
|
|
// renders the restricted node → leak. So when the node is known-restricted we
|
|
// must NOT route the move through the cache-gated path.
|
|
const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor(
|
|
node.id,
|
|
);
|
|
|
|
if (!isRestricted) {
|
|
// Normal case: not under a restricted ancestor. One moveTreeNode to the
|
|
// whole space room (emitTreeEvent's open-space fast path), no delete.
|
|
await this.wsService.emitTreeEvent(node.spaceId, node.id, movePayload);
|
|
return;
|
|
}
|
|
|
|
// Restricted case: a move can push a previously-visible page UNDER a
|
|
// restricted ancestor. The move (to authorized users) and the compensating
|
|
// delete (to everyone else) are now driven from ONE socket/access snapshot:
|
|
// emitMoveWithRestrictionSplit performs a single fetchSockets + a single
|
|
// getUserIdsWithPageAccess and partitions the room from that one snapshot.
|
|
// This eliminates the race window that existed when the move and the delete
|
|
// each resolved the audience independently — a socket could otherwise have
|
|
// landed in both sets (leaking the restricted node) or in neither (losing the
|
|
// compensating delete). Authorized users get exactly the moveTreeNode,
|
|
// everyone else (unauthorized + anonymous) gets exactly the deleteTreeNode.
|
|
//
|
|
// Users who LOSE visibility need the delete because otherwise the node would
|
|
// linger in their tree at its old parent with its real title/slugId/icon
|
|
// (existence + metadata leak).
|
|
await this.wsService.emitMoveWithRestrictionSplit(node.spaceId, node.id, movePayload, {
|
|
operation: 'deleteTreeNode',
|
|
spaceId: node.spaceId,
|
|
payload: {
|
|
node: {
|
|
id: node.id,
|
|
slugId: node.slugId,
|
|
parentPageId: event.oldParentId ?? null,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
// Used for restore (and other subtree re-attachments): rather than emitting N
|
|
// pointwise addTreeNode events, ask clients in the space to refetch the root
|
|
// tree. The client already understands `refetchRootTreeNodeEvent`.
|
|
async broadcastRefetchRoot(spaceId: string): Promise<void> {
|
|
this.wsService.emitToSpaceRoom(spaceId, {
|
|
operation: 'refetchRootTreeNodeEvent',
|
|
spaceId,
|
|
});
|
|
}
|
|
}
|