Compare commits

..

11 Commits

Author SHA1 Message Date
agent_coder d38a12f9b0 Merge remote-tracking branch 'gitea/develop' into feat/481-version-coherence
# Conflicts:
#	CHANGELOG.md
2026-07-12 03:31:47 +03:00
vvzvlad 4f8563b5b5 Merge pull request 'feat(comments): audit trail + целостность apply suggestions (#496)' (#512) from feat/496-suggestions-audit into develop
Reviewed-on: #512
2026-07-12 03:21:37 +03:00
agent_coder eae7640f30 test(comments): не-вакуумные тесты audit-swallow + dominant-run; CHANGELOG (#512 ревью)
DO1: тест audit-swallow был вакуумен (log() — fire-and-forget void persist(),
.not.toThrow() зелен независимо от try/catch). Теперь: после failInsert()
шпионим Logger.warn + слушаем unhandledRejection, флашим микро/макротаски,
ассертим warn=1 и 0 floating-rejection. Mutation: убрать try/catch у persist
→ красный.
DO2: dominant-run тест не отличал longest от first (самый длинный ран был и
первым). Перестроено: короткий plain ран впереди, длинный bold — следом →
ассерт bold:true держится только при genuine longest; +тест tie→first.
Mutation: reduce→segments[0] → красный.
DO3: CHANGELOG [Unreleased] — 3 записи по #496.
DO4: убран no-op override insertAttrs.comment (=dominant.markAttrs, а он и есть
attributes['comment'] — сегменты фильтруются по нему; {...attributes,comment:
attributes.comment}={...attributes}); коммент про 'defensive re-assert' исправлен.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:44:27 +03:00
agent_coder 0099ba272d fix(comments): suggestion с вечным 409 (#496)
expectedText брался из debounced REST-снапшота (getAnchoredText по
pages/info), а метка ставилась в live-доке — при расхождении (док ушёл
вперёд за окно дебаунса) apply строго сравнивал текст под меткой с
устаревшим stored selection и давал 409 на каждый вызов.

MCP-клиент теперь в transform-фазе (та же версия live-дока, где ставится
метка) перечитывает фактическую подстроку под меткой и, если она
отличается от сохранённого selection, синкает её через новый эндпоинт
POST /comments/resync-suggestion-anchor. Best-effort: сбой синка не
откатывает уже заякоренный комментарий, а лишь выдаёт мягкое
предупреждение. Совпадающий снапшот не делает лишнего round-trip.

Сервер: resyncSuggestionAnchor правит только stored selection
незаселённой suggestion своего автора (guards: top-level, есть
suggestedText, не applied/resolved, отличается от suggestedText),
идемпотентно, без ws-бродкаста.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:43:42 +03:00
agent_coder 826bb491ca fix(comments): orphan-anchor reconcile + docstring (#496)
deleteEphemeralSuggestion docstring обещал инвариант «метка снимается
FIRST and FATALLY» синхронно — после #399 это уже не так: fatal только
ENQUEUE снятия метки, сама операция идёт в воркере с ретраями. Docstring
переписан под фактическое поведение.

Reconcile: воркер COMMENT_MARK_UPDATE на resolve/unresolve, обнаружив что
строки комментария больше нет (hard-delete гонкой с ephemeral apply/
dismiss), теперь СНИМАЕТ осиротевшую метку вместо тихого return. Это
самозаживляет тихую дивергенцию и закрывает fire-and-forget resolve/
unresolve enqueue из resolveComment. Операция идемпотентна.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:43:42 +03:00
agent_coder 7be49c1280 fix(comments): дедуп двойного WS-broadcast при apply треда с ответами (#496)
finalizeAppliedSuggestion на ветке «есть ответы» вызывал resolveComment
(бродкаст commentResolved с обогащённой строкой), а затем сам слал ещё и
commentUpdated — клиент получал два события на один apply. Теперь
commentUpdated шлётся только когда resolveComment НЕ вызывался (редкий
повторный вход по уже разрешённому треду), иначе один бродкаст.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:43:42 +03:00
agent_coder c7073b62d1 fix(comments): apply не стирает форматирование (#496)
replaceYjsMarkedText вставлял замену только с меткой `{comment}`, молча
теряя bold/italic/code/link исходного run'а. Теперь захватываем полный
набор атрибутов доминирующего (самого длинного) сегмента заменяемого
диапазона и применяем его к вставке: однородное форматирование
сохраняется точно, для смешанного run'а берётся преобладающий стиль
вместо полной потери. Метка комментария при этом гарантированно
сохраняется (переутверждается явно).

Клиентское предупреждение в превью диффа не добавлялось: у клиента нет
марок из документа (только plain selection/suggestedText), а фикс на
сервере уже сохраняет форматирование, так что баннер был бы неточным.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:43:42 +03:00
agent_coder 11b2c55485 feat(audit): DB-backed audit trail вместо Noop (#496)
Событие comment.suggestion_applied/dismissed раньше улетало в
NoopAuditService и молча терялось; при childless-ветке apply/dismiss
комментарий hard-delete'ится, поэтому восстановить, кто и что решил,
было невозможно.

- DatabaseAuditService пишет в уже существующую таблицу `audit`
  (миграция 20260228T223532); actor/workspace/ip берутся из CLS
  AuditContext, вне HTTP — из явного контекста (logWithContext).
  Аудит — побочная запись: сбой записи не ломает исходный запрос,
  EXCLUDED_AUDIT_EVENTS отбрасываются.
- Биндинг AUDIT_SERVICE переключён с Noop на DatabaseAuditService.
- payload apply/dismiss дополнен suggestedText/selection/commentAuthor/
  decidedBy — на childless-ветке это единственная уцелевшая запись.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:43:42 +03:00
agent_coder 03c91dfb0b docs(deploy): поправить устаревший коммент про auto-reload скрытой вкладки под вариант C (#481, ревью F1)
r1-коммент утверждал «auto-reload on a hidden tab», но вариант C (r2) убрал
авто-reload скрытой вкладки ради защиты несохранённого ввода. Переписал под
фактическое поведение: баннер + отложенный reload на следующей in-app навигации.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 04:10:22 +03:00
agent_coder 83f792ca39 fix(deploy): variant C — reload по in-app навигации вместо visibilitychange + лог-след + CHANGELOG (#481, ревью)
Правки по ревью #499. Владелец выбрал вариант C по вопросу потери несохранённого
ввода.

F1 (reload по навигации): убрал ветку visibilitychange→reload И немедленный reload
скрытой-при-получении вкладки — visibility больше не влияет на авто-reload вообще.
На реальном mismatch: баннер (кнопка «Обновить» — немедленный reload под тем же
one-shot гардом) + модульный флаг pendingNavReload. Хук useVersionReloadOnNavigation
(useLocation + useEffect по location.key, пропуск первого рендера через useRef;
react-router v7 BrowserRouter компонентный, объекта роутера нет — подписка изнутри
дерева) на СЛЕДУЮЩЕЙ навигации зовёт consumeNavigationReload → performAutoReload
(mark-перед-reload, при spent/непишущемся storage — только баннер). Скрытая вкладка
идёт тем же путём (C единообразно). chunk-load-error-boundary reload не тронут —
остаётся бэкапом для не-навигирующей вкладки. Так reload в безопасной точке (юзер
и так уходит со страницы), а не когда свернул с недописанным комментарием.

F2 (лог-след): общий recordReloadBreadcrumb/takeReloadBreadcrumb (sessionStorage,
переживает reload). performAutoReload пишет breadcrumb {path:proactive,server,
client} + console.warn перед reload; chunk-boundary пишет {path:chunk-boundary};
surfacePreviousReloadBreadcrumb на маунте UserProvider логирует прошлый след.

F3 (CHANGELOG + AGENTS.md): user-facing запись про version-coherence + строка про
артефакт client/dist/version.json.

Тесты: vitest version-coherence+reload-guard+guarded-reload 23 (переписан на
MemoryRouter+useNavigate: reload РОВНО раз на первой навигации после mismatch, НЕ
на 2-й навигации, НЕ на 2-м mismatch one-shot, НЕ от ухода в фон; скрытая вкладка
— только на навигации; Update — сразу; banner-only/write-fail — никогда). Шире:
features/user 34.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 03:01:07 +03:00
agent_coder 1e4925007d feat(deploy): version-coherence — WS-анонс версии сервера + guarded reload устаревших вкладок (#481)
SPA — долгоживущий клиент: вкладку держат часами, сервер за это время
передеплоивают. Старый index.html ссылается на content-hashed чанки, которых в
новом образе нет → ленивый import() → catch-all отдаёт index.html как text/html →
WebKit «not a valid JavaScript MIME type» (наблюдали в проде). Реактивный
предохранитель (chunk-error-boundary) ловит УЖЕ случившуюся ошибку. Делаем сигнал
ПРОАКТИВНЫМ: сервер сообщает версию по существующему WS, клиент сравнивает и
делает guarded reload ДО битого чанка. closes #481

- Единый источник версии: vite.config.ts вычисляет resolveAppVersion РОВНО раз →
  и в define.APP_VERSION, и в плагине, пишущем dist/version.json. Бандл и файл
  тождественны by construction. Сервер читает client/dist/version.json при старте
  (client-version.ts: resolveClientDistPath вынесен из static.module — единый
  источник пути; readClientBuildVersion → версия или '' на любой ошибке,
  fail-safe). НИКАКИХ Docker/ENV-изменений.
- WS: отдельное событие app-version {version} (НЕ в message/WebSocketEvent —
  член без operation сломал бы union). ws.gateway читает версию в onModuleInit +
  boot-лог ACTIVE/DISABLED; emit в handleConnection (success-ветка, после join).
  ТОЛЬКО per-connect анонс, БЕЗ broadcast (broadcast в кластере → thundering-herd
  reload флота; естественный реконнект покрывает и single-container, и кластер).
- Клиент: listener навешан СИНХРОННО в том же useEffect, что создаёт сокет (до
  connect — иначе гонка с немедленным emit сервера). Чистая decideVersionAction
  (reload|banner|noop; пустая версия → noop fail-safe). Грязная оболочка
  triggerGuardedReload: модульный latch, hidden→немедленный reload, visible→баннер
  + reload-on-hidden {once}, фикс-id закрываемый баннер с кнопкой «Обновить».
- Общий one-shot флаг: reload-guard.ts (hasAutoReloaded/markAutoReloaded над
  существующим chunk-reload-attempted); chunk-load-error-boundary переведён на
  него. Проактивный + реактивный reload делят ОДИН счётчик → максимум одна
  авто-перезагрузка за сессию суммарно; permanent skew / oscillation / disabled
  storage → после первого reload только баннер, петли нет (проверено трассировкой).

Проверка: server jest client-version 7/7; client vitest version-coherence+
reload-guard+guarded-reload 16/16 (coverage 97.9%). Build-proof единого источника:
APP_VERSION=test-A → dist/version.json {"version":"test-A"} + вшито в бандл.
Полный staging-приём (docker build-arg, WS-кадры в DevTools, мульти-таб) — вне
автостенда, шаги приёмки #481 (крит.1-4) на ручную проверку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 02:14:12 +03:00
39 changed files with 2019 additions and 719 deletions
+1
View File
@@ -463,6 +463,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, `apps/server` (#345), and `apps/client` (#347) — do NOT reintroduce a per-package copy. The client uses the package's `browser` entry (`@docmost/prosemirror-markdown/browser`): markdown paste (`markdown-clipboard.ts`), copy-as-markdown, and AI-chat rendering now all go through the canonical converter, so the hand-written `marked`/`turndown` markdown layer that used to live in `editor-ext` was deleted (#347). The browser entry runs the HTML→DOM stage on the native `DOMParser`, so jsdom stays out of the client bundle. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
- The build also emits `client/dist/version.json` (`{"version": …}`) from a small `vite.config.ts` plugin using the **same** `appVersion` that feeds `define.APP_VERSION`, so the file and the baked-in bundle version are identical by construction. The server reads it at startup (`ws.gateway.ts` via `readClientBuildVersion`/`resolveClientDistPath`) and announces it to each socket on connect (`app-version` event) so a tab left open across a redeploy can guard-reload before hitting a stale chunk (version-coherence). No runtime env / Dockerfile change — the file already ships in `client/dist`; missing/empty file ⇒ feature inert.
## Conventions
+32 -1
View File
@@ -129,6 +129,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **A drifted comment suggestion can be re-synced instead of failing forever
with a 409.** A suggestion whose stored anchor no longer matched the live
document used to reject every apply attempt with an unrecoverable conflict; a
new resync path re-reads the live anchor so the suggestion applies against the
current text, and orphaned anchors (whose marked run was deleted) are
reconciled rather than left blocking. (#496)
- **Open tabs pick up a new deploy on their own.** After the server is
redeployed while a tab is left open for hours, the tab now learns the new
build version over the existing WebSocket (announced per-connect, so a natural
reconnect delivers it) and shows a "A new version is available" banner with an
Update button. To avoid dropping a half-written comment or form, the tab is
not reloaded when you merely switch away from it; instead it auto-reloads at
the next safe point — the next in-app navigation (or immediately if you click
Update) — before it can hit a stale lazy-loaded chunk. At most one automatic
reload happens per browser session (shared with the existing chunk-load
recovery), so a permanent version skew degrades to the banner rather than a
reload loop. When the build carries no version info the feature stays inert.
- **Place several images side by side in a row.** A new "Inline (side by
side)" alignment mode in the image bubble menu renders consecutive inline
images as a row that wraps onto the next line on narrow screens. The row is
@@ -352,7 +370,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
body-timeout so a legitimate >1-min idle between the model's tool calls no
longer breaks a long-lived SSE socket (new `AI_MCP_SSE_BODY_TIMEOUT_MS`, default
10 min; see `.env.example`). (#489)
- **Decisions on comment suggestions now leave a durable audit record.**
Applying or dismissing a comment suggestion hard-deletes the (childless)
subject comment, so the only surviving trace of who decided what is the audit
event — but the audit trail was wired to a Noop service that silently
swallowed every event. The trail is now DB-backed, so
`comment.suggestion_applied` / `comment.suggestion_dismissed` (and the other
comment-decision events) persist to the `audit` table and can be reviewed
after the comment is gone. A persistence failure is still swallowed with a
warning so it never breaks the originating request. (#496)
- **Applying a comment suggestion no longer strips the replaced run's inline
formatting.** The suggested text was re-inserted carrying only the comment
anchor mark, silently dropping bold/italic/code/link on the affected run; the
prevailing formatting of the replaced run is now carried onto the applied
text. (#496)
- **The server no longer runs out of heap during long autonomous agent runs.** A
new pnpm patch on `ai@6.0.134` stops the SDK from building a cumulative
snapshot of the ENTIRE turn text on every streamed text-delta when no output
@@ -1,4 +1,5 @@
{
"A new version is available": "A new version is available",
"Account": "Account",
"Active": "Active",
"Add": "Add",
@@ -1,4 +1,5 @@
{
"A new version is available": "Доступна новая версия",
"Account": "Аккаунт",
"Active": "Активный",
"Add": "Добавить",
@@ -1,8 +1,11 @@
import { ReactNode } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { Button, Center, Stack, Text } from "@mantine/core";
const RELOAD_FLAG = "chunk-reload-attempted";
import {
hasAutoReloaded,
markAutoReloaded,
recordReloadBreadcrumb,
} from "@/lib/reload-guard";
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
@@ -25,16 +28,15 @@ function handleError(error: unknown) {
if (!isChunkLoadError(error)) return;
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
// the new chunk manifest. Auto-reload once, guarding against a reload loop
// (e.g. a genuinely missing chunk) with a one-shot sessionStorage flag. If the
// flag is already set we fall through to the manual recovery UI below.
try {
if (sessionStorage.getItem(RELOAD_FLAG)) return;
sessionStorage.setItem(RELOAD_FLAG, "1");
} catch {
// sessionStorage unavailable (private mode / disabled): skip the automatic
// reload rather than risk an unguarded loop; the fallback UI still recovers.
return;
}
// (e.g. a genuinely missing chunk) with the shared one-shot session flag
// (see @/lib/reload-guard — shared with the proactive version-coherence
// path). If it is already set, or the write fails (storage unavailable), we
// fall through to the manual recovery UI below rather than risk a loop.
if (hasAutoReloaded()) return;
if (!markAutoReloaded()) return;
// Trace before the reload clears the console (same diagnostic breadcrumb the
// proactive version-coherence path writes, tagged with this path).
recordReloadBreadcrumb({ path: "chunk-boundary" });
window.location.reload();
}
@@ -0,0 +1,195 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, act, cleanup } from "@testing-library/react";
import { MemoryRouter, useNavigate } from "react-router-dom";
// Mocks for the dirty shell's side-effecting collaborators.
vi.mock("@mantine/notifications", () => ({
notifications: { show: vi.fn() },
}));
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
vi.mock("@/lib/reload-guard", () => ({
hasAutoReloaded: vi.fn(() => false),
markAutoReloaded: vi.fn(() => true),
recordReloadBreadcrumb: vi.fn(),
takeReloadBreadcrumb: vi.fn(() => null),
}));
import { notifications } from "@mantine/notifications";
import { hasAutoReloaded, markAutoReloaded } from "@/lib/reload-guard";
import {
triggerGuardedReload,
useVersionReloadOnNavigation,
__resetGuardedReloadForTests,
} from "./guarded-reload";
const show = notifications.show as unknown as ReturnType<typeof vi.fn>;
const mockHasAutoReloaded = hasAutoReloaded as unknown as ReturnType<
typeof vi.fn
>;
const mockMarkAutoReloaded = markAutoReloaded as unknown as ReturnType<
typeof vi.fn
>;
let reload: ReturnType<typeof vi.fn>;
let visibility: DocumentVisibilityState;
// Test harness mounted inside a router: it installs the navigation hook and
// exposes `navigate` so a test can drive an in-app router navigation.
let doNavigate: (to: string) => void;
function Harness() {
useVersionReloadOnNavigation();
const navigate = useNavigate();
doNavigate = navigate;
return null;
}
function mountHarness() {
render(
<MemoryRouter initialEntries={["/start"]}>
<Harness />
</MemoryRouter>,
);
}
function navigateTo(path: string) {
act(() => {
doNavigate(path);
});
}
beforeEach(() => {
__resetGuardedReloadForTests();
vi.clearAllMocks();
mockHasAutoReloaded.mockReturnValue(false);
mockMarkAutoReloaded.mockReturnValue(true);
vi.stubGlobal("APP_VERSION", "test-A");
reload = vi.fn();
Object.defineProperty(window, "location", {
configurable: true,
value: { reload },
});
visibility = "visible";
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => visibility,
});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe("triggerGuardedReload (variant C)", () => {
it("noop when versions match: no banner, no reload", () => {
triggerGuardedReload("test-A");
expect(show).not.toHaveBeenCalled();
expect(reload).not.toHaveBeenCalled();
});
it("noop when the server version is empty (fail-safe)", () => {
triggerGuardedReload("");
triggerGuardedReload(undefined);
expect(show).not.toHaveBeenCalled();
expect(reload).not.toHaveBeenCalled();
});
it("real mismatch shows the banner but does NOT reload immediately", () => {
mountHarness();
triggerGuardedReload("test-B");
expect(show).toHaveBeenCalledTimes(1);
expect(show.mock.calls[0][0]).toMatchObject({
id: "app-version-reload",
autoClose: false,
withCloseButton: true,
});
expect(reload).not.toHaveBeenCalled();
});
it("reloads EXACTLY ONCE on the first in-app navigation after a mismatch", () => {
mountHarness();
triggerGuardedReload("test-B");
expect(reload).not.toHaveBeenCalled();
navigateTo("/next");
expect(reload).toHaveBeenCalledTimes(1);
// A second navigation must NOT reload again (one-shot was consumed).
navigateTo("/again");
expect(reload).toHaveBeenCalledTimes(1);
});
it("does NOT reload on a 2nd mismatch after the first was armed/consumed (one-shot)", () => {
mountHarness();
triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).toHaveBeenCalledTimes(1);
// Another app-version mismatch arrives (reconnect): must not re-arm.
triggerGuardedReload("test-C");
navigateTo("/again");
expect(reload).toHaveBeenCalledTimes(1);
});
it("does NOT reload merely from the tab going to the background", () => {
mountHarness();
triggerGuardedReload("test-B");
expect(reload).not.toHaveBeenCalled();
visibility = "hidden";
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(reload).not.toHaveBeenCalled();
});
it("a hidden-at-receipt tab is also NOT reloaded immediately (variant C uniform); reloads on next navigation", () => {
visibility = "hidden";
mountHarness();
triggerGuardedReload("test-B");
expect(reload).not.toHaveBeenCalled();
expect(show).toHaveBeenCalledTimes(1);
navigateTo("/next");
expect(reload).toHaveBeenCalledTimes(1);
});
it("the banner's Update button reloads immediately", () => {
triggerGuardedReload("test-B");
const message = show.mock.calls[0][0].message as {
props: { onClick: () => void };
};
message.props.onClick();
expect(reload).toHaveBeenCalledTimes(1);
});
it("banner-only (auto-reload already spent): banner, never auto-reload on navigation", () => {
mockHasAutoReloaded.mockReturnValue(true);
mountHarness();
triggerGuardedReload("test-B");
expect(show).toHaveBeenCalledTimes(1);
navigateTo("/next");
expect(reload).not.toHaveBeenCalled();
});
it("does NOT reload when the flag write fails; falls back to the banner", () => {
mockMarkAutoReloaded.mockReturnValue(false);
mountHarness();
triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).not.toHaveBeenCalled();
// performAutoReload falls back to showing the banner (initial + fallback).
expect(show).toHaveBeenCalled();
});
it("is idempotent within a tab-load: repeated emits do not stack banners", () => {
triggerGuardedReload("test-B");
triggerGuardedReload("test-B");
triggerGuardedReload("test-C");
expect(show).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,187 @@
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { Button } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import i18n from "@/i18n.ts";
import {
hasAutoReloaded,
markAutoReloaded,
recordReloadBreadcrumb,
takeReloadBreadcrumb,
} from "@/lib/reload-guard";
import { decideVersionAction } from "@/features/user/version-coherence";
// Dirty shell around the pure `decideVersionAction`: it reads globals
// (APP_VERSION), touches sessionStorage via the shared reload-guard, drives the
// Mantine notification, and arms the router-navigation reload hook. Kept
// separate from the pure module so the decision stays unit-testable without a
// DOM.
// One fixed id so repeated app-version signals (e.g. every reconnect) update a
// single banner instead of stacking a new one each time.
const BANNER_ID = "app-version-reload";
// Module-level idempotency for the current tab-load: once a mismatch has been
// handled we don't re-arm the navigation reload or re-show the banner on
// subsequent app-version emits.
let handled = false;
// Variant C: on a real mismatch we do NOT reload the tab when it merely goes to
// the background (that would silently drop a half-written comment/form). Instead
// we arm a one-shot reload for the NEXT in-app router navigation — a point where
// the user is already leaving the current page, so an in-app navigation would
// discard that unsaved component-state anyway and the reload adds no extra loss.
let pendingNavReload = false;
// Remembered from the last detected mismatch for the pre-reload breadcrumb and
// the (already-visible) banner.
let lastServerVersion = "";
let lastClientVersion = "";
// Read the build version baked into THIS bundle. The `typeof` guard avoids a
// ReferenceError where the `APP_VERSION` global is absent (e.g. under vitest,
// where Vite's `define` did not run) — an unknown client version makes the
// pure decision no-op (fail-safe).
function readClientVersion(): string {
return (typeof APP_VERSION !== "undefined" ? APP_VERSION : "").trim();
}
// Perform the actual reload — but only after the shared one-shot flag is
// persisted. If the write fails (storage unavailable) we must NOT reload
// (mirrors the reactive chunk-load boundary's `catch → return`), and fall back
// to the manual banner so the user can still recover.
function performAutoReload(): void {
if (!markAutoReloaded()) {
showReloadBanner();
return;
}
// Trace right before the reload (which clears the console): a persistent
// breadcrumb + a log line so the auto-reload is observable in a field report.
recordReloadBreadcrumb({
path: "proactive",
serverVersion: lastServerVersion,
clientVersion: lastClientVersion,
});
console.warn(
`[version-coherence] auto-reloading: client=${lastClientVersion} -> server=${lastServerVersion}`,
);
window.location.reload();
}
function showReloadBanner(): void {
notifications.show({
id: BANNER_ID,
title: i18n.t("A new version is available"),
message: (
<Button size="xs" mt="xs" onClick={() => performAutoReload()}>
{i18n.t("Update")}
</Button>
),
autoClose: false,
withCloseButton: true,
});
}
/**
* Handle a server `app-version` announcement: compare it to this bundle's
* version and, on a real mismatch, show the banner and arm a guarded reload for
* the next in-app navigation (variant C).
*
* - real mismatch (first this session) banner + arm navigation reload. The
* banner's "Update" button reloads immediately (same one-shot guard). The tab
* is NOT reloaded on visibility change.
* - auto-reload already used / storage error banner only (no arm), so there is
* at most one automatic reload per session (loop safety).
* - in sync / unknown version noop (fail-safe).
*/
export function triggerGuardedReload(
rawServerVersion: string | undefined | null,
): void {
const serverVersion = (rawServerVersion ?? "").trim();
const clientVersion = readClientVersion();
// A storage read error surfaces as autoReloadUsed=true → fail toward NOT
// reloading (banner only).
const autoReloadUsed = hasAutoReloaded();
const action = decideVersionAction({
serverVersion,
clientVersion,
autoReloadUsed,
});
if (action === "noop") return;
// Idempotent per tab-load: don't re-arm or re-stack the banner across repeated
// emits (reconnects) once we've already acted.
if (handled) return;
handled = true;
lastServerVersion = serverVersion;
lastClientVersion = clientVersion;
if (action === "banner") {
// Entered banner-only (permanent skew, node oscillation, or spent
// auto-reload). Log for diagnosability; show the manual banner.
console.warn(
`[version-coherence] server=${serverVersion} client=${clientVersion}: ` +
"auto-reload already spent this session — showing manual banner",
);
showReloadBanner();
return;
}
// action === "reload" (variant C): show the banner and defer the auto-reload
// to the next in-app navigation instead of reloading now / on visibility.
showReloadBanner();
pendingNavReload = true;
}
/**
* Consume the armed one-shot navigation reload, if any. Called by
* `useVersionReloadOnNavigation` on each in-app router navigation.
*/
export function consumeNavigationReload(): void {
if (!pendingNavReload) return;
pendingNavReload = false;
performAutoReload();
}
/**
* Hook (mounted inside the Router) that fires the armed one-shot reload on the
* NEXT in-app router navigation after a version mismatch. Skips the initial
* render so it only reacts to real navigations, not the first location.
*/
export function useVersionReloadOnNavigation(): void {
const location = useLocation();
const firstRender = useRef(true);
useEffect(() => {
if (firstRender.current) {
firstRender.current = false;
return;
}
consumeNavigationReload();
}, [location.key]);
}
/**
* Surface (log once) the breadcrumb left by an auto-reload in the previous page
* load the reload cleared the console, so this makes a "tab reloaded itself"
* report diagnosable. Call once on app startup.
*/
export function surfacePreviousReloadBreadcrumb(): void {
const crumb = takeReloadBreadcrumb();
if (!crumb) return;
console.info(
`[version-coherence] previous auto-reload: path=${crumb.path} ` +
`client=${crumb.clientVersion ?? ""} -> server=${crumb.serverVersion ?? ""} ` +
`at=${new Date(crumb.at).toISOString()}`,
);
}
// Test-only: reset module-level latches between cases.
export function __resetGuardedReloadForTests(): void {
handled = false;
pendingNavReload = false;
lastServerVersion = "";
lastClientVersion = "";
}
@@ -13,6 +13,12 @@ import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { Error404 } from "@/components/ui/error-404.tsx";
import { queryClient } from "@/main.tsx";
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
import {
triggerGuardedReload,
useVersionReloadOnNavigation,
surfacePreviousReloadBreadcrumb,
} from "@/features/user/guarded-reload.tsx";
import type { AppVersionSocketPayload } from "@/features/user/version-coherence.ts";
export function UserProvider({ children }: React.PropsWithChildren) {
const [, setCurrentUser] = useAtom(currentUserAtom);
@@ -22,6 +28,16 @@ export function UserProvider({ children }: React.PropsWithChildren) {
// fetch collab token on load
const { data: collab } = useCollabToken();
// version-coherence: fire the armed one-shot reload on the next in-app
// navigation (variant C — a safe point, not on tab backgrounding).
useVersionReloadOnNavigation();
// Surface any breadcrumb left by an auto-reload in the previous page load
// (the reload cleared the console) so a field report stays diagnosable.
useEffect(() => {
surfacePreviousReloadBreadcrumb();
}, []);
useEffect(() => {
if (isLoading || isError) {
return;
@@ -47,6 +63,16 @@ export function UserProvider({ children }: React.PropsWithChildren) {
handleConnect();
});
// Register the version-coherence listener SYNCHRONOUSLY, before the socket
// connects: the server emits `app-version` immediately in handleConnection,
// so a listener attached after connect would miss it on a fast localhost
// connect. On a version mismatch the client shows a banner and defers the
// auto-reload to the next in-app navigation (variant C — avoids reloading a
// backgrounded tab that may hold unsaved input) before it hits a stale chunk.
newSocket.on("app-version", (payload?: AppVersionSocketPayload) => {
triggerGuardedReload(payload?.version);
});
return () => {
console.log("ws disconnected");
newSocket.disconnect();
@@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest";
import { decideVersionAction } from "./version-coherence";
describe("decideVersionAction", () => {
it("noop when the server version is empty (fail-safe)", () => {
expect(
decideVersionAction({
serverVersion: "",
clientVersion: "v1",
autoReloadUsed: false,
}),
).toBe("noop");
});
it("noop when the client version is empty (fail-safe)", () => {
expect(
decideVersionAction({
serverVersion: "v1",
clientVersion: "",
autoReloadUsed: false,
}),
).toBe("noop");
});
it("noop when versions are equal (in sync)", () => {
expect(
decideVersionAction({
serverVersion: "v1",
clientVersion: "v1",
autoReloadUsed: false,
}),
).toBe("noop");
});
it("reload on a real mismatch the first time this session", () => {
expect(
decideVersionAction({
serverVersion: "test-B",
clientVersion: "test-A",
autoReloadUsed: false,
}),
).toBe("reload");
});
it("banner on a mismatch once the session auto-reload is spent", () => {
expect(
decideVersionAction({
serverVersion: "test-B",
clientVersion: "test-A",
autoReloadUsed: true,
}),
).toBe("banner");
});
it("equal versions stay noop even if auto-reload was already used", () => {
expect(
decideVersionAction({
serverVersion: "v1",
clientVersion: "v1",
autoReloadUsed: true,
}),
).toBe("noop");
});
});
@@ -0,0 +1,32 @@
// Payload of the per-connect `app-version` socket.io event announced by the
// server (ws.gateway.ts) after a successful auth. A dedicated event — NOT a
// member of the room-scoped `WebSocketEvent` union (which is discriminated by
// `operation`), so it never touches use-query-subscription.
export type AppVersionSocketPayload = { version: string };
/**
* Pure decision for the version-coherence guard.
*
* All inputs are injected (no globals, no side effects) so it is unit-testable
* without a DOM or the build-time `APP_VERSION` global (undefined under vitest).
*
* - `autoReloadUsed` = a session-wide automatic reload has already happened,
* so we must not auto-reload again (loop safety, shared with the reactive
* chunk-load boundary).
*
* Returns:
* - "noop" do nothing (unknown version on either side, or already in sync).
* - "banner" show the manual "update available" banner only (no auto-reload).
* - "reload" real first-time mismatch: eligible for a guarded auto-reload.
*/
export function decideVersionAction(args: {
serverVersion: string;
clientVersion: string;
autoReloadUsed: boolean;
}): "reload" | "banner" | "noop" {
const { serverVersion, clientVersion, autoReloadUsed } = args;
if (!serverVersion || !clientVersion) return "noop"; // fail-safe: unknown version → never act
if (serverVersion === clientVersion) return "noop"; // in sync
if (autoReloadUsed) return "banner"; // one auto-reload per session already spent
return "reload"; // real mismatch, first time this session
}
+97
View File
@@ -0,0 +1,97 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
hasAutoReloaded,
markAutoReloaded,
recordReloadBreadcrumb,
takeReloadBreadcrumb,
} from "./reload-guard";
const FLAG = "chunk-reload-attempted";
describe("reload-guard", () => {
beforeEach(() => {
sessionStorage.clear();
vi.restoreAllMocks();
});
afterEach(() => {
sessionStorage.clear();
vi.restoreAllMocks();
});
it("hasAutoReloaded is false before any reload, true after mark", () => {
expect(hasAutoReloaded()).toBe(false);
expect(markAutoReloaded()).toBe(true);
expect(hasAutoReloaded()).toBe(true);
// Uses the same key the reactive chunk-load boundary reads.
expect(sessionStorage.getItem(FLAG)).toBe("1");
});
it("hasAutoReloaded returns true when reading storage throws (fail toward not reloading)", () => {
vi.stubGlobal("sessionStorage", {
getItem: () => {
throw new Error("storage disabled");
},
setItem: () => {
throw new Error("storage disabled");
},
});
try {
expect(hasAutoReloaded()).toBe(true);
} finally {
vi.unstubAllGlobals();
}
});
it("markAutoReloaded returns false when writing storage throws", () => {
vi.stubGlobal("sessionStorage", {
getItem: () => null,
setItem: () => {
throw new Error("storage disabled");
},
});
try {
expect(markAutoReloaded()).toBe(false);
} finally {
vi.unstubAllGlobals();
}
});
it("records and then takes a breadcrumb once (cleared on read)", () => {
recordReloadBreadcrumb({
path: "proactive",
serverVersion: "test-B",
clientVersion: "test-A",
});
const crumb = takeReloadBreadcrumb();
expect(crumb).toMatchObject({
path: "proactive",
serverVersion: "test-B",
clientVersion: "test-A",
});
expect(typeof crumb?.at).toBe("number");
// Cleared on read → a second take returns null.
expect(takeReloadBreadcrumb()).toBeNull();
});
it("takeReloadBreadcrumb returns null when nothing was recorded", () => {
expect(takeReloadBreadcrumb()).toBeNull();
});
it("recordReloadBreadcrumb swallows a storage-write error (diagnostics only)", () => {
vi.stubGlobal("sessionStorage", {
getItem: () => null,
setItem: () => {
throw new Error("storage disabled");
},
removeItem: () => {},
});
try {
expect(() =>
recordReloadBreadcrumb({ path: "chunk-boundary" }),
).not.toThrow();
} finally {
vi.unstubAllGlobals();
}
});
});
+90
View File
@@ -0,0 +1,90 @@
// Shared one-shot auto-reload guard.
//
// Both auto-reload paths — the reactive chunk-load-error-boundary (recovers
// AFTER a stale lazy chunk 404s) and the proactive version-coherence feature
// (reloads BEFORE the tab hits a stale chunk) — go through these functions so
// they share ONE session-scoped flag. Net guarantee: at most a single
// automatic reload per browser session across both paths. Once the flag is set
// (or sessionStorage is unavailable), every further mismatch degrades to a
// manual banner/UI — no reload loop under permanent skew, node oscillation, or
// a disabled storage.
const RELOAD_FLAG = "chunk-reload-attempted";
/**
* Has an automatic reload already been performed (or attempted) this session?
*
* A storage read error (private mode / disabled) is reported as `true` so the
* caller fails toward NOT reloading an unguarded loop is worse than a stale
* tab the user can reload manually.
*/
export function hasAutoReloaded(): boolean {
try {
return sessionStorage.getItem(RELOAD_FLAG) !== null;
} catch {
return true;
}
}
/**
* Record that an automatic reload is being performed this session.
*
* Returns whether the write succeeded. A `false` return (storage unavailable)
* means the caller MUST NOT reload otherwise the flag would never stick and
* the reload could loop.
*/
export function markAutoReloaded(): boolean {
try {
sessionStorage.setItem(RELOAD_FLAG, "1");
return true;
} catch {
return false;
}
}
// Diagnostic breadcrumb for an automatic reload. Written right before
// window.location.reload() (which clears the console) and read back on the next
// page load, so a "the tab reloaded itself / it's looping" field report is
// diagnosable: which path fired (proactive version-coherence vs the reactive
// chunk-load boundary) and which version pair triggered it. sessionStorage
// survives a same-tab reload, unlike the console.
const RELOAD_BREADCRUMB_KEY = "reload-breadcrumb";
export type ReloadBreadcrumb = {
path: "proactive" | "chunk-boundary";
serverVersion?: string;
clientVersion?: string;
at: number;
};
/**
* Persist a best-effort breadcrumb just before an automatic reload. Failures
* (storage unavailable) are swallowed this is diagnostics only and must never
* block or alter the reload decision.
*/
export function recordReloadBreadcrumb(
entry: Omit<ReloadBreadcrumb, "at">,
): void {
try {
sessionStorage.setItem(
RELOAD_BREADCRUMB_KEY,
JSON.stringify({ ...entry, at: Date.now() }),
);
} catch {
// best-effort diagnostics only
}
}
/**
* Read and clear the breadcrumb left by an auto-reload in the previous page
* load. Cleared on read so it surfaces exactly once per reload.
*/
export function takeReloadBreadcrumb(): ReloadBreadcrumb | null {
try {
const raw = sessionStorage.getItem(RELOAD_BREADCRUMB_KEY);
if (!raw) return null;
sessionStorage.removeItem(RELOAD_BREADCRUMB_KEY);
return JSON.parse(raw) as ReloadBreadcrumb;
} catch {
return null;
}
}
+29 -2
View File
@@ -1,7 +1,8 @@
import { defineConfig, loadEnv } from "vite";
import { defineConfig, loadEnv, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import { compression } from "vite-plugin-compression2";
import * as path from "path";
import * as fs from "node:fs";
import { execSync } from "node:child_process";
const envPath = path.resolve(process.cwd(), "..", "..");
@@ -24,7 +25,32 @@ function resolveAppVersion(cwd: string): string {
}
}
// Emit <outDir>/version.json = { "version": appVersion } so the server can read
// the exact same build id the bundle was compiled with. The value is the SAME
// `appVersion` fed into `define.APP_VERSION`, so version.json and the baked-in
// global are identical by construction — the single source of truth (no
// runtime-env second copy that could drift and cause a false version mismatch).
function versionJsonPlugin(version: string): Plugin {
let outDir = "dist";
return {
name: "emit-version-json",
apply: "build",
configResolved(config) {
outDir = config.build.outDir;
},
writeBundle() {
const root = path.resolve(process.cwd(), outDir);
fs.mkdirSync(root, { recursive: true });
fs.writeFileSync(
path.join(root, "version.json"),
JSON.stringify({ version }),
);
},
};
}
export default defineConfig(({ mode }) => {
const appVersion = resolveAppVersion(envPath);
const {
APP_URL,
FILE_UPLOAD_SIZE_LIMIT,
@@ -52,10 +78,11 @@ export default defineConfig(({ mode }) => {
POSTHOG_HOST,
POSTHOG_KEY,
},
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
APP_VERSION: JSON.stringify(appVersion),
},
plugins: [
react(),
versionJsonPlugin(appVersion),
// Emit .br and .gz next to every built asset so the server can serve the
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
compression({
+2 -2
View File
@@ -25,7 +25,7 @@ import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis';
import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
import { AuditModule } from './integrations/audit/audit.module';
import { ThrottleModule } from './integrations/throttle/throttle.module';
import { McpModule } from './integrations/mcp/mcp.module';
import { SandboxModule } from './integrations/sandbox/sandbox.module';
@@ -55,7 +55,7 @@ try {
middleware: { mount: true },
}),
LoggerModule,
NoopAuditModule,
AuditModule,
CoreModule,
DatabaseModule,
EnvironmentModule,
@@ -529,4 +529,107 @@ describe('replaceYjsMarkedText', () => {
expect(result).toEqual({ applied: false, currentText: 'abcdef' });
expect(text.toDelta()).toEqual(before);
});
// #496: apply must NOT silently strip the replaced run's inline formatting.
// Build a paragraph and format the marked range with extra marks, then assert
// the replacement carries them.
function buildFormatted(
runs: Array<{ text: string; attrs?: Record<string, any> }>,
): { fragment: Y.XmlFragment; text: Y.XmlText } {
const ydoc = new Y.Doc();
const fragment = ydoc.getXmlFragment('default');
const para = new Y.XmlElement('paragraph');
fragment.insert(0, [para]);
const text = new Y.XmlText();
para.insert(0, [text]);
text.insert(0, runs.map((r) => r.text).join(''));
let offset = 0;
for (const run of runs) {
if (run.attrs) text.format(offset, run.text.length, run.attrs);
offset += run.text.length;
}
return { fragment, text };
}
it('preserves the original run formatting (bold + link) on the replacement', () => {
const { fragment, text } = buildFormatted([
{ text: 'see ' },
{
text: 'old',
attrs: {
comment: { commentId: 'c1', resolved: false },
bold: true,
link: { href: 'https://x.test' },
},
},
{ text: ' end' },
]);
const result = replaceYjsMarkedText(fragment, 'c1', 'old', 'new');
expect(result).toEqual({ applied: true, currentText: 'new' });
// The comment anchor AND the bold/link marks survive the delete+insert.
expect(text.toDelta()).toEqual([
{ insert: 'see ' },
{
insert: 'new',
attributes: {
comment: { commentId: 'c1', resolved: false },
bold: true,
link: { href: 'https://x.test' },
},
},
{ insert: ' end' },
]);
});
it('mixed formatting under the mark: replacement takes the DOMINANT (longest) run, NOT the leading one', () => {
// Leading run is SHORT + plain ("x", 1 char); the following run is LONGER +
// bold ("bolded", 6 chars), same commentId. The longest run is deliberately
// NOT first: a "first-wins" pick would carry plain (no bold), so asserting
// bold on the result only holds if the code genuinely selects the LONGEST run.
const { fragment, text } = buildFormatted([
{ text: 'x', attrs: { comment: { commentId: 'c1', resolved: false } } },
{
text: 'bolded',
attrs: { comment: { commentId: 'c1', resolved: false }, bold: true },
},
]);
const result = replaceYjsMarkedText(fragment, 'c1', 'xbolded', 'Z');
expect(result).toEqual({ applied: true, currentText: 'Z' });
expect(text.toDelta()).toEqual([
{
insert: 'Z',
attributes: { comment: { commentId: 'c1', resolved: false }, bold: true },
},
]);
});
it('mixed formatting under the mark: on a length tie the FIRST run wins', () => {
// Two equal-length runs (2 chars each) with different formatting, same
// commentId. The reduce keeps the accumulator on a tie, so the FIRST run
// (italic) prevails over the later bold one.
const { fragment, text } = buildFormatted([
{
text: 'AA',
attrs: { comment: { commentId: 'c1', resolved: false }, italic: true },
},
{
text: 'BB',
attrs: { comment: { commentId: 'c1', resolved: false }, bold: true },
},
]);
const result = replaceYjsMarkedText(fragment, 'c1', 'AABB', 'Z');
expect(result).toEqual({ applied: true, currentText: 'Z' });
expect(text.toDelta()).toEqual([
{
insert: 'Z',
attributes: { comment: { commentId: 'c1', resolved: false }, italic: true },
},
]);
});
});
+20 -5
View File
@@ -145,6 +145,10 @@ type MarkedSegment = {
length: number;
text: string;
markAttrs: Record<string, any>;
// The FULL attribute set of this delta run — the `comment` mark plus any
// inline formatting (bold/italic/code/link/…). Captured so apply can carry the
// original run's formatting onto the replacement instead of dropping it.
attributes: Record<string, any>;
};
/**
@@ -202,6 +206,7 @@ export function replaceYjsMarkedText(
length,
text: insert,
markAttrs: markAttr,
attributes,
});
}
offset += length;
@@ -251,15 +256,25 @@ export function replaceYjsMarkedText(
return { applied: false, currentText: joinedText };
}
// 3. All guards passed: delete the marked run and re-insert newText with the
// same comment attributes at the same offset. Atomic within the caller's
// transaction.
// 3. All guards passed: delete the marked run and re-insert newText at the
// same offset. Atomic within the caller's transaction.
const start = segments[0].offset;
const len = segments.reduce((sum, s) => sum + s.length, 0);
const markAttrs = segments[0].markAttrs;
// Carry the ORIGINAL run's formatting onto the replacement (#496): inserting
// with only the `comment` mark silently dropped bold/italic/code/link of the
// replaced text. Yjs applies one flat attribute set to the whole insert, so
// when the marked run mixes formatting we pick the DOMINANT segment (the one
// covering the most characters) and apply its attributes — a v1 that preserves
// the common single-format case exactly and, for a mixed run, keeps the
// prevailing style rather than losing all of it. `attributes` already carries
// the `comment` mark (every collected segment is filtered on it above), so the
// anchor is preserved by copying the run's attribute set verbatim.
const dominant = segments.reduce((a, b) => (b.length > a.length ? b : a));
const insertAttrs = { ...dominant.attributes };
node.delete(start, len);
node.insert(start, newText, { comment: markAttrs });
node.insert(start, newText, insertAttrs);
return { applied: true, currentText: newText };
}
@@ -0,0 +1,52 @@
import { join } from 'path';
import * as fs from 'node:fs';
import * as os from 'node:os';
import { readClientBuildVersion } from './client-version';
describe('readClientBuildVersion', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(join(os.tmpdir(), 'client-version-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
const writeVersionJson = (content: string) =>
fs.writeFileSync(join(dir, 'version.json'), content);
it('returns the version from a valid version.json', () => {
writeVersionJson(JSON.stringify({ version: 'test-A' }));
expect(readClientBuildVersion(dir)).toBe('test-A');
});
it('trims surrounding whitespace in the version', () => {
writeVersionJson(JSON.stringify({ version: ' v1.2.3 ' }));
expect(readClientBuildVersion(dir)).toBe('v1.2.3');
});
it('returns "" when version.json is missing', () => {
expect(readClientBuildVersion(dir)).toBe('');
});
it('returns "" on malformed JSON', () => {
writeVersionJson('{ not json');
expect(readClientBuildVersion(dir)).toBe('');
});
it('returns "" when the version field is absent', () => {
writeVersionJson(JSON.stringify({ notVersion: 'x' }));
expect(readClientBuildVersion(dir)).toBe('');
});
it('returns "" when the version field is not a string', () => {
writeVersionJson(JSON.stringify({ version: 123 }));
expect(readClientBuildVersion(dir)).toBe('');
});
it('returns "" when the path does not exist at all', () => {
expect(readClientBuildVersion(join(dir, 'nope'))).toBe('');
});
});
@@ -0,0 +1,36 @@
import { join } from 'path';
import * as fs from 'node:fs';
/**
* Resolve the absolute path to the built client bundle directory
* (`apps/client/dist`) shipped into the runtime image.
*
* The `../` depth is anchored on THIS module's compiled location
* (`dist/common/helpers`). `integrations/static` sits at the same depth under
* the compiled root, so both callers (StaticModule and readClientBuildVersion)
* MUST share this single helper rather than duplicating the depth a copy in a
* module at a different depth would silently resolve to the wrong directory.
*/
export function resolveClientDistPath(): string {
return join(__dirname, '..', '..', '..', '..', 'client/dist');
}
/**
* Read the build version the client bundle was compiled with, from
* `<clientDistPath>/version.json` (written by the Vite build the single
* source of truth shared by the baked-in `APP_VERSION` global and this file).
*
* Fail-safe: any error (missing file, unreadable, bad JSON, non-string
* version) yields `''`. The caller treats an empty version as "unknown" and
* the whole version-coherence feature stays silently inert existing deploys
* without the file keep working unchanged.
*/
export function readClientBuildVersion(clientDistPath: string): string {
try {
const raw = fs.readFileSync(join(clientDistPath, 'version.json'), 'utf8');
const version = (JSON.parse(raw) as { version?: unknown }).version;
return typeof version === 'string' ? version.trim() : '';
} catch {
return '';
}
}
+1
View File
@@ -3,3 +3,4 @@ export * from './nanoid.utils';
export * from './file.helper';
export * from './constants';
export * from './security-headers';
export * from './client-version';
@@ -16,6 +16,7 @@ import { UpdateCommentDto } from './dto/update-comment.dto';
import { ResolveCommentDto } from './dto/resolve-comment.dto';
import { ApplySuggestionDto } from './dto/apply-suggestion.dto';
import { DismissSuggestionDto } from './dto/dismiss-suggestion.dto';
import { ResyncSuggestionAnchorDto } from './dto/resync-suggestion-anchor.dto';
import { PageIdDto, CommentIdDto } from './dto/comments.input';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
@@ -235,6 +236,39 @@ export class CommentController {
return this.commentService.applySuggestion(comment, user, provenance);
}
@HttpCode(HttpStatus.OK)
@Post('resync-suggestion-anchor')
async resyncSuggestionAnchor(
@Body() dto: ResyncSuggestionAnchorDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const comment = await this.commentRepo.findById(dto.commentId, {
includeCreator: true,
includeResolvedBy: true,
});
if (!comment) {
throw new NotFoundException('Comment not found');
}
const page = await this.pageRepo.findById(comment.pageId);
if (!page || page.deletedAt) {
throw new NotFoundException('Page not found');
}
// Authorize BEFORE revealing structural detail (mirrors apply/dismiss).
// Re-anchoring does NOT change the page text — it only corrects the stored
// selection metadata — so the page-level gate is comment access. The service
// further restricts it to the suggestion's own author.
await this.pageAccessService.validateCanComment(page, user, workspace.id);
return this.commentService.resyncSuggestionAnchor(
comment,
dto.selection,
user,
);
}
@HttpCode(HttpStatus.OK)
@Post('dismiss-suggestion')
async dismissSuggestion(
@@ -146,11 +146,19 @@ describe('CommentService — applySuggestion', () => {
'page-1',
expect.objectContaining({ operation: 'commentDeleted', commentId: 'c-1' }),
);
// #496: hard-deleted row → the audit payload is the only surviving record.
expect(auditService.log).toHaveBeenCalledWith(
expect.objectContaining({
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
metadata: expect.objectContaining({
pageId: 'page-1',
suggestedText: 'new text',
selection: 'old text',
commentAuthor: 'user-1',
decidedBy: 'user-1',
}),
}),
);
expect(result.outcome).toBe('deleted');
@@ -189,17 +197,25 @@ describe('CommentService — applySuggestion', () => {
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
expect(resolvePatch.resolvedById).toBe('user-1');
// NOT deleted; broadcast an update, not a deletion.
// NOT deleted.
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
'deleteCommentMark',
expect.anything(),
expect.anything(),
);
// #496 dedup: resolveComment broadcasts `commentResolved` with the enriched
// row; finalize must NOT ALSO emit a redundant `commentUpdated`. So the
// thread receives exactly ONE resolve broadcast and no update broadcast.
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
'space-1',
'page-1',
expect.objectContaining({ operation: 'commentUpdated', comment: UPDATED }),
expect.objectContaining({ operation: 'commentResolved', comment: UPDATED }),
);
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
'space-1',
'page-1',
expect.objectContaining({ operation: 'commentUpdated' }),
);
expect(auditService.log).toHaveBeenCalledWith(
@@ -211,6 +227,36 @@ describe('CommentService — applySuggestion', () => {
expect(result.outcome).toBe('resolved');
});
it('re-entry: already applied+resolved WITH replies → emits commentUpdated (dedup does not over-suppress)', async () => {
// suggestionAppliedAt set → idempotent finalize; resolvedAt set → resolveComment
// is skipped, so there is NO commentResolved broadcast. The applied-stamp state
// must still reach clients via a single commentUpdated.
const { service, wsService } = makeService(
{ applied: false, currentText: 'new text' },
true,
);
await service.applySuggestion(
suggestionComment({
suggestionAppliedAt: new Date(),
resolvedAt: new Date(),
}),
user(),
);
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
'space-1',
'page-1',
expect.objectContaining({ operation: 'commentUpdated', comment: UPDATED }),
);
// Nothing resolved this time (already resolved) → no resolve broadcast.
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
'space-1',
'page-1',
expect.objectContaining({ operation: 'commentResolved' }),
);
});
// --- error / rejection branches -----------------------------------------
it('applied=false and currentText differs → ConflictException with currentText in payload', async () => {
@@ -107,11 +107,21 @@ describe('CommentService — dismissSuggestion', () => {
'page-1',
expect.objectContaining({ operation: 'commentDeleted', commentId: 'c-1' }),
);
// #496: the row is hard-deleted, so the audit payload must carry the
// decision's substance (what was suggested, the anchored text, who authored
// it, who decided) — it is the only surviving record.
expect(auditService.log).toHaveBeenCalledWith(
expect.objectContaining({
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
metadata: expect.objectContaining({
pageId: 'page-1',
suggestedText: 'new text',
selection: 'old text',
commentAuthor: 'user-1',
decidedBy: 'user-1',
}),
}),
);
expect(result.outcome).toBe('deleted');
@@ -0,0 +1,126 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { CommentService } from './comment.service';
/**
* Coverage for CommentService.resyncSuggestionAnchor (#496): re-anchoring a
* suggestion's stored selection (== apply-time expectedText) to the live-doc
* substring. The service is built directly with jest-mocked deps (the
* @InjectQueue tokens can't be resolved by Test.createTestingModule see the
* sibling specs).
*/
describe('CommentService — resyncSuggestionAnchor', () => {
const UPDATED = { id: 'c-1', selection: 'new anchor', __updated: true } as any;
function makeService() {
const commentRepo: any = {
updateComment: jest.fn(async () => undefined),
findById: jest.fn(async () => UPDATED),
};
const service = new CommentService(
commentRepo,
{} as any,
{ emitCommentEvent: jest.fn() } as any,
{} as any,
{ add: jest.fn() } as any,
{ add: jest.fn() } as any,
{ log: jest.fn() } as any,
);
return { service, commentRepo };
}
const suggestion = (over?: Partial<any>): any => ({
id: 'c-1',
creatorId: 'user-1',
parentCommentId: null,
selection: 'old anchor',
suggestedText: 'new text',
suggestionAppliedAt: null,
resolvedAt: null,
...over,
});
const user = (over?: Partial<any>): any => ({ id: 'user-1', ...over });
it('persists the new selection and returns the enriched comment', async () => {
const { service, commentRepo } = makeService();
const out = await service.resyncSuggestionAnchor(
suggestion(),
'new anchor',
user(),
);
expect(commentRepo.updateComment).toHaveBeenCalledWith(
{ selection: 'new anchor' },
'c-1',
);
expect(out).toBe(UPDATED);
});
it('is idempotent: no write when the anchor already matches', async () => {
const { service, commentRepo } = makeService();
const out = await service.resyncSuggestionAnchor(
suggestion({ selection: 'same' }),
'same',
user(),
);
expect(commentRepo.updateComment).not.toHaveBeenCalled();
expect(out).toEqual(suggestion({ selection: 'same' }));
});
it('rejects a non-author (only the suggestion owner may re-anchor)', async () => {
const { service, commentRepo } = makeService();
await expect(
service.resyncSuggestionAnchor(suggestion(), 'new anchor', user({ id: 'other' })),
).rejects.toBeInstanceOf(ForbiddenException);
expect(commentRepo.updateComment).not.toHaveBeenCalled();
});
it('rejects a reply / a comment with no suggestion', async () => {
const { service } = makeService();
await expect(
service.resyncSuggestionAnchor(
suggestion({ parentCommentId: 'p-1' }),
'x',
user(),
),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.resyncSuggestionAnchor(
suggestion({ suggestedText: null }),
'x',
user(),
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects re-anchoring an already applied or resolved suggestion', async () => {
const { service } = makeService();
await expect(
service.resyncSuggestionAnchor(
suggestion({ suggestionAppliedAt: new Date() }),
'x',
user(),
),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.resyncSuggestionAnchor(
suggestion({ resolvedAt: new Date() }),
'x',
user(),
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a no-op selection equal to the suggested text', async () => {
const { service } = makeService();
await expect(
service.resyncSuggestionAnchor(
suggestion({ suggestedText: 'new text' }),
'new text',
user(),
),
).rejects.toBeInstanceOf(BadRequestException);
});
});
+122 -17
View File
@@ -370,6 +370,76 @@ export class CommentService {
return updatedComment;
}
/**
* Re-sync a suggestion's stored `selection` (== apply-time expectedText) to the
* RAW substring the inline mark actually covers in the LIVE document (#496).
*
* The MCP client creates the comment from a DEBOUNCED REST snapshot, then
* anchors the mark in the live collab doc. When the two disagree (the doc moved
* on in the debounce window) the stored selection no longer equals the marked
* text, so EVERY apply 409s ("the commented text changed"). After anchoring the
* client re-reads the exact marked substring and calls this to store it, making
* apply's strict equality hold.
*
* Only meaningful for an un-settled top-level suggestion authored by the
* caller: applying/resolving freezes the anchor, and a reply-carrying thread is
* preserved rather than mutated. The new text must still differ from the
* suggestion (else "apply" would be a no-op), preserving create()'s invariant.
*/
async resyncSuggestionAnchor(
comment: Comment,
selection: string,
user: User,
): Promise<Comment> {
if (comment.creatorId !== user.id) {
throw new ForbiddenException(
'You can only re-anchor your own suggestion',
);
}
if (comment.parentCommentId) {
throw new BadRequestException(
'Only a top-level comment can carry a suggested edit',
);
}
if (!comment.suggestedText) {
throw new BadRequestException('This comment has no suggested edit');
}
// A settled suggestion's anchor is frozen: re-anchoring an applied/resolved
// thread is meaningless and could resurrect a stale expectedText.
if (comment.suggestionAppliedAt || comment.resolvedAt) {
throw new BadRequestException(
'Cannot re-anchor a suggestion that was already applied or resolved',
);
}
const trimmed = selection.trim();
if (trimmed.length === 0) {
throw new BadRequestException('The re-anchored selection cannot be empty');
}
// Same no-op guard as create(): the suggestion must differ from the text it
// replaces, or apply becomes indistinguishable from already-applied.
if (trimmed === comment.suggestedText.trim()) {
throw new BadRequestException(
'A suggested edit must differ from the selected text',
);
}
// Idempotent: nothing to persist when the anchor already matches.
if (comment.selection === selection) {
return comment;
}
await this.commentRepo.updateComment({ selection }, comment.id);
const updatedComment = await this.commentRepo.findById(comment.id, {
includeCreator: true,
includeResolvedBy: true,
});
// Re-anchoring only corrects stored metadata; it does not change the page
// text or the comment body, so no ws broadcast / notification is warranted.
return updatedComment;
}
/**
* Apply the suggested edit carried by a top-level inline comment: atomically
* replace the text under the comment mark in the collaborative document with
@@ -524,7 +594,7 @@ export class CommentService {
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
metadata: { pageId: comment.pageId },
metadata: this.suggestionAuditMetadata(comment, user),
});
return { ...updatedComment, outcome: 'resolved' };
}
@@ -538,7 +608,7 @@ export class CommentService {
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
metadata: { pageId: comment.pageId },
metadata: this.suggestionAuditMetadata(comment, user),
});
return settled;
}
@@ -577,8 +647,10 @@ export class CommentService {
// Auto-resolve the thread. resolveComment handles the resolve mark, its ws
// broadcast and the resolve notification. Stay defensive on re-entry.
let didResolveBroadcast = false;
if (!comment.resolvedAt) {
await this.resolveComment(comment, true, user, provenance);
didResolveBroadcast = true;
}
const updatedComment = await this.commentRepo.findById(comment.id, {
@@ -586,18 +658,27 @@ export class CommentService {
includeResolvedBy: true,
});
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
operation: 'commentUpdated',
pageId: comment.pageId,
comment: updatedComment,
});
// #496 dedup: resolveComment already broadcast `commentResolved` carrying
// the fully-enriched row (the applied stamps were persisted above, before
// that call, so its re-read reflects them). Emitting `commentUpdated` here
// too made the client receive TWO events for one apply. Broadcast the
// update ONLY when we did NOT resolve — i.e. the rare re-entry on an
// already-resolved thread, where the applied-stamp change still needs a
// broadcast and resolveComment did not run.
if (!didResolveBroadcast) {
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
operation: 'commentUpdated',
pageId: comment.pageId,
comment: updatedComment,
});
}
this.auditService.log({
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
metadata: { pageId: comment.pageId },
metadata: this.suggestionAuditMetadata(comment, user),
});
return { ...updatedComment, outcome: 'resolved' };
@@ -616,7 +697,7 @@ export class CommentService {
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
metadata: { pageId: comment.pageId },
metadata: this.suggestionAuditMetadata(comment, user),
});
return settled;
@@ -627,14 +708,17 @@ export class CommentService {
* inline `comment` anchor mark, then ATOMICALLY hard-delete the row only if it
* is still childless. Shared by the apply/dismiss no-replies branches (#329).
*
* ORDER MATTERS: the anchor mark is removed FIRST and FATALLY (mirrors
* applySuggestion, which mutates the doc before writing the DB). The row
* delete is irreversible, so if the mark removal fails including the
* COLLAB_DISABLE_REDIS "no live instance" hard-error we must NOT delete the
* row and report success, or the document is left with a permanent orphan
* anchor pointing at a comment that no longer exists (the exact data-integrity
* bug #329 targets). Let the exception propagate ( 5xx); the operation is
* then repeatable with row + mark still consistent.
* ORDER MATTERS (updated #399 #496): what runs FIRST and FATALLY here is the
* mark-removal ENQUEUE (a fast, durable Redis add), NOT the mark op itself.
* deleteCommentMark awaits only the enqueue, so a failed add throws BEFORE the
* irreversible row delete the row + mark stay consistent and the operation is
* repeatable. The actual anchor strip then runs off the HTTP path in the worker
* (idempotent, 3 retries). Only an EXHAUSTED-retries job could leave the doc
* with an orphan anchor pointing at a hard-deleted comment (the data-integrity
* bug #329 targets); that residual divergence is now self-healed by the
* resolve/unresolve mark worker, which strips an orphan mark whenever its
* comment row is gone (#496), and it is meanwhile VISIBLE via BullMQ failed-job
* metrics rather than a silently-swallowed warn.
*
* RACE (#338 F4): the caller read `hasChildren` BEFORE the (slow) mark
* removal, so a reply can land in that window. `comments.parent_comment_id` is
@@ -732,6 +816,27 @@ export class CommentService {
return this.generalQueue.add(QueueJob.COMMENT_MARK_UPDATE, jobData);
}
/**
* Build the audit metadata for a suggestion apply/dismiss decision (#496).
* The subject comment is HARD-DELETED on the childless path, so the audit row
* is the only surviving record capture the decision's substance (what was
* suggested, the anchored text it replaced, who authored it, who decided)
* before the row can vanish. `decidedBy` is the acting user; `commentAuthor`
* is the suggestion's creator.
*/
private suggestionAuditMetadata(
comment: Comment,
user: User,
): Record<string, any> {
return {
pageId: comment.pageId,
suggestedText: comment.suggestedText ?? null,
selection: comment.selection ?? null,
commentAuthor: comment.creatorId ?? null,
decidedBy: user.id,
};
}
private async queueCommentNotification(
content: any,
oldMentionIds: string[],
@@ -0,0 +1,20 @@
import { IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
/**
* #496: after the MCP client anchors a suggestion in the LIVE collab doc, it
* re-reads the exact substring under the new mark and syncs it here as the
* comment's stored `selection` (== apply-time expectedText). Fixes the perpetual
* 409 where expectedText came from a debounced REST snapshot while the mark sat
* in the live doc.
*/
export class ResyncSuggestionAnchorDto {
@IsUUID()
commentId: string;
// The raw substring the mark now covers in the live document. Bounded like the
// create-time selection (2000) so a legitimate anchored span is never cut.
@IsString()
@MinLength(1)
@MaxLength(2000)
selection: string;
}
@@ -1,14 +1,19 @@
import { Global, Module } from '@nestjs/common';
import { AUDIT_SERVICE, NoopAuditService } from './audit.service';
import { AUDIT_SERVICE } from './audit.service';
import { DatabaseAuditService } from './database-audit.service';
// #496: bind the audit token to a real DB-backed trail (was NoopAuditService,
// which silently dropped every event). Kysely (@Global DatabaseModule) and
// ClsService (@Global ClsModule) are both globally available, so this module
// needs no extra imports.
@Global()
@Module({
providers: [
{
provide: AUDIT_SERVICE,
useClass: NoopAuditService,
useClass: DatabaseAuditService,
},
],
exports: [AUDIT_SERVICE],
})
export class NoopAuditModule {}
export class AuditModule {}
@@ -0,0 +1,198 @@
import { Logger } from '@nestjs/common';
import { DatabaseAuditService } from './database-audit.service';
import { AUDIT_CONTEXT_KEY } from '../../common/middlewares/audit-context.middleware';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
/**
* Observable-property coverage for the DB-backed audit trail (#496): every
* assertion pins what actually reaches the `audit` table (or that nothing does),
* driven through a chainable Kysely mock that captures the inserted rows.
*/
describe('DatabaseAuditService', () => {
function makeService(clsContext: any) {
const inserted: any[] = [];
const updated: any[] = [];
let failNextInsert = false;
const db: any = {
insertInto: jest.fn(() => ({
values: jest.fn((rows: any) => ({
execute: jest.fn(async () => {
if (failNextInsert) {
failNextInsert = false;
throw new Error('boom');
}
inserted.push(rows);
}),
})),
})),
updateTable: jest.fn(() => ({
set: jest.fn((patch: any) => ({
where: jest.fn(() => ({
execute: jest.fn(async () => {
updated.push(patch);
}),
})),
})),
})),
};
const store: Record<string, any> = { [AUDIT_CONTEXT_KEY]: clsContext };
const cls: any = {
get: jest.fn((key: string) => store[key]),
set: jest.fn((key: string, val: any) => {
store[key] = val;
}),
};
const service = new DatabaseAuditService(db, cls);
return {
service,
inserted,
updated,
cls,
store,
failInsert: () => {
failNextInsert = true;
},
};
}
const applyPayload = () => ({
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
spaceId: 'space-1',
metadata: { pageId: 'p-1', suggestedText: 'new', decidedBy: 'u-2' },
});
const ctx = (over?: any) => ({
workspaceId: 'ws-1',
actorId: 'u-2',
actorType: 'user',
ipAddress: '10.0.0.1',
userAgent: 'jest',
...over,
});
it('log() persists a row with the CLS context merged onto the payload', async () => {
const { service, inserted } = makeService(ctx());
service.log(applyPayload());
// log() is fire-and-forget; flush the microtask queue.
await Promise.resolve();
await Promise.resolve();
expect(inserted).toHaveLength(1);
expect(inserted[0]).toMatchObject({
workspaceId: 'ws-1',
actorId: 'u-2',
actorType: 'user',
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
spaceId: 'space-1',
ipAddress: '10.0.0.1',
metadata: { pageId: 'p-1', suggestedText: 'new', decidedBy: 'u-2' },
});
});
it('log() is a no-op when there is no workspace in scope', async () => {
const { service, inserted } = makeService(ctx({ workspaceId: null }));
service.log(applyPayload());
await Promise.resolve();
expect(inserted).toHaveLength(0);
});
it('log() drops EXCLUDED_AUDIT_EVENTS (e.g. comment.created)', async () => {
const { service, inserted } = makeService(ctx());
service.log({
event: AuditEvent.COMMENT_CREATED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
spaceId: 'space-1',
});
await Promise.resolve();
await Promise.resolve();
expect(inserted).toHaveLength(0);
});
it('a failed insert is swallowed with a warn and floats no rejection (audit is a side-record)', async () => {
// This pins the load-bearing swallow inside persist(). Because log() is
// fire-and-forget (`void this.persist(...)`), it always returns synchronously
// without throwing — so `not.toThrow()` alone would stay green even if the
// try/catch were removed. We instead observe the two effects the catch is
// responsible for: a warn IS emitted, and NO unhandled rejection floats.
// Removing persist()'s try/catch reddens both assertions (warn count 0 + a
// captured rejection).
const warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined as any);
const rejections: unknown[] = [];
const onRejection = (err: unknown) => rejections.push(err);
process.on('unhandledRejection', onRejection);
try {
const { service, failInsert } = makeService(ctx());
failInsert();
expect(() => service.log(applyPayload())).not.toThrow();
// Flush microtasks so the rejected insert settles, then give any floated
// rejection a macrotask tick to be reported by the runtime.
await Promise.resolve();
await Promise.resolve();
await new Promise((r) => setImmediate(r));
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0][0])).toContain(
'Failed to persist audit event',
);
expect(rejections).toHaveLength(0);
} finally {
process.off('unhandledRejection', onRejection);
warnSpy.mockRestore();
}
});
it('logWithContext() persists with an explicit (non-request) context', async () => {
const { service, inserted } = makeService(undefined);
service.logWithContext(applyPayload(), ctx({ actorType: 'system' }) as any);
await Promise.resolve();
await Promise.resolve();
expect(inserted).toHaveLength(1);
expect(inserted[0].actorType).toBe('system');
expect(inserted[0].workspaceId).toBe('ws-1');
});
it('logBatchWithContext() inserts only non-excluded events', async () => {
const { service, inserted } = makeService(undefined);
service.logBatchWithContext(
[
applyPayload(),
{
event: AuditEvent.COMMENT_CREATED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-2',
},
],
ctx() as any,
);
await Promise.resolve();
await Promise.resolve();
// Batch is a single insert call carrying only the applied event.
expect(inserted).toHaveLength(1);
expect(inserted[0]).toHaveLength(1);
expect(inserted[0][0].event).toBe(AuditEvent.COMMENT_SUGGESTION_APPLIED);
});
it('setActorId / setActorType mutate the ambient CLS context', () => {
const { service, store } = makeService(ctx({ actorId: null }));
service.setActorId('u-9');
service.setActorType('api_key');
expect(store[AUDIT_CONTEXT_KEY].actorId).toBe('u-9');
expect(store[AUDIT_CONTEXT_KEY].actorType).toBe('api_key');
});
it('updateRetention() writes the workspace retention window', async () => {
const { service, updated } = makeService(ctx());
await service.updateRetention('ws-1', 30);
expect(updated).toEqual([{ auditRetentionDays: 30 }]);
});
});
@@ -0,0 +1,160 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { ClsService } from 'nestjs-cls';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import {
AuditContext,
AUDIT_CONTEXT_KEY,
} from '../../common/middlewares/audit-context.middleware';
import {
AuditLogPayload,
ActorType,
EXCLUDED_AUDIT_EVENTS,
} from '../../common/events/audit-events';
import { AuditLogContext, IAuditService } from './audit.service';
/**
* Minimal DB-backed audit trail (#496). Replaces NoopAuditService so that
* decision-bearing events notably comment.suggestion_applied /
* comment.suggestion_dismissed, whose subject comment is HARD-DELETED on the
* childless path leave a durable record of who decided what. Without this the
* events were emitted (comment.service / *.controller) but swallowed, so an
* applied/dismissed suggestion was unrecoverable once the row was gone.
*
* Rows land in the pre-existing `audit` table (migration 20260228T223532). The
* per-request actor/workspace/ip come from the CLS AuditContext populated by
* AuditContextMiddleware + AuditActorInterceptor; callers that run OUTSIDE a
* request (queue workers, imports) pass an explicit context via
* logWithContext / logBatchWithContext.
*
* Audit is a side-record: a write failure MUST NOT break the originating
* request, so every persistence path swallows its error with a warn. Events in
* EXCLUDED_AUDIT_EVENTS (high-volume/low-signal) are dropped.
*/
@Injectable()
export class DatabaseAuditService implements IAuditService {
private readonly logger = new Logger(DatabaseAuditService.name);
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly cls: ClsService,
) {}
/**
* Persist a single event using the ambient request-scoped AuditContext. A
* no-op when there is no workspace in scope (the table's workspace_id is NOT
* NULL) or the event is excluded. Fire-and-forget: the returned promise is not
* awaited by hot callers, and its rejection is swallowed here.
*/
log(payload: AuditLogPayload): void {
const context = this.cls?.get<AuditContext>(AUDIT_CONTEXT_KEY);
if (!context?.workspaceId) {
// No workspace in scope — nothing we can attribute the row to. This is
// expected for events emitted outside an HTTP request; those callers must
// use logWithContext instead.
return;
}
void this.persist(payload, {
workspaceId: context.workspaceId,
actorId: context.actorId ?? undefined,
actorType: context.actorType,
ipAddress: context.ipAddress ?? undefined,
userAgent: context.userAgent ?? undefined,
});
}
/** Persist a single event with an explicit (non-request) context. */
logWithContext(payload: AuditLogPayload, context: AuditLogContext): void {
if (!context?.workspaceId) return;
void this.persist(payload, context);
}
/** Persist a batch of events sharing one explicit context (imports). */
logBatchWithContext(
payloads: AuditLogPayload[],
context: AuditLogContext,
): void {
if (!context?.workspaceId || payloads.length === 0) return;
const rows = payloads
.filter((p) => !EXCLUDED_AUDIT_EVENTS.has(p.event))
.map((p) => this.toRow(p, context));
if (rows.length === 0) return;
this.db
.insertInto('audit')
.values(rows)
.execute()
.catch((err: any) =>
this.logger.warn(`Failed to persist ${rows.length} audit events: ${err?.message}`),
);
}
/** Update the ambient request actor (e.g. after login resolves the user). */
setActorId(actorId: string): void {
const context = this.cls?.get<AuditContext>(AUDIT_CONTEXT_KEY);
if (context) {
context.actorId = actorId;
this.cls.set(AUDIT_CONTEXT_KEY, context);
}
}
/** Update the ambient request actor type (user | system | api_key). */
setActorType(actorType: ActorType): void {
const context = this.cls?.get<AuditContext>(AUDIT_CONTEXT_KEY);
if (context) {
context.actorType = actorType;
this.cls.set(AUDIT_CONTEXT_KEY, context);
}
}
/** Persist a workspace's audit-log retention window (days). */
async updateRetention(
workspaceId: string,
retentionDays: number,
): Promise<void> {
try {
await this.db
.updateTable('workspaces')
.set({ auditRetentionDays: retentionDays })
.where('id', '=', workspaceId)
.execute();
} catch (err: any) {
this.logger.warn(
`Failed to update audit retention for workspace ${workspaceId}: ${err?.message}`,
);
}
}
private async persist(
payload: AuditLogPayload,
context: AuditLogContext,
): Promise<void> {
if (EXCLUDED_AUDIT_EVENTS.has(payload.event)) return;
try {
await this.db
.insertInto('audit')
.values(this.toRow(payload, context))
.execute();
} catch (err: any) {
// Audit is a side-record; never let a failed write surface to the caller.
this.logger.warn(
`Failed to persist audit event ${payload.event}: ${err?.message}`,
);
}
}
private toRow(payload: AuditLogPayload, context: AuditLogContext) {
return {
workspaceId: context.workspaceId,
actorId: context.actorId ?? null,
actorType: context.actorType ?? 'user',
event: payload.event,
resourceType: payload.resourceType,
resourceId: payload.resourceId ?? null,
spaceId: payload.spaceId ?? null,
// jsonb columns: node-postgres serializes plain objects to JSON.
changes: payload.changes ? (payload.changes as any) : null,
metadata: payload.metadata ? (payload.metadata as any) : null,
ipAddress: context.ipAddress ?? null,
};
}
}
@@ -139,13 +139,19 @@ describe('GeneralQueueProcessor — COMMENT_MARK_UPDATE (#399)', () => {
);
});
it('skips (no throw) when the comment row has vanished', async () => {
it('reconcile (#496): comment row vanished → strips the orphan anchor mark', async () => {
const { proc, collaborationGateway, commentRepo } = makeProc();
commentRepo.findById.mockResolvedValue(undefined);
await expect(
proc.process(job({ ...base, action: 'resolve', ts: 1000 })),
).resolves.toBeUndefined();
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
// A resolve/unresolve mark job whose comment row is gone leaves a silent
// orphan; the worker self-heals by stripping the anchor instead of returning.
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
'deleteCommentMark',
'page.page-1',
{ commentId: 'c-1', user: { id: 'user-1' } },
);
});
});
@@ -95,7 +95,8 @@ export class GeneralQueueProcessor
* #399: apply a comment's inline-mark mirror in the collab Y.Doc, off the HTTP
* critical path. Runs the SAME gateway path the synchronous comment.service
* code used (byte-identical mark op):
* - resolve / unresolve resolveCommentMark (flip the `resolved` attribute);
* - resolve / unresolve resolveCommentMark (flip the `resolved` attribute),
* OR strip an orphan anchor when the comment row has vanished (#496);
* - delete deleteCommentMark (strip the ephemeral-suggestion anchor #329).
* The op is idempotent, so a BullMQ retry is safe. Throwing propagates to
* WorkerHost the job is retried and, on exhaustion, surfaces in failed-job
@@ -133,7 +134,18 @@ export class GeneralQueueProcessor
// of this resolve), skip it rather than flip the mark to a stale state.
const comment = await this.commentRepo.findById(commentId);
if (!comment) {
// The comment vanished (e.g. hard-deleted) → nothing left to mirror.
// #496 reconcile: the comment row is GONE (e.g. an ephemeral apply/dismiss
// hard-deleted it while this resolve/unresolve mark job sat in the queue),
// but its inline anchor may still live in the doc — a silent orphan mark
// pointing at a comment that no longer exists. Self-heal by stripping it
// instead of just returning: this closes the divergence the fire-and-forget
// resolve/unresolve enqueue (comment.service resolveComment) could leave.
// Idempotent — deleteCommentMark on an already-absent mark is a no-op.
await this.getCollaborationGateway().handleYjsEvent(
'deleteCommentMark',
documentName,
{ commentId, user },
);
return;
}
const wantResolved = action === 'resolve';
@@ -4,6 +4,7 @@ import { join } from 'path';
import * as fs from 'node:fs';
import fastifyStatic from '@fastify/static';
import { EnvironmentService } from '../environment/environment.service';
import { resolveClientDistPath } from '../../common/helpers/client-version';
/**
* Resolve the response headers for a statically served client asset.
@@ -56,14 +57,7 @@ export class StaticModule implements OnModuleInit {
const httpAdapter = this.httpAdapterHost.httpAdapter;
const app = httpAdapter.getInstance();
const clientDistPath = join(
__dirname,
'..',
'..',
'..',
'..',
'client/dist',
);
const clientDistPath = resolveClientDistPath();
const indexFilePath = join(clientDistPath, 'index.html');
+37 -2
View File
@@ -9,10 +9,14 @@ import {
import { Server, Socket } from 'socket.io';
import { TokenService } from '../core/auth/services/token.service';
import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
import { OnModuleDestroy } from '@nestjs/common';
import { Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { WsService } from './ws.service';
import { getSpaceRoomName, getUserRoomName } from './ws.utils';
import {
readClientBuildVersion,
resolveClientDistPath,
} from '../common/helpers/client-version';
import * as cookie from 'cookie';
@WebSocketGateway({
@@ -20,17 +24,40 @@ import * as cookie from 'cookie';
transports: ['websocket'],
})
export class WsGateway
implements OnGatewayConnection, OnGatewayInit, OnModuleDestroy
implements
OnGatewayConnection,
OnGatewayInit,
OnModuleInit,
OnModuleDestroy
{
@WebSocketServer()
server: Server;
private readonly logger = new Logger(WsGateway.name);
// The build version of the client bundle shipped in this image, read once at
// startup from client/dist/version.json (single source of truth, same value
// baked into the client's APP_VERSION). Empty string => version.json missing
// or empty => the proactive version-coherence reload feature stays inert.
private appVersion = '';
constructor(
private tokenService: TokenService,
private spaceMemberRepo: SpaceMemberRepo,
private wsService: WsService,
) {}
onModuleInit(): void {
this.appVersion = readClientBuildVersion(resolveClientDistPath());
if (this.appVersion) {
this.logger.log(`app-version reload: ACTIVE (v=${this.appVersion})`);
} else {
this.logger.log(
'app-version reload: DISABLED (version.json missing/empty)',
);
}
}
afterInit(server: Server): void {
this.wsService.setServer(server);
}
@@ -55,6 +82,14 @@ export class WsGateway
const spaceRooms = userSpaceIds.map((id) => getSpaceRoomName(id));
client.join([userRoom, workspaceRoom, ...spaceRooms]);
// Announce this container's client build version to the freshly
// authenticated socket. On a redeploy the client reconnects to the new
// container and receives the new version here, letting it guard-reload
// before it hits a stale lazy chunk. Per-connect only (no broadcast):
// natural reconnect covers both single-container and cluster without a
// thundering-herd fleet reload.
client.emit('app-version', { version: this.appVersion });
} catch (err) {
client.emit('Unauthorized');
client.disconnect();
+1 -5
View File
@@ -31,11 +31,7 @@ import { TransformsMixin, type ITransformsMixin } from "./client/transforms.js";
// existing importer (index.ts, http.ts, stdio.ts, the in-app host) keeps working
// with ZERO changes.
export type { DocmostMcpConfig, SandboxPut } from "./client/context.js";
export {
formatDocmostAxiosError,
assertFullUuid,
formatSpaceNotAccessible,
} from "./client/errors.js";
export { formatDocmostAxiosError, assertFullUuid } from "./client/errors.js";
// The full public + shared instance surface of the assembled client. Built by
// INTERSECTING each domain mixin's public interface (each DERIVED from its class
+46 -9
View File
@@ -450,6 +450,12 @@ export function CommentsMixin<TBase extends GConstructor<DocmostClientContext>>(
// can surface the closest-block / spans-multiple-blocks hint built from the
// LIVE document (the pre-check page is not in scope there).
let liveNotFoundError: Error | null = null;
// #496: the RAW substring the mark actually covers in the LIVE doc. The
// stored selection (payload.selection) came from a DEBOUNCED REST snapshot,
// which can differ from the live doc — and apply compares the marked live
// text to the stored selection strictly, so a stale snapshot 409s on EVERY
// apply. Captured here (same doc version the mark is set in) and synced below.
let liveAnchoredSelection: string | null = null;
try {
const collabToken = await this.getCollabTokenWithReauth();
// Open the collab doc by the canonical UUID, never the slugId (#260). The
@@ -489,6 +495,12 @@ export function CommentsMixin<TBase extends GConstructor<DocmostClientContext>>(
}
if (applyAnchorInDoc(doc, selection as string, newCommentId)) {
anchored = true;
// For a suggestion, re-read the exact substring now under the mark
// (the mark is an attribute, so it does not change the raw text) to
// sync as the stored expectedText after the mutation resolves.
if (hasSuggestion) {
liveAnchoredSelection = getAnchoredText(doc, selection as string);
}
return doc;
}
// Selection text not found in the LIVE document: abort the write. The
@@ -527,6 +539,36 @@ export function CommentsMixin<TBase extends GConstructor<DocmostClientContext>>(
);
}
// #496: sync the stored selection (== apply-time expectedText) to the RAW
// substring the mark actually covers in the LIVE doc when it diverged from
// the debounced REST snapshot we stored at create time. Without this, apply
// strictly compares the marked live text to a stale stored selection and
// 409s every time. Best-effort: the comment is already correctly anchored, so
// a resync failure must NOT roll it back — it only risks a later apply 409,
// which we surface as a soft warning.
if (
hasSuggestion &&
liveAnchoredSelection != null &&
liveAnchoredSelection !== payload.selection
) {
try {
await this.client.post("/comments/resync-suggestion-anchor", {
commentId: newCommentId,
selection: liveAnchoredSelection,
});
// Reflect the corrected anchor in the returned comment.
if (result.data) result.data.selection = liveAnchoredSelection;
} catch (e) {
if (process.env.DEBUG) {
console.error("Failed to resync suggestion anchor:", e);
}
result.warning =
"The suggestion was anchored, but its stored selection could not be " +
"synced to the live document; applying it may report a conflict if the " +
"text changed. Re-create the suggestion if Apply fails.";
}
}
// Soft warning (like editPageText): the selection only matched after
// stripping markdown, so the caller likely quoted a styled fragment.
if (anchorNormalized) {
@@ -650,15 +692,10 @@ export function CommentsMixin<TBase extends GConstructor<DocmostClientContext>>(
// The subtree scope (parentPageId given) already INCLUDES the root node
// itself: /pages/tree seeds getPageAndDescendants with id = parentPageId, so
// no separate getPageRaw fetch for the parent is needed.
// #534: the enumerateSpacePages seed (`/pages/tree`) 404s for a bad or
// inaccessible spaceId; wrap it so that 404 becomes an actionable "spaceId
// not accessible" hint instead of the opaque "Space permissions not found".
// Only the whole enumeration is wrapped (the only 404 source here) — see the
// wrap-allowlist invariant on withSpaceAccessDiagnostics.
const { pages: pagesInScope, truncated } =
await this.withSpaceAccessDiagnostics(spaceId, "checkNewComments", () =>
this.enumerateSpacePages(spaceId, parentPageId),
);
const { pages: pagesInScope, truncated } = await this.enumerateSpacePages(
spaceId,
parentPageId,
);
// 2. Fetch comments for each page, keep ones created after since
const results: any[] = [];
+3 -213
View File
@@ -25,10 +25,7 @@ import {
} from "../lib/collab-session.js";
import { withPageLock, isUuid } from "../lib/page-lock.js";
import { getCollabToken, performLogin } from "../lib/auth-utils.js";
import {
formatDocmostAxiosError,
formatSpaceNotAccessible,
} from "./errors.js";
import { formatDocmostAxiosError } from "./errors.js";
import { GetPageConversionCache } from "./getpage-cache.js";
// A generic mixin base constructor (issue #450). Each domain mixin is a factory
@@ -119,36 +116,6 @@ function readCollabTokenTtlMs(): number {
return Number.isFinite(raw) ? Math.max(0, raw) : 5 * 60 * 1000;
}
/**
* Accessible-space index cache TTL in milliseconds (issue #534). Read fresh from
* the environment on every access mirroring readCollabTokenTtlMs above so a
* test or a live rollback can change it without reloading the module.
*
* The index (see getAccessibleSpaceIndex) is fetched ONLY on the enrich-on-404
* slow path to turn an opaque "Space permissions not found" 404 into a factual
* "spaceId X is not among your accessible spaces" hint; a short TTL keeps a burst
* of failing tool calls from re-sweeping /spaces each time while never widening
* the permission-staleness window meaningfully. Default 60s. An EXPLICIT 0 (or
* negative) DISABLES the cache (exact fetch-per-enrichment). Unset/unparseable
* (NaN) falls back to the 60s default with the cache ON.
*/
function readSpacesCacheTtlMs(): number {
const raw = parseInt(process.env.MCP_SPACES_CACHE_TTL_MS ?? "", 10);
return Number.isFinite(raw) ? Math.max(0, raw) : 60000;
}
/**
* The set of spaces the current token can see, plus a `complete` flag that is
* false when the /spaces listing was truncated at the pagination ceiling. Used
* by the enrich-on-404 diagnostics: an authoritative membership test is only
* possible when `complete` is true (see withSpaceAccessDiagnostics).
*/
export type AccessibleSpaceIndex = {
ids: Set<string>;
spaces: { id: string; name: string }[];
complete: boolean;
};
export abstract class DocmostClientContext {
protected client: AxiosInstance;
protected token: string | null = null;
@@ -196,27 +163,6 @@ export abstract class DocmostClientContext {
// bypassed on a forced refresh (the 401/403 reauth path). null = no token yet.
protected collabTokenCache: { token: string; mintedAt: number } | null = null;
// Accessible-space index cache + single-flight (issue #534). TWO separate
// fields, mirroring loginPromise (in-flight dedup) vs collabTokenCache
// (persistent value):
// - spaceIndexCache: the last SUCCESSFULLY-FETCHED, COMPLETE index plus the
// wall-clock time it was fetched. Written ONLY from a resolved /spaces
// sweep whose result was complete (a truncated list is never cached, since
// it cannot answer "is this spaceId missing?"). Per-instance (a
// DocmostClient is built per user / per chat) so it can never leak across
// identities; invalidated on every identity change exactly like
// collabTokenCache (login() + the 401/403 reauth interceptor).
// - spaceIndexInFlight: dedups concurrent enrich-on-404 fetches into ONE
// /spaces sweep. CRITICAL INVARIANT: this promise is nulled in `.finally`
// on BOTH resolve AND reject — a rejected/settled promise is NEVER
// memoized, so a transient /spaces blip during one failed tool call cannot
// poison the diagnostics for the rest of the session.
protected spaceIndexCache: {
index: AccessibleSpaceIndex;
fetchedAt: number;
} | null = null;
protected spaceIndexInFlight: Promise<AccessibleSpaceIndex> | null = null;
// Content-addressed conversion cache for getPage (issue #479). Keyed on
// (canonical pageId, updatedAt, optionsHash) -> the converted Markdown, so a
// re-read of an UNCHANGED page skips the expensive convertProseMirrorToMarkdown
@@ -334,9 +280,6 @@ export abstract class DocmostClientContext {
// keep serving a collab token minted under the old one.
this.token = null;
this.collabTokenCache = null;
// #534: a new identity/login must not keep serving a space index
// computed under the old token (same reasoning as collabTokenCache).
this.spaceIndexCache = null;
delete this.client.defaults.headers.common["Authorization"];
try {
await this.login();
@@ -469,8 +412,6 @@ export abstract class DocmostClientContext {
// Identity (re)established: drop any collab token minted under a
// previous identity so the #435 cache can never outlive it.
this.collabTokenCache = null;
// #534: likewise drop the accessible-space index of the old identity.
this.spaceIndexCache = null;
this.client.defaults.headers.common["Authorization"] =
`Bearer ${token}`;
})
@@ -681,34 +622,13 @@ export abstract class DocmostClientContext {
}
/**
* Generic pagination handler for Docmost API endpoints. Thin wrapper over
* paginateAllWithMeta that discards the `truncated` flag the historical
* contract every caller (getSpaces, etc.) relies on. Callers that need to KNOW
* whether the result set was complete (e.g. #534's getAccessibleSpaceIndex,
* which must not assert "spaceId missing" against a truncated list) call
* paginateAllWithMeta directly.
* Generic pagination handler for Docmost API endpoints
*/
async paginateAll<T = any>(
endpoint: string,
basePayload: Record<string, any> = {},
limit: number = 100,
): Promise<T[]> {
return (await this.paginateAllWithMeta<T>(endpoint, basePayload, limit))
.items;
}
/**
* Generic pagination handler that ALSO surfaces whether the result was
* truncated at the MAX_PAGES ceiling. `paginateAll` swallows this flag (it only
* warns); callers that must distinguish "complete listing" from "gave up at the
* cap" use this overload. `truncated` is true iff the loop stopped at the
* ceiling while the server still reported more pages.
*/
async paginateAllWithMeta<T = any>(
endpoint: string,
basePayload: Record<string, any> = {},
limit: number = 100,
): Promise<{ items: T[]; truncated: boolean }> {
await this.ensureAuthenticated();
const clampedLimit = Math.max(1, Math.min(100, limit));
@@ -769,137 +689,7 @@ export abstract class DocmostClientContext {
);
}
return { items: allItems, truncated };
}
/**
* The set of spaces the current token can access (issue #534), fetched from the
* single source of truth the `/spaces` listing with a per-instance
* short-TTL cache and single-flight dedup. Used ONLY by the enrich-on-404 slow
* path (withSpaceAccessDiagnostics), so the happy path incurs ZERO extra
* requests.
*
* `complete` is `!truncated`: it is false when the /spaces listing was cut at
* the pagination ceiling. A truncated index can never authoritatively answer
* "is this spaceId missing?", so only a complete result is cached AND only a
* complete result is allowed to drive the "not accessible" rewrite.
*
* Cache/single-flight discipline (see the spaceIndexCache / spaceIndexInFlight
* field docs):
* - serve a fresh, complete cached index without any request;
* - otherwise collapse concurrent callers onto ONE in-flight /spaces sweep;
* - write the persistent cache ONLY from a resolved, complete fetch;
* - null the in-flight promise on BOTH resolve and reject (never memoize a
* rejected promise a transient /spaces failure must be retried fresh).
*/
async getAccessibleSpaceIndex(): Promise<AccessibleSpaceIndex> {
const ttl = readSpacesCacheTtlMs();
// Fast path: a still-fresh, complete cached index needs no request at all.
if (
ttl > 0 &&
this.spaceIndexCache &&
Date.now() - this.spaceIndexCache.fetchedAt < ttl
) {
return this.spaceIndexCache.index;
}
// Single-flight: a concurrent enrichment joins the in-flight sweep instead of
// issuing its own. (A settled/rejected promise is never left here — see the
// `.finally` below — so this only ever joins a genuinely in-progress fetch.)
if (this.spaceIndexInFlight) return this.spaceIndexInFlight;
const fetchPromise = (async (): Promise<AccessibleSpaceIndex> => {
const { items, truncated } = await this.paginateAllWithMeta("/spaces", {});
const spaces = items.map((s: any) => ({
id: s?.id,
name: s?.name,
}));
return {
ids: new Set(spaces.map((s) => s.id)),
spaces,
complete: !truncated,
};
})();
this.spaceIndexInFlight = fetchPromise
.then((index) => {
// Cache ONLY a complete result, and only while the cache is enabled.
if (ttl > 0 && index.complete) {
this.spaceIndexCache = { index, fetchedAt: Date.now() };
}
return index;
})
.finally(() => {
// CRITICAL (#534): clear the in-flight slot on BOTH resolve and reject.
// Nulling on reject too means a transient /spaces error is retried by the
// NEXT enrichment with a fresh fetch, never re-serving the rejection.
this.spaceIndexInFlight = null;
});
return this.spaceIndexInFlight;
}
/**
* Wrap a client method whose 404 means "the supplied spaceId is not accessible"
* and, ONLY on that 404, replace the opaque server text ("Space permissions not
* found") with a factual, actionable message naming the spaceId and the spaces
* the token can actually see (issue #534). A HINT layered on top of the
* backend, which stays authoritative so it FAILS OPEN on ANY uncertainty:
* every branch below that is not a confident "this spaceId is genuinely
* missing" rethrows the ORIGINAL server error unchanged. The happy path returns
* fn()'s value with zero extra requests.
*
* WRAP-ALLOWLIST INVARIANT (load-bearing read before wrapping a new method):
* among the currently wrapped tools a 404 comes ONLY from the spaceId
* membership / space-permissions check their pageId / rootPageId /
* parentPageId branches resolve to 403 or 200, NEVER 404. If a future change
* adds a `NotFoundException` to `/pages/tree`, `/pages/recent`,
* `/pages/sidebar-pages` or `/search` (e.g. "page not found"), this enrichment
* would MISATTRIBUTE that 404 to the spaceId. Re-audit the wrapped call before
* relying on this, and only wrap paths where the sole 404 cause is the space.
*/
protected async withSpaceAccessDiagnostics<T>(
spaceId: string,
mcpName: string,
fn: () => Promise<T>,
): Promise<T> {
try {
return await fn();
} catch (e) {
// Abort/cap wins FIRST and is detected by the SIGNAL FLAG, not e.name: a
// per-call cap may be an AbortSignal.timeout() (reason name "TimeoutError")
// or a custom reason, so `e.name === 'AbortError'` is NOT reliable (#534
// hole B). A stopped/capped turn must propagate its reason, never trigger a
// /spaces sweep or a rewrite.
if (this.toolAbortSignal?.aborted) throw e;
// Only a 404 is enrichable; any other status/shape is a different failure.
if (!(axios.isAxiosError(e) && e.response?.status === 404)) throw e;
let idx: AccessibleSpaceIndex;
try {
idx = await this.getAccessibleSpaceIndex();
} catch (fetchErr) {
// The /spaces sweep itself failed. If we were aborted mid-sweep,
// propagate the abort reason; otherwise FAIL OPEN with the ORIGINAL
// server error rather than a misleading "not found".
if (this.toolAbortSignal?.aborted) throw fetchErr;
if (process.env.DEBUG) {
console.error("space-diag: /spaces fetch failed:", fetchErr);
}
throw e;
}
// Fail open when the listing is incomplete (can't assert "missing") or when
// the spaceId IS present (the 404 is about something else, not the space).
if (!idx.complete) throw e;
if (idx.ids.has(spaceId)) throw e;
// Confident: the spaceId is well-formed but not among the accessible
// spaces. Replace the opaque server text with the actionable fact.
throw new Error(formatSpaceNotAccessible(mcpName, spaceId, idx.spaces));
}
return allItems;
}
-34
View File
@@ -46,40 +46,6 @@ export function assertFullUuid(
}
}
// Max number of accessible spaces to enumerate inline in the "space not
// accessible" message (issue #534) before collapsing the rest into a "(+N ещё)"
// tail, so a workspace with many spaces cannot blow up the model context.
const SPACE_LIST_CAP = 10;
/**
* Compose the model-facing "spaceId is not accessible" message (issue #534).
* This is FACT text about the supplied spaceId that REPLACES the opaque server
* string ("Space permissions not found") on the enrich-on-404 path it names
* the exact bad id, lists the spaces the token can actually see (id + name, so
* the agent can copy the right id verbatim), and points at `listSpaces`.
*
* Deliberately Russian: like the other agent-facing tool guidance in this repo,
* this is the message the acting agent reads to self-correct.
*/
export function formatSpaceNotAccessible(
mcpName: string,
spaceId: string,
spaces: { id: string; name: string }[],
): string {
// No accessible spaces at all — a distinct diagnosis (token has no space
// access), not "you picked the wrong one from this list".
if (!Array.isArray(spaces) || spaces.length === 0) {
return `${mcpName}: spaceId "${spaceId}" недоступен, и доступных тебе спейсов нет — проверь доступ токена / вызови listSpaces.`;
}
const shown = spaces.slice(0, SPACE_LIST_CAP);
const listed = shown.map((s) => `${s.id} (${s.name})`).join(", ");
const remaining = spaces.length - shown.length;
const tail =
remaining > 0 ? ` (+${remaining} ещё, см. listSpaces)` : "";
return `${mcpName}: spaceId "${spaceId}" не найден среди доступных тебе спейсов. Доступные: ${listed}${tail} — скопируй нужный id дословно из listSpaces.`;
}
// Keep ONLY the pathname of a request (no host, no query string, no fragment)
// so the message never leaks a host or query params. Resolves a relative
// config.url against config.baseURL, then discards everything but the path.
+16 -46
View File
@@ -132,29 +132,17 @@ export function ReadMixin<TBase extends GConstructor<DocmostClientContext>>(Base
// BFS hit its node cap) had no way to know pages were missing. Return the
// tree alongside the flag; the primary /pages/tree path is uncapped so this
// is false there.
// #534: spaceId is required here; wrap so a bad-spaceId 404 (from the
// /pages/tree seed inside enumerateSpacePages) becomes an actionable hint.
return this.withSpaceAccessDiagnostics(spaceId, "listPages", async () => {
const { pages, truncated } = await this.enumerateSpacePages(spaceId);
return { tree: buildPageTree(pages), truncated };
});
const { pages, truncated } = await this.enumerateSpacePages(spaceId);
return { tree: buildPageTree(pages), truncated };
}
const clampedLimit = Math.max(1, Math.min(100, limit));
const payload: Record<string, any> = { limit: clampedLimit, page: 1 };
if (spaceId) payload.spaceId = spaceId;
// #534: only the WITH-spaceId recent path can 404 on space access; wrap it so
// that 404 is rewritten. Without a spaceId there is no space to diagnose, so
// the wrapper is inert (run the request directly).
const runRecent = async () => {
const response = await this.client.post("/pages/recent", payload);
const data = response.data;
const items = data.data?.items || data.items || [];
return items.map((page: any) => filterPage(page));
};
return spaceId
? this.withSpaceAccessDiagnostics(spaceId, "listPages", runRecent)
: runRecent();
const response = await this.client.post("/pages/recent", payload);
const data = response.data;
const items = data.data?.items || data.items || [];
return items.map((page: any) => filterPage(page));
}
/**
@@ -186,16 +174,8 @@ export function ReadMixin<TBase extends GConstructor<DocmostClientContext>>(Base
"getTree: spaceId is required (a page tree is scoped to one space).",
);
}
// #534: the 404 for a bad/inaccessible spaceId surfaces from the
// `/pages/tree` seeding step inside enumerateSpacePages (which is NOT in a
// try/catch of its own for that case) — wrap the whole body so it is caught
// and rewritten into an actionable "spaceId not accessible" message. Only the
// space membership check can 404 here (rootPageId 403/200), see the
// wrap-allowlist invariant on withSpaceAccessDiagnostics.
return this.withSpaceAccessDiagnostics(spaceId, "getTree", async () => {
const { pages } = await this.enumerateSpacePages(spaceId, rootPageId);
return buildPageTree(pages, { shape: "getTree", maxDepth });
});
const { pages } = await this.enumerateSpacePages(spaceId, rootPageId);
return buildPageTree(pages, { shape: "getTree", maxDepth });
}
/**
@@ -720,27 +700,17 @@ export function ReadMixin<TBase extends GConstructor<DocmostClientContext>>(Base
if (limit !== undefined) {
payload.limit = Math.max(1, Math.min(50, limit));
}
const response = await this.client.post("/search", payload);
const runSearch = async () => {
const response = await this.client.post("/search", payload);
// Normalize both response shapes: bare array and paginated { items: [...] }
const data = response.data?.data;
const items = Array.isArray(data) ? data : data?.items || [];
const filteredItems = items.map((item: any) => filterSearchResult(item));
// Normalize both response shapes: bare array and paginated { items: [...] }
const data = response.data?.data;
const items = Array.isArray(data) ? data : data?.items || [];
const filteredItems = items.map((item: any) => filterSearchResult(item));
return {
items: filteredItems,
success: response.data?.success || false,
};
return {
items: filteredItems,
success: response.data?.success || false,
};
// #534: a search scoped to a spaceId 404s when that space is inaccessible;
// wrap only that case so the 404 becomes an actionable hint. A workspace-wide
// search (no spaceId) has no space to diagnose — run it directly (inert).
return spaceId
? this.withSpaceAccessDiagnostics(spaceId, "search", runSearch)
: runSearch();
}
}
@@ -553,6 +553,189 @@ test("suggestedText: the stored selection is the doc's RAW typographic substring
assert.equal(createPayload.suggestedText, "goodbye");
});
// -----------------------------------------------------------------------------
// 8b) #496: the DEBOUNCED REST snapshot (pages/info) DIFFERS from the LIVE collab
// doc (mutatePage) — the doc moved on in the debounce window. The stored
// selection is captured from the snapshot at create time, but the mark is set
// in the live doc, so apply would 409 forever. After anchoring, the client
// re-reads the RAW substring under the mark from the LIVE doc and POSTs it to
// /comments/resync-suggestion-anchor so the stored expectedText matches.
// -----------------------------------------------------------------------------
test("suggestion: re-syncs the stored selection to the LIVE doc substring when the REST snapshot lagged", async () => {
let createPayload = null;
let resyncPayload = null;
const { baseURL } = await spawn(async (req, res) => {
const raw = await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/pages/info") {
// DEBOUNCED snapshot: ASCII quotes (stale — the live doc has since been
// typographically corrected).
sendJson(res, 200, {
data: {
id: "33333333-3333-3333-3333-333333333333",
content: {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: 'he said "hello" loudly' }],
},
],
},
},
});
return;
}
if (req.url === "/api/comments/create") {
createPayload = JSON.parse(raw);
sendJson(res, 200, {
data: {
id: "cmt-resync-1",
content: createPayload.content,
selection: createPayload.selection,
suggestedText: createPayload.suggestedText,
type: createPayload.type,
},
});
return;
}
if (req.url === "/api/comments/resync-suggestion-anchor") {
resyncPayload = JSON.parse(raw);
sendJson(res, 200, { data: { id: "cmt-resync-1" } });
return;
}
sendJson(res, 404, { message: "not found" });
});
class TestClient extends DocmostClient {
async getCollabTokenWithReauth() {
return "collab-token";
}
async resolvePageId() {
return "33333333-3333-3333-3333-333333333333";
}
async mutatePage(pageId, collabToken, apiUrl, transform) {
// LIVE doc: SMART quotes (what the mark is actually set over).
const doc = {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "he said “hello” loudly" }],
},
],
};
const out = transform(doc);
return { doc: out, verify: { ok: true } };
}
}
const client = new TestClient(baseURL, "user@example.com", "pw");
const result = await client.createComment(
"33333333-3333-3333-3333-333333333333",
"please change",
"inline",
'"hello"', // ASCII quotes
undefined,
"goodbye",
);
assert.equal(result.success, true);
assert.equal(result.anchored, true);
// Create stored the STALE snapshot substring (ASCII quotes).
assert.equal(createPayload.selection, '"hello"');
// …then the client re-synced to the LIVE marked substring (smart quotes).
assert.ok(resyncPayload, "/comments/resync-suggestion-anchor must be called");
assert.equal(resyncPayload.commentId, "cmt-resync-1");
assert.equal(resyncPayload.selection, "“hello”");
// The returned comment reflects the corrected anchor.
assert.equal(result.data.selection, "“hello”");
});
// -----------------------------------------------------------------------------
// 8c) #496: when the REST snapshot and the LIVE doc AGREE, no resync round-trip
// is made (the common case must stay a single write).
// -----------------------------------------------------------------------------
test("suggestion: no resync call when the snapshot already matches the live doc", async () => {
let resyncCalls = 0;
const { baseURL } = await spawn(async (req, res) => {
const raw = await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/pages/info") {
sendJson(res, 200, {
data: {
id: "44444444-4444-4444-4444-444444444444",
content: {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "Hello brave world" }] },
],
},
},
});
return;
}
if (req.url === "/api/comments/create") {
const p = JSON.parse(raw);
sendJson(res, 200, {
data: { id: "cmt-nosync-1", content: p.content, selection: p.selection, suggestedText: p.suggestedText, type: p.type },
});
return;
}
if (req.url === "/api/comments/resync-suggestion-anchor") {
resyncCalls++;
sendJson(res, 200, { data: {} });
return;
}
sendJson(res, 404, { message: "not found" });
});
class TestClient extends DocmostClient {
async getCollabTokenWithReauth() {
return "collab-token";
}
async resolvePageId() {
return "44444444-4444-4444-4444-444444444444";
}
async mutatePage(pageId, collabToken, apiUrl, transform) {
const doc = {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "Hello brave world" }] },
],
};
const out = transform(doc);
return { doc: out, verify: { ok: true } };
}
}
const client = new TestClient(baseURL, "user@example.com", "pw");
const result = await client.createComment(
"44444444-4444-4444-4444-444444444444",
"rename",
"inline",
"brave",
undefined,
"bold",
);
assert.equal(result.anchored, true);
assert.equal(resyncCalls, 0, "matching snapshot must NOT trigger a resync round-trip");
});
// -----------------------------------------------------------------------------
// 8) #408: a not-found selection error QUOTES the closest block text so the
// model can self-correct instead of blind-retrying.
@@ -1,354 +0,0 @@
// Issue #534: enrich-on-404 space-access diagnostics.
//
// When a tool is handed a well-formed but non-existent/inaccessible spaceId, the
// server answers the space-permissions check with an opaque 404 ("Space
// permissions not found"). The client wrapper (withSpaceAccessDiagnostics) turns
// ONLY that 404 into an actionable message naming the bad spaceId and the spaces
// the token can actually see — while FAILING OPEN (rethrowing the original
// server error unchanged) on every source of uncertainty.
//
// These tests drive the assembled DocmostClient with its inner seams
// (enumerateSpacePages / client.post / paginateAllWithMeta) stubbed at runtime,
// mirroring the stub-client style of error-diagnostics.test.mjs. Only criterion
// 9 (createPage is NOT wrapped) uses a real offline http server, because
// createPage's 404 arrives over a bare-axios multipart path.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import axios, { AxiosError } from "axios";
import {
DocmostClient,
formatSpaceNotAccessible,
} from "../../build/client.js";
// Two accessible spaces (id + name is all getAccessibleSpaceIndex maps/uses).
const SPACES = [
{ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", name: "Engineering" },
{ id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", name: "Design" },
];
// A well-formed UUID that is NOT among the accessible spaces.
const BAD = "99999999-9999-4999-8999-999999999999";
// Build an AxiosError shaped exactly as the response interceptor would hand it
// on a 404 — status readable, axios.isAxiosError() true.
function make404(url = "/pages/tree") {
const config = { method: "post", url, baseURL: "http://host.example/api" };
const response = {
status: 404,
statusText: "Not Found",
data: { message: "Space permissions not found" },
headers: {},
config,
};
return new AxiosError(
"Request failed with status code 404",
"ERR_BAD_REQUEST",
config,
{},
response,
);
}
// A client whose token is pre-set (so ensureAuthenticated never hits the
// network) and whose /spaces sweep is a counted stub. Individual tests override
// enumerateSpacePages / client.post to shape the method-under-test's outcome.
function makeClient({ spaces = SPACES, truncated = false } = {}) {
const c = new DocmostClient("http://127.0.0.1:1/api", "u@example.com", "pw");
c.token = "t";
c.client.defaults.headers.common["Authorization"] = "Bearer t";
c._spacesFetches = 0;
c.paginateAllWithMeta = async (endpoint) => {
if (endpoint === "/spaces") {
c._spacesFetches++;
return { items: spaces, truncated };
}
throw new Error(`unexpected paginate endpoint ${endpoint}`);
};
return c;
}
// --- Criterion 1: bad spaceId on getTree -> actionable rewrite --------------
test("getTree with a non-existent spaceId is rewritten into an actionable hint", async () => {
const c = makeClient();
c.enumerateSpacePages = async () => {
throw make404();
};
await assert.rejects(
() => c.getTree(BAD),
(e) => {
assert.ok(e.message.includes(BAD), "names the passed spaceId");
// At least one valid "id (name)" pair.
assert.ok(
e.message.includes(`${SPACES[0].id} (${SPACES[0].name})`),
"lists an accessible id (name)",
);
assert.ok(e.message.includes("listSpaces"), "points at listSpaces");
assert.ok(
!e.message.includes("Space permissions not found"),
"the opaque server text is replaced",
);
return true;
},
);
assert.equal(c._spacesFetches, 1, "one /spaces sweep on the enrichment path");
});
// --- Criterion 2: happy path -> no /spaces request --------------------------
test("getTree with a valid spaceId returns the tree and makes NO /spaces request", async () => {
const c = makeClient();
// Spy client.post to prove no /spaces POST is issued on the happy path.
const posted = [];
c.client.post = async (url) => {
posted.push(url);
throw new Error(`unexpected post ${url}`);
};
c.enumerateSpacePages = async () => ({ pages: [], truncated: false });
const res = await c.getTree(SPACES[0].id);
assert.ok(Array.isArray(res), "tree returned as before");
assert.equal(c._spacesFetches, 0, "no /spaces sweep on the happy path");
assert.ok(
!posted.includes("/spaces"),
"no /spaces POST on the happy path",
);
});
// --- Criterion 3: short-TTL cache + refetch after expiry --------------------
test("two bad getTree within TTL share ONE /spaces fetch; a refetch happens after TTL", async () => {
const c = makeClient();
c.enumerateSpacePages = async () => {
throw make404();
};
await assert.rejects(() => c.getTree(BAD), /не найден среди/);
await assert.rejects(() => c.getTree(BAD), /не найден среди/);
assert.equal(c._spacesFetches, 1, "second call served from cache");
// Age the cache past the default 60s TTL -> exactly one refetch.
assert.ok(c.spaceIndexCache, "a complete result was cached");
c.spaceIndexCache.fetchedAt = Date.now() - 10 * 60 * 1000;
await assert.rejects(() => c.getTree(BAD), /не найден среди/);
assert.equal(c._spacesFetches, 2, "exactly one refetch after TTL");
});
// --- Criterion 4: no spaceId -> wrapper inert -------------------------------
test("listPages WITHOUT spaceId is inert (raw error propagates, no /spaces sweep)", async () => {
const c = makeClient();
c.client.post = async () => {
throw make404("/pages/recent");
};
await assert.rejects(
() => c.listPages(),
(e) => {
assert.equal(e.response?.status, 404, "raw server 404 propagates");
assert.ok(!/не найден среди/.test(e.message ?? ""), "not rewritten");
return true;
},
);
assert.equal(c._spacesFetches, 0, "no /spaces sweep without a spaceId");
});
test("search WITHOUT spaceId is inert (raw error propagates, no /spaces sweep)", async () => {
const c = makeClient();
c.client.post = async () => {
throw make404("/search");
};
await assert.rejects(
() => c.search("query"),
(e) => {
assert.equal(e.response?.status, 404, "raw server 404 propagates");
assert.ok(!/не найден среди/.test(e.message ?? ""), "not rewritten");
return true;
},
);
assert.equal(c._spacesFetches, 0, "no /spaces sweep without a spaceId");
});
// --- Criterion 5: incomplete listing -> fail open ---------------------------
test("a truncated /spaces listing (!complete) fails OPEN with the original 404", async () => {
const c = makeClient({ truncated: true });
c.enumerateSpacePages = async () => {
throw make404();
};
await assert.rejects(
() => c.getTree(BAD),
(e) => {
assert.equal(e.response?.status, 404, "original server error preserved");
assert.ok(!/не найден среди/.test(e.message ?? ""), "no false rewrite");
return true;
},
);
assert.equal(c.spaceIndexCache, null, "a truncated result is never cached");
});
// --- Criterion 6: /spaces fetch fails -> fail open; in-flight nulled on reject
test("a /spaces fetch failure fails OPEN, logs under DEBUG, and never memoizes the rejected in-flight promise", async () => {
const c = makeClient();
let fetchCount = 0;
c.paginateAllWithMeta = async () => {
fetchCount++;
throw new Error("network boom");
};
c.enumerateSpacePages = async () => {
throw make404();
};
const prevDebug = process.env.DEBUG;
process.env.DEBUG = "1";
const errs = [];
const origErr = console.error;
console.error = (...a) => errs.push(a.map(String).join(" "));
try {
await assert.rejects(
() => c.getTree(BAD),
(e) => {
assert.equal(e.response?.status, 404, "original 404 rethrown");
return true;
},
);
} finally {
console.error = origErr;
if (prevDebug === undefined) delete process.env.DEBUG;
else process.env.DEBUG = prevDebug;
}
assert.ok(
errs.some((l) => l.includes("space-diag: /spaces fetch failed")),
"a DEBUG stderr line is emitted",
);
assert.equal(c.spaceIndexInFlight, null, "in-flight promise nulled on reject");
// The rejected in-flight promise must NOT be reused: a second enrichment does
// a genuinely fresh fetch (fetchCount increments to 2).
await assert.rejects(
() => c.getTree(BAD),
(e) => e.response?.status === 404,
);
assert.equal(fetchCount, 2, "fresh fetch — rejected promise not memoized");
});
// --- Criterion 7: zero accessible spaces ------------------------------------
test("zero accessible spaces yields the dedicated 'no accessible spaces' message", async () => {
const c = makeClient({ spaces: [] });
c.enumerateSpacePages = async () => {
throw make404();
};
await assert.rejects(
() => c.getTree(BAD),
(e) => {
assert.ok(
e.message.includes("доступных тебе спейсов нет"),
"the zero-spaces branch fired",
);
assert.ok(e.message.includes(BAD), "still names the bad spaceId");
return true;
},
);
});
// --- Criterion 8: abort/cap during enrichment -------------------------------
test("an aborted signal (custom/TimeoutError reason) propagates its reason, not a rewrite, and skips the sweep", async () => {
const c = makeClient();
const ac = new AbortController();
const reason = new Error("per-call cap exceeded");
reason.name = "TimeoutError"; // NOT 'AbortError' — hole B
ac.abort(reason);
c.setToolAbortSignal(ac.signal);
// Simulate paginateAll's throwIfAborted(): the inner op rejects with the
// signal's reason (not an AxiosError).
c.enumerateSpacePages = async () => {
throw ac.signal.reason;
};
await assert.rejects(
() => c.getTree(BAD),
(e) => {
assert.equal(e, reason, "the abort/cap reason itself propagates");
assert.equal(e.name, "TimeoutError");
assert.ok(!/не найден среди/.test(e.message ?? ""), "no rewrite");
return true;
},
);
assert.equal(c._spacesFetches, 0, "abort short-circuits before any sweep");
});
// --- Criterion 9: createPage is NOT wrapped (real offline server) -----------
function readBody(req) {
return new Promise((resolve) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => resolve(raw));
});
}
function sendJson(res, status, obj, extra = {}) {
res.writeHead(status, { "Content-Type": "application/json", ...extra });
res.end(JSON.stringify(obj));
}
const openServers = [];
function spawn(handler) {
return new Promise((resolve) => {
const server = http.createServer(handler);
server.listen(0, "127.0.0.1", () => {
openServers.push(server);
const { port } = server.address();
resolve({ baseURL: `http://127.0.0.1:${port}/api` });
});
});
}
after(async () => {
await Promise.all(openServers.map((s) => new Promise((r) => s.close(r))));
});
test("createPage with an inaccessible spaceId returns the server error as-is (NOT wrapped)", async () => {
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/pages/import") {
// The space-permissions 404 createPage would see for a bad spaceId.
sendJson(res, 404, { message: "Space permissions not found" });
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "u@example.com", "pw");
// Prove the diagnostics path is never entered from createPage.
let idxCalls = 0;
const realIdx = client.getAccessibleSpaceIndex.bind(client);
client.getAccessibleSpaceIndex = async () => {
idxCalls++;
return realIdx();
};
await assert.rejects(
() => client.createPage("Title", "body", BAD),
(e) => {
assert.equal(e.response?.status, 404, "the raw server 404 surfaces");
assert.ok(
!/не найден среди/.test(e.message ?? ""),
"createPage's 404 is NOT rewritten (multi-cause path)",
);
return true;
},
);
assert.equal(idxCalls, 0, "createPage never invokes the space diagnostics");
});
// --- formatSpaceNotAccessible unit shape ------------------------------------
test("formatSpaceNotAccessible caps the inline list at 10 and appends a (+N ещё) tail", () => {
const many = Array.from({ length: 13 }, (_, i) => ({
id: `id-${i}`,
name: `Space ${i}`,
}));
const msg = formatSpaceNotAccessible("getTree", BAD, many);
assert.ok(msg.includes("id-0 (Space 0)"));
assert.ok(msg.includes("id-9 (Space 9)"), "10th entry (index 9) is shown");
assert.ok(!msg.includes("id-10 (Space 10)"), "the 11th is collapsed");
assert.ok(msg.includes("(+3 ещё, см. listSpaces)"), "tail counts the remainder");
});