Files
gitmost/apps/server/src/ws/ws-tree.service.ts
claude_code c3161a05dd refactor(ws): single-snapshot move audience to close the restricted-move race (#93)
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>
2026-06-21 14:24:18 +03:00

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,
});
}
}