@@ -147,8 +147,8 @@ MCP_DOCMOST_PASSWORD=
|
||||
# per-IP limit is fully evaded. It is a COST backstop, not an access control, and
|
||||
# FAILS CLOSED if Redis is unavailable (an optional assistant briefly going
|
||||
# offline is safer than an unbounded bill). Override the hourly cap below
|
||||
# (default: 300 calls per workspace per rolling hour).
|
||||
# SHARE_AI_WORKSPACE_MAX_PER_HOUR=300
|
||||
# (default: 100 calls per workspace per rolling hour).
|
||||
# SHARE_AI_WORKSPACE_MAX_PER_HOUR=100
|
||||
#
|
||||
# Per-request output-token ceiling for the anonymous assistant (default: 512).
|
||||
# Worst-case output per accepted call = agent steps (5) × this value.
|
||||
|
||||
@@ -671,6 +671,30 @@
|
||||
"AI agent": "AI-агент",
|
||||
"AI agent is typing…": "AI-агент печатает…",
|
||||
"{{name}} is typing…": "{{name}} печатает…",
|
||||
"Agent role": "Роль агента",
|
||||
"AI chat": "AI-чат",
|
||||
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
||||
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
|
||||
"Ask a question…": "Задайте вопрос…",
|
||||
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
|
||||
"Ask the AI agent…": "Спросите AI-агента…",
|
||||
"Copy chat": "Копировать чат",
|
||||
"Created successfully": "Успешно создано",
|
||||
"Current context size": "Текущий размер контекста",
|
||||
"Delete this chat?": "Удалить этот чат?",
|
||||
"Deleted successfully": "Успешно удалено",
|
||||
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
|
||||
"Failed to delete chat": "Не удалось удалить чат",
|
||||
"Failed to rename chat": "Не удалось переименовать чат",
|
||||
"Minimize": "Свернуть",
|
||||
"No chats yet.": "Чатов пока нет.",
|
||||
"Send": "Отправить",
|
||||
"Something went wrong": "Что-то пошло не так",
|
||||
"Stop": "Стоп",
|
||||
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
||||
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
|
||||
"Universal assistant": "Универсальный ассистент",
|
||||
"You": "Вы",
|
||||
"AI is thinking...": "ИИ обрабатывает запрос...",
|
||||
"Thinking": "Думаю",
|
||||
"Ask a question...": "Задайте вопрос...",
|
||||
|
||||
@@ -12,6 +12,15 @@ i18n
|
||||
// init i18next
|
||||
// for all options read: https://www.i18next.com/overview/configuration-options
|
||||
.init({
|
||||
// i18n maintenance policy:
|
||||
// - en-US is the source of truth for all UI strings (keys are the English text).
|
||||
// - en-US and ru-RU are the fully-maintained locales; in particular, the
|
||||
// AI-chat string set is kept complete in both so the UI never renders
|
||||
// mixed-language (no per-key en-US fallback within a single widget).
|
||||
// - The other 10 locales (fr-FR, de-DE, es-ES, nl-NL, ja-JP, zh-CN, ko-KR,
|
||||
// pt-BR, it-IT, uk-UA) are partial and intentionally rely on the
|
||||
// `fallbackLng: "en-US"` fallback below until translations are
|
||||
// contributed (e.g. via Crowdin).
|
||||
fallbackLng: "en-US",
|
||||
debug: false,
|
||||
showSupportNotice: false,
|
||||
|
||||
@@ -386,7 +386,7 @@ describe('resolveShareAiWorkspaceMax (env-overridable per-workspace cap)', () =>
|
||||
it('falls back to the default for an unparseable / NaN value', () => {
|
||||
process.env[ENV] = 'not-a-number';
|
||||
expect(resolveShareAiWorkspaceMax()).toBe(SHARE_AI_WORKSPACE_MAX_PER_WINDOW);
|
||||
expect(SHARE_AI_WORKSPACE_MAX_PER_WINDOW).toBe(300);
|
||||
expect(SHARE_AI_WORKSPACE_MAX_PER_WINDOW).toBe(100);
|
||||
});
|
||||
|
||||
it('falls back to the default when unset', () => {
|
||||
|
||||
@@ -42,7 +42,7 @@ import type { Redis } from 'ioredis';
|
||||
*/
|
||||
|
||||
/** Default cap: anonymous share-AI calls allowed per workspace per window. */
|
||||
export const SHARE_AI_WORKSPACE_MAX_PER_WINDOW = 300;
|
||||
export const SHARE_AI_WORKSPACE_MAX_PER_WINDOW = 100;
|
||||
/** Default window length: one rolling hour. */
|
||||
export const SHARE_AI_WORKSPACE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
* fan-out per user, sockets with no userId skipped).
|
||||
*
|
||||
* Both private methods are exercised through their public entry points:
|
||||
* spaceHasRestrictions via emitTreeEvent, broadcastToAuthorizedUsers via
|
||||
* emitToAuthorizedUsers. WsService is constructed with mocked cache + repo and a
|
||||
* mocked socket.io server, so no live infra is needed.
|
||||
* spaceHasRestrictions via emitTreeEvent, broadcastToAuthorizedUsers via the
|
||||
* restricted-page path of emitTreeEvent. WsService is constructed with mocked
|
||||
* cache + repo and a mocked socket.io server, so no live infra is needed.
|
||||
*/
|
||||
|
||||
describe('WsService.spaceHasRestrictions (cache lifecycle, via emitTreeEvent)', () => {
|
||||
@@ -127,7 +127,7 @@ describe('WsService.spaceHasRestrictions (cache lifecycle, via emitTreeEvent)',
|
||||
});
|
||||
});
|
||||
|
||||
describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUsers)', () => {
|
||||
describe('WsService.broadcastToAuthorizedUsers fan-out (via emitTreeEvent restricted path)', () => {
|
||||
let service: WsService;
|
||||
let pagePermissionRepo: {
|
||||
hasRestrictedPagesInSpace: jest.Mock;
|
||||
@@ -167,6 +167,12 @@ describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUser
|
||||
in: serverIn,
|
||||
};
|
||||
service.setServer(server as never);
|
||||
|
||||
// Reach broadcastToAuthorizedUsers through emitTreeEvent's restricted path:
|
||||
// the space has restrictions (cache miss -> repo says true) and the page has
|
||||
// a restricted ancestor, so the emit is scoped to the authorized users.
|
||||
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(true);
|
||||
pagePermissionRepo.hasRestrictedAncestor.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('only sockets whose userId is in getUserIdsWithPageAccess receive the event', async () => {
|
||||
@@ -180,7 +186,7 @@ describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUser
|
||||
]);
|
||||
|
||||
const data = { operation: 'moveTreeNode' };
|
||||
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
|
||||
await service.emitTreeEvent('space-1', 'page-1', data);
|
||||
|
||||
// The authorized set is resolved from the candidate userIds present on the
|
||||
// sockets (deduped), then only those users' sockets get the event.
|
||||
@@ -203,7 +209,7 @@ describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUser
|
||||
]);
|
||||
|
||||
const data = { operation: 'moveTreeNode' };
|
||||
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
|
||||
await service.emitTreeEvent('space-1', 'page-1', data);
|
||||
|
||||
// Both of the authorized user's sockets (e.g. two browser tabs) receive it.
|
||||
expect(tab1).toHaveBeenCalledWith('message', data);
|
||||
@@ -227,7 +233,7 @@ describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUser
|
||||
]);
|
||||
|
||||
const data = { operation: 'moveTreeNode' };
|
||||
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
|
||||
await service.emitTreeEvent('space-1', 'page-1', data);
|
||||
|
||||
expect(okEmit).toHaveBeenCalledWith('message', data);
|
||||
expect(anonEmit).not.toHaveBeenCalled();
|
||||
@@ -241,7 +247,7 @@ describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUser
|
||||
it('no sockets in the room -> no repo lookup, no emit', async () => {
|
||||
fetchSockets.mockResolvedValue([]);
|
||||
|
||||
await service.emitToAuthorizedUsers('space-1', 'page-1', { op: 'x' });
|
||||
await service.emitTreeEvent('space-1', 'page-1', { op: 'x' });
|
||||
|
||||
expect(pagePermissionRepo.getUserIdsWithPageAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -252,7 +258,7 @@ describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUser
|
||||
{ id: 's1', data: { userId: 'u' }, emit: jest.fn() },
|
||||
]);
|
||||
|
||||
await service.emitToAuthorizedUsers('space-7', 'page-1', { op: 'x' });
|
||||
await service.emitTreeEvent('space-7', 'page-1', { op: 'x' });
|
||||
|
||||
expect(serverIn).toHaveBeenCalledWith(getSpaceRoomName('space-7'));
|
||||
});
|
||||
|
||||
@@ -27,8 +27,7 @@ describe('WsTreeService', () => {
|
||||
let wsService: {
|
||||
emitTreeEvent: jest.Mock;
|
||||
emitToSpaceRoom: jest.Mock;
|
||||
emitDeleteToUnauthorized: jest.Mock;
|
||||
emitToAuthorizedUsers: jest.Mock;
|
||||
emitMoveWithRestrictionSplit: jest.Mock;
|
||||
};
|
||||
let pagePermissionRepo: { hasRestrictedAncestor: jest.Mock };
|
||||
|
||||
@@ -36,8 +35,7 @@ describe('WsTreeService', () => {
|
||||
wsService = {
|
||||
emitTreeEvent: jest.fn().mockResolvedValue(undefined),
|
||||
emitToSpaceRoom: jest.fn(),
|
||||
emitDeleteToUnauthorized: jest.fn().mockResolvedValue(undefined),
|
||||
emitToAuthorizedUsers: jest.fn().mockResolvedValue(undefined),
|
||||
emitMoveWithRestrictionSplit: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
pagePermissionRepo = {
|
||||
// Default: not restricted, so broadcastPageMoved skips the compensating
|
||||
@@ -150,14 +148,13 @@ describe('WsTreeService', () => {
|
||||
|
||||
await service.broadcastPageMoved(event);
|
||||
|
||||
// Normal path: move goes to the whole room via emitTreeEvent, and neither
|
||||
// the authorized-only move path nor the compensating delete fire.
|
||||
// Normal path: move goes to the whole room via emitTreeEvent, and the
|
||||
// single-snapshot move/delete split does not fire.
|
||||
expect(wsService.emitTreeEvent).toHaveBeenCalledTimes(1);
|
||||
expect(wsService.emitToAuthorizedUsers).not.toHaveBeenCalled();
|
||||
expect(wsService.emitDeleteToUnauthorized).not.toHaveBeenCalled();
|
||||
expect(wsService.emitMoveWithRestrictionSplit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('broadcastPageMoved into a RESTRICTED subtree routes the move to authorized users only AND emits a compensating delete to unauthorized — from one fresh decision', async () => {
|
||||
it('broadcastPageMoved into a RESTRICTED subtree drives the move + compensating delete from ONE single-snapshot split call', async () => {
|
||||
// Destination is now under a restricted ancestor.
|
||||
pagePermissionRepo.hasRestrictedAncestor.mockResolvedValue(true);
|
||||
|
||||
@@ -180,11 +177,18 @@ describe('WsTreeService', () => {
|
||||
// which could leak the move to the whole room during the stale-cache window.
|
||||
expect(wsService.emitTreeEvent).not.toHaveBeenCalled();
|
||||
|
||||
// The move is delivered to authorized users only.
|
||||
expect(wsService.emitToAuthorizedUsers).toHaveBeenCalledTimes(1);
|
||||
expect(wsService.emitToAuthorizedUsers).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
'page-1',
|
||||
// BOTH the move and the compensating delete are driven from ONE call, so a
|
||||
// single socket/access snapshot partitions the room (no race window).
|
||||
expect(wsService.emitMoveWithRestrictionSplit).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [spaceId, pageId, movePayload, deletePayload] =
|
||||
wsService.emitMoveWithRestrictionSplit.mock.calls[0];
|
||||
|
||||
expect(spaceId).toBe('space-1');
|
||||
expect(pageId).toBe('page-1');
|
||||
|
||||
// The move payload is the moveTreeNode for the moved page.
|
||||
expect(movePayload).toEqual(
|
||||
expect.objectContaining({
|
||||
operation: 'moveTreeNode',
|
||||
spaceId: 'space-1',
|
||||
@@ -192,20 +196,23 @@ describe('WsTreeService', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// The users who lost access get a deleteTreeNode for the moved node, scoped
|
||||
// to the same page id (same fresh authorized set → disjoint from the move).
|
||||
expect(wsService.emitDeleteToUnauthorized).toHaveBeenCalledTimes(1);
|
||||
expect(wsService.emitDeleteToUnauthorized).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
'page-1',
|
||||
// The delete payload is the compensating deleteTreeNode, scoped to the same
|
||||
// page id and carrying the OLD parent id (so it disappears from where it was
|
||||
// last visible).
|
||||
expect(deletePayload).toEqual(
|
||||
expect.objectContaining({
|
||||
operation: 'deleteTreeNode',
|
||||
spaceId: 'space-1',
|
||||
payload: {
|
||||
node: expect.objectContaining({ id: 'page-1', slugId: 'slug-1' }),
|
||||
node: expect.objectContaining({
|
||||
id: 'page-1',
|
||||
slugId: 'slug-1',
|
||||
parentPageId: 'old-parent',
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(deletePayload.payload.node.parentPageId).toBe(event.oldParentId);
|
||||
});
|
||||
|
||||
it('broadcastRefetchRoot emits refetchRootTreeNodeEvent to the space room', async () => {
|
||||
@@ -339,7 +346,7 @@ describe('WsService.emitTreeEvent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('emitDeleteToUnauthorized sends ONLY to sockets whose user lacks page access', async () => {
|
||||
it('emitMoveWithRestrictionSplit partitions the room from one snapshot: authorized -> move, unauthorized + anonymous -> delete', async () => {
|
||||
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']);
|
||||
|
||||
const okEmit = jest.fn();
|
||||
@@ -348,38 +355,49 @@ describe('WsService.emitTreeEvent', () => {
|
||||
const sockets = [
|
||||
{ id: 's1', data: { userId: 'user-ok' }, emit: okEmit },
|
||||
{ id: 's2', data: { userId: 'user-no' }, emit: noEmit },
|
||||
// Unauthenticated socket (no userId) — must also receive the delete.
|
||||
// Unauthenticated socket (no userId) — must receive the delete.
|
||||
{ id: 's3', data: {}, emit: anonEmit },
|
||||
];
|
||||
server.in.mockReturnValue({
|
||||
fetchSockets: jest.fn().mockResolvedValue(sockets),
|
||||
});
|
||||
|
||||
const data = { operation: 'deleteTreeNode' };
|
||||
await service.emitDeleteToUnauthorized('space-1', 'page-1', data);
|
||||
const movePayload = { operation: 'moveTreeNode' };
|
||||
const deletePayload = { operation: 'deleteTreeNode' };
|
||||
await service.emitMoveWithRestrictionSplit(
|
||||
'space-1',
|
||||
'page-1',
|
||||
movePayload,
|
||||
deletePayload,
|
||||
);
|
||||
|
||||
// Authorized user does NOT get the delete (they got the move instead).
|
||||
expect(okEmit).not.toHaveBeenCalled();
|
||||
// Unauthorized + anonymous sockets DO get the delete.
|
||||
expect(noEmit).toHaveBeenCalledWith('message', data);
|
||||
expect(anonEmit).toHaveBeenCalledWith('message', data);
|
||||
// Authorized socket gets ONLY the move.
|
||||
expect(okEmit).toHaveBeenCalledWith('message', movePayload);
|
||||
expect(okEmit).not.toHaveBeenCalledWith('message', deletePayload);
|
||||
// Unauthorized + anonymous sockets get ONLY the delete.
|
||||
expect(noEmit).toHaveBeenCalledWith('message', deletePayload);
|
||||
expect(noEmit).not.toHaveBeenCalledWith('message', movePayload);
|
||||
expect(anonEmit).toHaveBeenCalledWith('message', deletePayload);
|
||||
expect(anonEmit).not.toHaveBeenCalledWith('message', movePayload);
|
||||
});
|
||||
});
|
||||
|
||||
describe('move-into-restricted disjointness contract (WsTreeService + real WsService)', () => {
|
||||
// CONTRACT: a move under a restricted ancestor PARTITIONS the room. The
|
||||
// authorized set (gets the moveTreeNode via emitToAuthorizedUsers) and its
|
||||
// complement (gets the deleteTreeNode via emitDeleteToUnauthorized) are
|
||||
// disjoint and together cover every socket — and an anonymous (no-userId)
|
||||
// socket lands in the delete set. We wire a REAL WsService (only its repo,
|
||||
// cache and socket server mocked) so both broadcasts run against the SAME fixed
|
||||
// socket set, the way they do in production.
|
||||
// CONTRACT: a move under a restricted ancestor PARTITIONS the room from a
|
||||
// SINGLE snapshot. emitMoveWithRestrictionSplit performs exactly one
|
||||
// fetchSockets + one getUserIdsWithPageAccess; the authorized set (gets the
|
||||
// moveTreeNode) and its complement (gets the deleteTreeNode) are disjoint and
|
||||
// together cover every socket — and an anonymous (no-userId) socket lands in
|
||||
// the delete set. We wire a REAL WsService (only its repo, cache and socket
|
||||
// server mocked) so the partition runs against the SAME fixed socket set, the
|
||||
// way it does in production.
|
||||
let treeService: WsTreeService;
|
||||
let pagePermissionRepo: {
|
||||
hasRestrictedPagesInSpace: jest.Mock;
|
||||
hasRestrictedAncestor: jest.Mock;
|
||||
getUserIdsWithPageAccess: jest.Mock;
|
||||
};
|
||||
let fetchSockets: jest.Mock;
|
||||
|
||||
// Fixed room: two authorized users (one with two sockets), one unauthorized
|
||||
// user, one anonymous socket.
|
||||
@@ -429,11 +447,12 @@ describe('move-into-restricted disjointness contract (WsTreeService + real WsSer
|
||||
}).compile();
|
||||
|
||||
const wsService = module.get<WsService>(WsService);
|
||||
// Capture fetchSockets so the test can assert the SINGLE-snapshot contract:
|
||||
// exactly one fetchSockets call drives the whole partition.
|
||||
fetchSockets = jest.fn().mockResolvedValue(sockets);
|
||||
const server = {
|
||||
to: jest.fn().mockReturnValue({ emit: jest.fn() }),
|
||||
in: jest.fn().mockReturnValue({
|
||||
fetchSockets: jest.fn().mockResolvedValue(sockets),
|
||||
}),
|
||||
in: jest.fn().mockReturnValue({ fetchSockets }),
|
||||
};
|
||||
wsService.setServer(server as never);
|
||||
|
||||
@@ -469,5 +488,12 @@ describe('move-into-restricted disjointness contract (WsTreeService + real WsSer
|
||||
// The anonymous socket specifically lands in the DELETE set, never the move.
|
||||
expect(deleteSet.has('s-anon')).toBe(true);
|
||||
expect(moveSet.has('s-anon')).toBe(false);
|
||||
|
||||
// SINGLE SNAPSHOT: the whole partition (move + compensating delete) is driven
|
||||
// from exactly ONE fetchSockets and exactly ONE getUserIdsWithPageAccess.
|
||||
// This is what closes the race window — there is no second, independent
|
||||
// snapshot that could disagree with the first.
|
||||
expect(fetchSockets).toHaveBeenCalledTimes(1);
|
||||
expect(pagePermissionRepo.getUserIdsWithPageAccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,22 +131,20 @@ export class WsTreeService {
|
||||
}
|
||||
|
||||
// Restricted case: a move can push a previously-visible page UNDER a
|
||||
// restricted ancestor. Route the move to authorized users ONLY (same fresh
|
||||
// getUserIdsWithPageAccess set the delete uses) and send the compensating
|
||||
// delete to everyone else. Both sets come from one fresh decision, so they
|
||||
// are guaranteed disjoint: authorized users get exactly the moveTreeNode,
|
||||
// unauthorized users get exactly the deleteTreeNode, nobody gets both.
|
||||
// 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.emitToAuthorizedUsers(
|
||||
node.spaceId,
|
||||
node.id,
|
||||
movePayload,
|
||||
);
|
||||
|
||||
await this.wsService.emitDeleteToUnauthorized(node.spaceId, node.id, {
|
||||
await this.wsService.emitMoveWithRestrictionSplit(node.spaceId, node.id, movePayload, {
|
||||
operation: 'deleteTreeNode',
|
||||
spaceId: node.spaceId,
|
||||
payload: {
|
||||
|
||||
@@ -118,19 +118,42 @@ export class WsService {
|
||||
this.server.to(getSpaceRoomName(spaceId)).emit('message', data);
|
||||
}
|
||||
|
||||
// Broadcast `data` (a deleteTreeNode) to every socket in the space room whose
|
||||
// user is NOT authorized to see `pageId`. Used to compensate a move that pushes
|
||||
// a previously-visible page UNDER a restricted ancestor: authorized users get
|
||||
// the moveTreeNode (via emitTreeEvent), everyone else gets a deleteTreeNode so
|
||||
// the now-restricted node disappears from their tree instead of lingering with
|
||||
// its real title/slugId/icon. The two event sets are disjoint by construction
|
||||
// (a user is either authorized or not), so no socket receives both.
|
||||
async emitDeleteToUnauthorized(
|
||||
// Single-snapshot move broadcast. This is the ONE place that fans out a move
|
||||
// under a restricted ancestor together with its compensating delete, resolving
|
||||
// the audience EXACTLY ONCE so the two never disagree.
|
||||
//
|
||||
// It takes a SINGLE socket snapshot (`this.server.in(room).fetchSockets()` is
|
||||
// called exactly once) and a SINGLE authorization resolution
|
||||
// (`getUserIdsWithPageAccess` is called exactly once). From that one snapshot it
|
||||
// partitions the room into two groups and emits to each:
|
||||
// - authorized users (their userId is in the authorized set) receive
|
||||
// `movePayload` (the moveTreeNode);
|
||||
// - everyone else — unauthorized users AND anonymous/no-userId sockets —
|
||||
// receive `deletePayload` (the compensating deleteTreeNode) so a now-hidden
|
||||
// node disappears from their tree instead of lingering with its real
|
||||
// title/slugId/icon.
|
||||
// Because both groups are derived from the same socket array and the same
|
||||
// authorized set, the partition is guaranteed DISJOINT (no socket gets both)
|
||||
// and COMPLETE (every socket gets exactly one). This closes the race window
|
||||
// that existed when the move and the compensating delete each ran their own
|
||||
// independent fetchSockets + getUserIdsWithPageAccess: between those two
|
||||
// snapshots a socket could connect/disconnect or its access change, so a socket
|
||||
// could end up in both sets (leaking the restricted node, then no delete) or in
|
||||
// neither (losing the compensating delete).
|
||||
//
|
||||
// It deliberately does NOT consult the cached spaceHasRestrictions: the caller
|
||||
// (broadcastPageMoved) has already established, freshly and uncached, that the
|
||||
// page is restricted, so we must not risk a stale cache fanning the move out to
|
||||
// the whole room.
|
||||
async emitMoveWithRestrictionSplit(
|
||||
spaceId: string,
|
||||
pageId: string,
|
||||
data: any,
|
||||
movePayload: any,
|
||||
deletePayload: any,
|
||||
): Promise<void> {
|
||||
const room = getSpaceRoomName(spaceId);
|
||||
|
||||
// ONE socket snapshot for the whole partition.
|
||||
const sockets = await this.server.in(room).fetchSockets();
|
||||
if (sockets.length === 0) return;
|
||||
|
||||
@@ -141,39 +164,26 @@ export class WsService {
|
||||
.filter((id): id is string => !!id),
|
||||
),
|
||||
);
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
const authorizedUserIds =
|
||||
await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, userIds);
|
||||
// ONE authorization resolution for the whole partition.
|
||||
const authorizedUserIds = userIds.length
|
||||
? await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, userIds)
|
||||
: [];
|
||||
const authorizedSet = new Set(authorizedUserIds);
|
||||
|
||||
for (const socket of sockets) {
|
||||
const userId = socket.data.userId as string;
|
||||
// Unauthenticated sockets (no userId) cannot see restricted content; send
|
||||
// them the delete too so a leaked node can't linger.
|
||||
if (!userId || !authorizedSet.has(userId)) {
|
||||
socket.emit('message', data);
|
||||
if (userId && authorizedSet.has(userId)) {
|
||||
// Authorized: deliver the move.
|
||||
socket.emit('message', movePayload);
|
||||
} else {
|
||||
// Unauthorized OR anonymous (no userId): deliver the compensating
|
||||
// delete so the now-hidden node can't linger.
|
||||
socket.emit('message', deletePayload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Server-origin broadcast of `data` to exactly the users in the space room who
|
||||
// ARE authorized to see `pageId`. This is the counterpart of
|
||||
// emitDeleteToUnauthorized: both resolve the authorized set from the SAME
|
||||
// fetchSockets + getUserIdsWithPageAccess call shape, so a caller that drives
|
||||
// both from one decision gets two disjoint sets (authorized vs. not) with no
|
||||
// socket in both. Unlike emitTreeEvent, this does NOT consult the cached
|
||||
// spaceHasRestrictions: the caller already knows the page is restricted, so we
|
||||
// must not risk a stale cache fanning the move out to the whole room.
|
||||
async emitToAuthorizedUsers(
|
||||
spaceId: string,
|
||||
pageId: string,
|
||||
data: any,
|
||||
): Promise<void> {
|
||||
const room = getSpaceRoomName(spaceId);
|
||||
await this.broadcastToAuthorizedUsers(room, null, pageId, data);
|
||||
}
|
||||
|
||||
private async broadcastToAuthorizedUsers(
|
||||
room: string,
|
||||
excludeSocketId: string | null,
|
||||
|
||||
Reference in New Issue
Block a user