Compare commits

..

25 Commits

Author SHA1 Message Date
claude code agent 227
310b54a6da fix(dictation): address PR #118 review feedback (security, stability, tests)
Implements all reviewer comments (code-review, red-team, and test-strategy
audit), accepting the recommended variants.

Server — realtime service (ai-realtime.service.ts):
- SSRF: pin the validated IP via a WebSocket `lookup` hook that re-checks every
  resolved address with isIpAllowed (mirrors external-mcp buildPinnedDispatcher),
  closing the TOCTOU/DNS-rebinding window; fix the misleading comment.
- no-silent-loss: on Stop, drain the in-flight segment (bounded 2.5s) and deliver
  the final via onFinal before closing instead of dropping the tail.
- fail-closed deriveRealtimeUrl: a non-empty unparseable base now THROWS (no
  silent api.openai.com fallback that would leak a self-hosted key); http://ws://
  bases rejected (plaintext key). Path normalization preserved.
- parseUpstreamEvent keys the accumulator by item_id+content_index so GA segments
  don't concatenate.
- inject a wsFactory seam for testing; also fix a latent bug — `import WebSocket
  from 'ws'` resolved to undefined at runtime (no esModuleInterop) -> import=require.
- unref idle/max/drain timers.

Server — realtime gateway (ai-realtime.gateway.ts, session-limits.ts):
- reject revoked/disabled users and inactive sessions (mirror jwt.strategy:
  findById+isUserDisabled + findActiveById) with NO counter increment.
- CSWSH: Origin allowlist (matching APP_URL, or no Origin for native clients)
  before auth, no increment.
- extract SessionCounters (delete-at-zero, never negative) + pure canConnect
  (both caps >= checked before any increment); document the per-process/in-memory
  cap caveat (single-replica only).

Client:
- dictation-group: realtime final now inserts at the captured rangeRef SNAPSHOT
  (not the live caret) and guards editor.isEditable; single-space separator.
- use-realtime-dictation/realtime-dictation-client: stop-during-acquisition tears
  down the mic (no leak / button reset); reconnect re-emits start (double-start
  guarded); interim ghost cleared on teardown; io() options de-duplicated.
- pcm16-worklet: flush the partial sub-frame tail on stop; one-pole anti-aliasing
  low-pass before 48k->24k.
- extract shared mic-capture (acquireMicStream/mapGetUserMediaError, used by batch
  + realtime), pure DSP (pcm16-dsp.ts), and the session reducer/baseLanguageSubtag;
  extract applyInterimMeta/clampRange/resolveUrl/appendFinalToDraft.

Tests + infra: +~150 server tests (deriveRealtimeUrl, parseUpstreamEvent branches,
openSession/lifecycle/timers/testConnection via fake ws, gateway auth/caps/no-leak,
realtime-test admin contract, AiSettings update/resolve, DTO boolean, SSRF deny)
and +~140 client tests (DSP property/edge, resampler continuity, framing, reducer,
mic-capture, RealtimeDictationClient/MicButton, ProseMirror interim regression +
history guards, appendFinalToDraft, resolveKeyField, route contract). Added
@vitest/coverage-v8. CHANGELOG [Unreleased] entry incl. the single-replica caveat.

Review: APPROVE WITH SUGGESTIONS (no critical/regression); applied the drain-timer
unref. Server tsc clean + 358 tests; client tsc clean + 201 tests; vite build ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:41:37 +03:00
claude_code
7db3f007cb feat(dictation): add realtime streaming STT (live dictation)
Layer an optional realtime speech-to-text path on top of the existing
batch dictation, so transcribed text appears as the user speaks.

Transport A2: browser <-> our server (Socket.IO `/ai-realtime`) <->
OpenAI Realtime (raw ws). The provider API key never leaves the server;
the upstream URL is SSRF-checked before connecting; the gateway enforces
the dictation+dictationRealtime gate, cookie-JWT auth and per-user/
per-workspace concurrency caps. Implemented against the GA (2026) OpenAI
Realtime transcription contract (session.update / audio.input.format /
server_vad), not the now-removed beta shape.

Editor UI B2: interim text is shown as a meta-only ProseMirror ghost
decoration (no Yjs/history noise); only completed segments are committed.
Chat shows interim as a dimmed tail. The mic button switches realtime vs
batch by the workspace flag; batch remains the default and fallback.

Server:
- AiRealtimeService (upstream ws proxy, normalized events, idle/max-
  duration timeouts, idempotent teardown) + parseUpstreamEvent unit tests
- AiRealtimeGateway (Socket.IO `/ai-realtime`) wired into AiChatModule
- admin-gated POST /ai-chat/realtime/test connectivity probe
- config: settings.ai.dictationRealtime + provider sttRealtimeModel/
  sttRealtimeBaseUrl (realtime key reuses sttApiKey; no new secret)

Client:
- pcm16 AudioWorklet (24kHz mono PCM16), RealtimeDictationClient,
  use-realtime-dictation hook (status/start/stop/cancel + onInterim/onFinal)
- RealtimeMicButton + dictation-interim ProseMirror decoration
- editor/chat integration + AI settings UI (toggle, model, test endpoint)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:40:24 +03:00
claude_code
74e2b7ad7f Merge branch 'fix/ai-chat-role-cards-fit' into develop
Fit full role-card description text in the AI chat empty state and show a
generic "AI is typing…" indicator (role name kept only as the dimmed
interlocutor label).
2026-06-21 17:11:56 +03:00
claude_code
a86d0c7c3b fix(ai-chat): always show generic "AI is typing…" indicator
The typing indicator rendered "<role name> is typing…". Show a generic
"AI is typing…" instead and keep the role/identity name only in the
dimmed interlocutor label above the typing dots.

- typing line now always renders t("AI is typing…")
- add the "AI is typing…" key to en-US and ru-RU locales
- sync stale doc comments that referenced the old text

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:11:21 +03:00
claude_code
569da822b6 fix(ai-chat): fit full role-card description text in new-chat empty state
The colored role cards in the AI chat empty state truncated their
admin-configured description with an ellipsis and could clip the top row
when the cards overflowed. Make the full text fit:

- drop the description lineClamp so the whole text renders
- add overflow-wrap: anywhere so long unbreakable tokens (URLs) wrap
- switch the cards container to align-content: flex-start so an
  overflowing top row stays reachable while scrolling (the parent
  Mantine Center still vertically centers the block when it fits)
- widen the card max-width 180px -> 200px for more text room

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:11:21 +03:00
claude_code
4720705155 Merge branch 'docs/review-followups' into develop
Review follow-ups (docs only):
- CHANGELOG [Unreleased]: post-0.93.0 share-AI cap lowered 300->100 (#62)
- backlog: track deferred AiChatService.stream integration coverage
2026-06-21 16:57:13 +03:00
claude_code
ce60498a90 docs: track post-0.93.0 share-AI cap change + deferred stream-coverage debt
Follow-ups from the multi-aspect review of the e5bc82c7..d4658d4c range.

- CHANGELOG: document under [Unreleased] that the default per-workspace
  hourly public-share assistant cap was lowered 300 -> 100 after the
  v0.93.0 tag (#62). v0.93.0 shipped 300, so existing deployments that
  never set SHARE_AI_WORKSPACE_MAX_PER_HOUR drop to 100 on upgrade.
- Recreate the still-open Section 3 (AiChatService.stream integration
  coverage) of the deleted feature-test-coverage-deferred.md as a focused
  backlog doc so the test debt stays tracked; Sections 1-2 are already
  closed by the integration harness (PR #115).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:54:56 +03:00
4a22cc1955 Merge pull request 'feat(ai-chat): role cards start the chat and show role identity' (#121) from feat/ai-chat-role-cards-ux into develop
Reviewed-on: #121
2026-06-21 16:28:51 +03:00
claude_code
b83a5d4597 feat(ai-chat): role cards start the chat and show role identity
Rework the new-chat role-card empty state:
- Remove the "Universal assistant" card; universal assistant is now the
  implicit default the user gets by typing without picking a card.
- Show each role's description on its card (under the emoji and name).
- Clicking a card immediately starts the chat: it binds the role to the
  new chat and sends the default opening prompt "Take a look at the
  current document" (one click, no separate select step). roleIdRef is
  set synchronously before sendMessage so the create request carries the
  role.
- Show the current role's name in the window header badge and as the
  assistant's display name (transcript label + "… is typing…"), falling
  back to "AI agent" for a role-less chat. selectChat resets the picked
  role so it cannot leak into an unrelated existing chat.
- Add the "Take a look at the current document" i18n key (en-US, ru-RU).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:20:36 +03:00
claude_code
d4658d4cb3 Merge pull request '#114 refactor(ai-chat): shared parseNodeArg helper; keep duplication backlog doc' (#114) from refactor/ai-chat-tool-spec-registry into develop
# Conflicts:
#	apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts
2026-06-21 14:45:20 +03:00
claude_code
4105836a2d Merge pull request '#112 test(ai-chat): current-page coverage + getCurrentPage helper' (#112) from feat/ai-chat-current-page-robustness into develop 2026-06-21 14:31:12 +03:00
claude_code
f5a45d5453 Merge pull request '#115 test(server): integration harness + deferred coverage' (#115) from test/deferred-integration-coverage into develop 2026-06-21 14:31:12 +03:00
claude_code
9fad6ab73b Merge pull request '#113 feat(ai-chat): role-selection cards empty-state' (#113) from feat/ai-chat-role-cards into develop 2026-06-21 14:31:11 +03:00
claude_code
194924c3ba Merge pull request '#111 feat(ai-chat): collapse-on-page-focus (remove completed backlog doc)' (#111) from feat/ai-chat-collapse-on-page-focus into develop 2026-06-21 14:31:11 +03:00
claude_code
c7f0b51389 fix(ai-chat): keep tool-duplication backlog doc; fix parseNodeArg comment
Pre-merge review follow-up for the parseNodeArg dedupe (PR #114):
- Restore docs/backlog/ai-chat-tool-definitions-duplicated.md instead of
  deleting it: it still tracks open debt (unified spec registry + ProseMirror
  <-> Markdown converter unification) that this branch defers, and
  docs/git-sync-plan.md links to its converter section. Mark the node-arg
  quirk as done and add a Progress section.
- Reword the in-app helper header from "byte-for-byte" to "behaviorally
  identical": the two copies differ in comments/quote style; only the logic,
  throw messages and branch order match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:30:37 +03:00
claude_code
ebfe56a684 Merge pull request 'chore: finish the 3 remaining open issues (#93 move-snapshot, #62 cap, #109 ru-RU i18n)' (#117) from chore/finish-open-issues into develop 2026-06-21 14:27:02 +03:00
claude_code
e12ddaa2c8 i18n(ai-chat): complete the ru-RU AI-chat strings + record locale policy (#109)
ru-RU was missing most AI-chat keys, so the chat/typing widgets rendered
mixed-language (some keys fell back to en-US). Fill the full AI-chat string
set in ru-RU and document the maintenance policy.

- ru-RU/translation.json: add the 24 missing AI-chat keys (labels, typing
  indicator, Ask-AI widget, public-share, error messages); keep the typing
  keys grouped; existing translations untouched.
- i18n.ts: add a policy comment near fallbackLng — en-US is the source of
  truth; en-US + ru-RU are fully maintained; the other 10 locales
  intentionally rely on the en-US fallback until contributed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:24:18 +03:00
claude_code
6397b500ba fix(share-ai): lower default per-workspace cap to 100 (#62)
The fail-closed limiter behavior (#62 primary item) already shipped; this
finishes the issue by lowering the default hourly per-workspace cap from 300
to 100 to better fit real anonymous-assistant load. Still overridable via
SHARE_AI_WORKSPACE_MAX_PER_HOUR.

- public-share-workspace-limiter.ts: SHARE_AI_WORKSPACE_MAX_PER_WINDOW 300 -> 100.
- .env.example: documented default + example value 300 -> 100.
- public-share-chat.spec.ts: update the default-cap assertion to 100.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:24:18 +03:00
claude_code
c3161a05dd refactor(ws): single-snapshot move audience to close the restricted-move race (#93)
Implements Option 2 of #93. The restricted branch of broadcastPageMoved
previously resolved its audience twice — emitToAuthorizedUsers and
emitDeleteToUnauthorized each ran an independent fetchSockets +
getUserIdsWithPageAccess — leaving a race window between the two snapshots
where a socket could receive both the move and the delete (leak) or neither
(lost compensating delete).

- ws.service.ts: add emitMoveWithRestrictionSplit() that takes ONE socket
  snapshot and ONE authorization resolution, then partitions the room:
  authorized users get the moveTreeNode, everyone else (unauthorized +
  anonymous) get the compensating deleteTreeNode. Disjoint + complete by
  construction. Remove the now-unused emitToAuthorizedUsers /
  emitDeleteToUnauthorized; keep private broadcastToAuthorizedUsers (still
  used by emitRestrictedAwareToSpace).
- ws-tree.service.ts: broadcastPageMoved restricted branch now drives move +
  delete from the single method.
- specs: assert the single method is used and that fetchSockets /
  getUserIdsWithPageAccess are each called exactly once (single snapshot);
  re-route ws-service.spec to emitTreeEvent after the method removal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:24:18 +03:00
claude_code
06bfca5fdb docs(changelog): 0.93.0 release notes 2026-06-21 14:09:44 +03:00
claude code agent 227
04f05626ad test(server): integration harness + deferred coverage vs real Postgres/Redis
Builds the deferred integration tests from docs/backlog/feature-test-coverage-
deferred.md that needed real infra (a test Postgres + real Redis) which the repo
lacked. Runs against an isolated, auto-created docmost_test database and Redis
logical DB 15 — never the dev data.

Harness (apps/server/test/integration/, run via new `pnpm --filter server test:int`
=> jest --config test/jest-integration.json; default unit `jest` is untouched and
excludes these via the *.int-spec.ts name + rootDir):
- db.ts: buildTestDb() mirrors database.module.ts exactly (PostgresJSDialect,
  CamelCasePlugin, bigint to:20/from:[20,1700] parsing) + minimal seed helpers.
- global-setup.ts: DROP/CREATE docmost_test, CREATE EXTENSION vector, migrate to
  latest via Kysely Migrator (fails loud on any errored migration).
- global-teardown.ts: closes the pool.

Coverage (5 suites, 16 tests, all green against live PG+Redis):
- WorkspaceRepo.updateSetting: jsonb-merge persists htmlEmbed without clobbering
  sibling ai/sharing namespaces (the kill-switch write half).
- AiAgentRoleRepo: soft-delete exclusion, cross-workspace tenant isolation,
  duplicate (name,workspace) -> 23505, name reusable after softDelete (partial
  unique index WHERE deleted_at IS NULL), same name across workspaces allowed.
- page_template_references: deleting either source or referenced page cascades
  the link row (onDelete cascade) — real FK, not mocked.
- PublicShareWorkspaceLimiter vs REAL Redis: real ioredis EVAL of the sliding-
  window Lua — max boundary (3 admit / 4th deny), re-admit after the window
  slides, same-ms distinct members. Catches Lua bugs a FakeRedis cannot.
- AiChatRepo.findByCreator: role-badge join (enabled->badge; soft-deleted or
  disabled role -> null).

Review: APPROVE; applied its two hardening suggestions (fail loud on errored
migration result even without a top-level error; TEST_REDIS_URL override + ping
preflight). tsc clean; unit run excludes int-spec (verified).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 07:02:55 +03:00
claude code agent 227
f9757fda12 refactor(ai-chat): dedupe node-arg JSON normalization into a shared helper
First, safe step of docs/backlog/ai-chat-tool-definitions-duplicated.md: the
"node may be a JSON object OR a JSON string" quirk was hand-copied at 6 tool
sites. Extract it into a single parseNodeArg() helper per package and call it at
every site. Behavior-preserving — each site's throw message is byte-identical
(patch/insert: 'node was a string but not valid JSON'; update_page_json: 'content
was a string but not valid JSON'); no tool name/description/schema changed.

Two helper copies (packages/mcp/src/lib/parse-node-arg.ts and
apps/server/src/core/ai-chat/tools/parse-node-arg.ts) are intentional: the
ESM-only @docmost/mcp cannot be imported by the CommonJS server (it is loaded at
runtime via the Function('import()') trick), so runtime code cannot cross that
boundary by a normal import. Each copy is now the single source within its
package (6 inline copies -> 2 helpers). packages/mcp/build rebuilt in sync.

Tests: parse-node-arg.spec.ts (server, Jest) + parse-node-arg.test.mjs (mcp,
node:test) — object passthrough, valid-string parse, invalid-string throw with
the right message. Server tsc clean; mcp suite 254 pass; agent structural-edit
path verified live in-browser (agent inserted a node, persisted to the doc).

Deferred (documented for the record, since the backlog doc is removed with this
commit): the FULL transport-agnostic tool-spec registry (one name+schema+
description per tool shared by both transports) and deriving DocmostClientLike
from the real client type. Both are blocked by the current architecture, not by
effort: (1) @docmost/mcp ships no type declarations and is ESM-only, so a
type-only derivation needs declaration emission + tsconfig path wiring, and the
real client's precise return types break the in-app tool test stubs (attempted,
reverted to keep tsc green); (2) the two transports intentionally DIVERGE in tool
NAMES (snake_case x38 vs camelCase x41), membership (in-app adds getCurrentPage/
listSidebarPages, omits delete_comment/image tools) and model-facing
DESCRIPTIONS, so a unified registry would change behavior on BOTH the agent and
external MCP clients and needs its own verification pass. This is forward-looking
debt (the code is correct today), to be done incrementally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 06:51:09 +03:00
claude code agent 227
19cd73a5aa feat(ai-chat): role-selection cards as new-chat empty-state
Replace the new-chat <Select label="Agent role"> picker with colored role
cards rendered as the empty-state of a brand-new chat (centered in the window),
per docs/backlog/ai-chat-role-cards-empty-state.md. Clicking a card selects that
identity; sending without a pick falls back to the Universal assistant; the
cards disappear once the chat is non-empty. Purely client-side — the existing
selectedAiRoleIdAtom + roleId request wiring (server role fixation on chat
creation) is unchanged.

- new RoleCards rendered through the existing emptyState prop chain
  (AiChatWindow -> ChatThread -> MessageList); MessageList already supported it.
- Universal assistant card (gray, value null, default-selected) + one card per
  enabled role, color cycled from a 10-name Mantine palette via the pure
  roleCardColor() helper; theme-aware CSS vars (light/-light-color/-filled).
- each card is an UnstyledButton with aria-pressed for a11y + testability.
- tests: role-card-color (palette cycling, negative-safe) + role-cards.test.tsx
  (render, emoji/name, selection highlight, click -> onSelect). 9 tests green,
  client tsc clean.

Verified live in-browser: cards (not a Select) show for a new chat; selecting
Пират binds the chat to that role end-to-end (badge + pirate reply); no pick =>
Universal; cards vanish after the first message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 06:32:16 +03:00
claude code agent 227
e6b1170553 test(ai-chat): cover current-page injection; extract resolveCurrentPageResult
The 'current page' feature (client useMatch openPage + server getCurrentPage
tool + system-prompt injection) was already implemented & merged; this backfills
its missing test coverage and removes the completed backlog doc.

- extract pure resolveCurrentPageResult(openedPage) into current-page.util.ts
  (byte-identical to the prior inline getCurrentPage tool body) so it is
  unit-testable without the dynamically-imported ESM Docmost client; the tool
  now delegates to it.
- current-page.util.spec.ts: 7 cases (null/undefined/no-id/empty-id/full/no-title).
- ai-chat.prompt.spec.ts: +8 cases for the openedPage context line (title+pageId
  present, Untitled fallback for blank/whitespace title, no line when absent/blank
  id, and sandwich ordering before the trailing safety block).

Verified live in-browser: client sends openPage{id,title} on a page and null
off-page; the agent invokes getCurrentPage and answers with the real title+id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 06:21:38 +03:00
claude code agent 227
2e0f4456e1 docs: remove completed backlog doc for ai-chat collapse-on-page-focus
The feature is already implemented and merged into develop (f6e216cb):
auto-collapse the AI chat window into its header on outside-page pointer,
expand on header click, with keyboard a11y. Verified live in-browser and
covered by collapse-helpers.test.ts (9 tests). Removing the now-completed
planning doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 06:11:52 +03:00
48 changed files with 1639 additions and 896 deletions

View File

@@ -147,8 +147,8 @@ MCP_DOCMOST_PASSWORD=
# per-IP limit is fully evaded. It is a COST backstop, not an access control, and
# FAILS CLOSED if Redis is unavailable (an optional assistant briefly going
# offline is safer than an unbounded bill). Override the hourly cap below
# (default: 300 calls per workspace per rolling hour).
# SHARE_AI_WORKSPACE_MAX_PER_HOUR=300
# (default: 100 calls per workspace per rolling hour).
# SHARE_AI_WORKSPACE_MAX_PER_HOUR=100
#
# Per-request output-token ceiling for the anonymous assistant (default: 512).
# Worst-case output per accepted call = agent steps (5) × this value.

View File

@@ -10,8 +10,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- **Public share AI: default per-workspace hourly assistant cap lowered
300 → 100.** The limiter falls back to this default whenever
`SHARE_AI_WORKSPACE_MAX_PER_HOUR` is unset, so a `0.93.0` deployment that
never set the env var has its anonymous public-share assistant hourly cap
cut from 300 to 100 on upgrade. Set `SHARE_AI_WORKSPACE_MAX_PER_HOUR` to
keep the previous limit. (#62)
## [0.93.0] - 2026-06-21
This release builds on the 0.91.0 AI foundation: admin-defined AI agent roles,
an anonymous AI assistant on public shares, server-side voice dictation, an
editor footnotes model, live page-template embeds, and sandboxed arbitrary-HTML
embeds — plus a large batch of security hardening and test coverage.
### Breaking Changes
- **MCP shared-token auth moved to its own header.** The `/mcp` shared guard
no longer reads `Authorization: Bearer <MCP_TOKEN>`; it now reads only the
`X-MCP-Token` header. The `Authorization` header is now reserved for per-user
HTTP Basic / Bearer access-JWT credentials, so each `/mcp` request
authenticates as a specific user (the `MCP_DOCMOST_*` service account is only
a fallback). Existing MCP clients (e.g. Claude Desktop) configured with
`Authorization: Bearer <MCP_TOKEN>` must be reconfigured to send
`X-MCP-Token: <MCP_TOKEN>` instead. See `MCP_TOKEN` in `.env.example`. As a
one-time aid, the server logs a single migration warning when it sees the
old-style header.
### Added
- **AI agent roles**: admin-defined assistant personas with an optional
per-role model override, selectable in chat.
- **Anonymous AI assistant on public shares**: public-share visitors can chat
with a selectable agent-role identity that reuses the internal chat
presentation, with per-request output-token caps and a fail-closed Redis
limiter.
- **Voice dictation (STT)**: server-side speech-to-text with a mic button in
the chat and the editor, OpenRouter STT support, an endpoint test, and real
provider-error surfacing.
- **Realtime streaming dictation**: a new live-dictation mic mode layered on top
of the existing batch STT. Audio streams over a dedicated `/ai-realtime`
Socket.IO namespace and text is inserted as you speak (interim partials shown
@@ -26,9 +64,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
balancing) lets a user or workspace exceed these caps, since each process
counts only its own sessions. Treat the limits as per-process until the
counters are moved to a shared store.
- Admin-only "Analytics / tracker" workspace setting: a raw HTML/JS snippet
- **Footnotes**: an editor footnotes model (inline references + a definitions
list).
- **Page templates**: live whole-page embed (MVP) with a template-marker icon
in the page tree and a working Refresh action.
- **Arbitrary HTML/CSS/JS embeds**: a sandboxed-iframe embed block gated by a
per-workspace toggle (default OFF); insertable by any member when the toggle
is on.
- Admin-only **"Analytics / tracker"** workspace setting: a raw HTML/JS snippet
injected into the `<head>` of public share pages only (for analytics such as
Google Analytics or Yandex.Metrika).
Google Analytics or Yandex.Metrika), kept separate from the member-facing
HTML-embed feature.
- **MCP**: a hierarchical tree mode for `list_pages`, and per-user auth for the
embedded `/mcp` endpoint.
- **Page tree**: Expand all / Collapse all for the space tree, and
server-authoritative realtime tree updates.
- **AI chat UX**: a `get_current_page` tool for proxy-robust page context, a
current-context-size readout, an agent step cap raised 8→20 with a forced
final text answer, and auto-collapse of the chat window on page focus.
- **AI settings**: a Clear control inside the API-key field and an endpoint
status dot bound to "configured × enabled".
- **Client**: an always-visible space grid replacing the space-switcher popover,
removal of the sidebar Overview item, tighter comments-panel density, and no
auto-open of the comments panel when adding a comment.
### Changed
@@ -42,16 +100,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
server-side strip is the public-share read path, which still honors the
workspace HTML-embed toggle.
### Breaking Changes
### Fixed
- **MCP shared-token auth moved to its own header.** The `/mcp` shared guard
no longer reads `Authorization: Bearer <MCP_TOKEN>`; it now reads only the
`X-MCP-Token` header. Existing MCP clients (e.g. Claude Desktop) configured
with `Authorization: Bearer <MCP_TOKEN>` must be reconfigured to send
`X-MCP-Token: <MCP_TOKEN>` instead. The `Authorization` header is now
reserved for per-user HTTP Basic / Bearer access JWT credentials. See
`MCP_TOKEN` in `.env.example`. As a one-time aid, the server logs a single
migration warning when it sees the old-style header.
- AI chat: preserve scroll position during streaming, record chats that fail on
their first turn, and resolve the current page for agent context behind
proxies.
- AI roles: guard `update()` against concurrent soft-delete; harden the model
override, role-name uniqueness, and id validation; sandwich the safety
framework around the role persona.
- Auth: handle null-password (SSO/LDAP-only) accounts without a bcrypt throw.
- Footnotes: survive duplicate-id definitions without collab divergence.
- HTML embed: fix stale iframe height and damp the resize loop; strip embeds at
serve time on authenticated read paths and the plain page-create path.
- Page templates: import `ThrottleModule` so collab boots, never strand an
in-flight page-embed id, and add defense-in-depth workspace checks.
- Pages: `movePage` cycle guard with no phantom `PAGE_MOVED` event.
- Import: surface the real error cause from `/pages/import` instead of a generic
400.
### Security
- MCP: close an SSO/MFA bypass on Basic auth and stop minting non-init sessions;
close a brute-force limiter check-then-act race.
- Public share: block restricted descendants in the anonymous assistant, cap
per-request output, fail closed when Redis is unavailable, and reject non-text
message parts to close a size-cap bypass.
- Make `trustProxy` env-configurable with a safe default.
### Internal
- CI: gate the `develop` and release image builds on the test suite, run the
suites on push/PR, and build the `:develop` image on push to `develop`.
- Docs: replace `CLAUDE.md` with `AGENTS.md` codifying the agent workflow and
the release procedure, add migration-ordering guidance, and prune implemented
plans.
- A large batch of new server/client test coverage.
## [0.91.0] - 2026-06-18
@@ -135,5 +218,6 @@ knowledge layer, an embedded MCP server, and the Gitmost rebrand.
- Build: drop the private EE submodule, retarget CI to GHCR, and update the
Docker image to the GHCR registry.
[Unreleased]: https://github.com/vvzvlad/gitmost/compare/v0.91.0...HEAD
[Unreleased]: https://github.com/vvzvlad/gitmost/compare/v0.93.0...HEAD
[0.93.0]: https://github.com/vvzvlad/gitmost/compare/v0.91.0...v0.93.0
[0.91.0]: https://github.com/vvzvlad/gitmost/compare/v0.90.1...v0.91.0

View File

@@ -1144,8 +1144,10 @@
"Minimize": "Minimize",
"Current context size": "Current context size",
"AI agent": "AI agent",
"Take a look at the current document": "Take a look at the current document",
"AI agent is typing…": "AI agent is typing…",
"{{name}} is typing…": "{{name}} is typing…",
"AI is typing…": "AI is typing…",
"Send": "Send",
"Stop": "Stop",
"Chat menu": "Chat menu",

View File

@@ -669,8 +669,34 @@
"AI Answer": "Ответ ИИ",
"Ask AI": "Спросить ИИ",
"AI agent": "AI-агент",
"Take a look at the current document": "Посмотри текущий документ",
"AI agent is typing…": "AI-агент печатает…",
"{{name}} is typing…": "{{name}} печатает…",
"AI is typing…": "AI печатает…",
"Agent role": "Роль агента",
"AI chat": "AI-чат",
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
"Ask a question…": "Задайте вопрос…",
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
"Ask the AI agent…": "Спросите AI-агента…",
"Copy chat": "Копировать чат",
"Created successfully": "Успешно создано",
"Current context size": "Текущий размер контекста",
"Delete this chat?": "Удалить этот чат?",
"Deleted successfully": "Успешно удалено",
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
"Failed to delete chat": "Не удалось удалить чат",
"Failed to rename chat": "Не удалось переименовать чат",
"Minimize": "Свернуть",
"No chats yet.": "Чатов пока нет.",
"Send": "Отправить",
"Something went wrong": "Что-то пошло не так",
"Stop": "Стоп",
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
"Universal assistant": "Универсальный ассистент",
"You": "Вы",
"AI is thinking...": "ИИ обрабатывает запрос...",
"Thinking": "Думаю",
"Ask a question...": "Задайте вопрос...",

View File

@@ -6,7 +6,7 @@ import {
useRef,
useState,
} from "react";
import { Group, Loader, Select, Tooltip } from "@mantine/core";
import { Group, Loader, Tooltip } from "@mantine/core";
import {
IconArrowsDiagonal,
IconCheck,
@@ -145,6 +145,7 @@ export default function AiChatWindow() {
() => (roles ?? []).filter((r) => r.enabled === true),
[roles],
);
const { data: messageRows, isLoading: messagesLoading } =
useAiChatMessagesQuery(activeChatId ?? undefined);
@@ -178,8 +179,11 @@ export default function AiChatWindow() {
setActiveChatId(chatId);
setHistoryOpen(false);
setDraft("");
// Reset the card-picked role so a stale pick can't leak into the existing
// chat's header/assistant-name (which prefers the chat's persisted role).
setSelectedRoleId(null);
},
[setActiveChatId, setDraft],
[setActiveChatId, setDraft, setSelectedRoleId],
);
// After a turn finishes, refresh the chat list. For a brand-new chat (no id
@@ -199,6 +203,18 @@ export default function AiChatWindow() {
);
const canExport = !!activeChatId && !!messageRows && messageRows.length > 0;
// The role to display in the header and as the assistant's name. Prefer the
// persisted role of an existing chat (chat-list JOIN); fall back to the role
// picked via a card click for a brand-new or just-adopted chat. selectChat
// resets selectedRoleId, so this fallback never leaks into an unrelated chat.
const currentRole = useMemo<{ name: string; emoji: string | null } | null>(() => {
if (activeChat?.roleName) {
return { name: activeChat.roleName, emoji: activeChat.roleEmoji ?? null };
}
const picked = enabledRoles.find((r) => r.id === selectedRoleId);
return picked ? { name: picked.name, emoji: picked.emoji } : null;
}, [activeChat, enabledRoles, selectedRoleId]);
// Build a Markdown export from the already-loaded persisted rows (no network
// call) and copy it to the clipboard. The "Copied" notification is the
// feedback.
@@ -430,12 +446,13 @@ export default function AiChatWindow() {
{t("AI chat")}
</span>
{/* Role badge for the active chat (emoji + name). Shown only when the
chat is bound to a role that still exists. */}
{activeChat?.roleName && (
{/* Role badge (emoji + name). Shows the persisted role of an existing
chat, or the role picked via a card for a brand-new chat. Hidden for
a universal (no-role) chat. */}
{currentRole && (
<span className={classes.badge} title={t("Agent role")}>
{activeChat.roleEmoji ? `${activeChat.roleEmoji} ` : ""}
{activeChat.roleName}
{currentRole.emoji ? `${currentRole.emoji} ` : ""}
{currentRole.name}
</span>
)}
@@ -537,28 +554,10 @@ export default function AiChatWindow() {
)}
</div>
{/* Role picker — only for a NEW chat (before it is created). Once the
chat exists, its role is fixed and shown as a header badge instead.
Defaults to "Universal assistant" (no role). */}
{activeChatId === null && (enabledRoles?.length ?? 0) > 0 && (
<div style={{ padding: "4px 8px 0" }}>
<Select
size="xs"
label={t("Agent role")}
value={selectedRoleId ?? ""}
onChange={(value) => setSelectedRoleId(value || null)}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
data={[
{ value: "", label: t("Universal assistant") },
...enabledRoles.map((r) => ({
value: r.id,
label: `${r.emoji ? `${r.emoji} ` : ""}${r.name}`,
})),
]}
/>
</div>
)}
{/* The role picker for a NEW chat is rendered as the chat's empty-state
(colored role cards centered in the empty window) by ChatThread
itself — clicking a card starts the chat with that role. Once the
chat exists, its role is fixed and shown as a header badge instead. */}
{/* body: active chat thread */}
<div className={classes.body}>
@@ -574,6 +573,11 @@ export default function AiChatWindow() {
openPage={openPage}
// Honoured only for a new chat; null = universal assistant.
roleId={activeChatId === null ? selectedRoleId : null}
// Role cards are the new-chat empty-state; offered only when this
// is a brand-new chat. Clicking a card starts the chat with it.
roles={activeChatId === null ? enabledRoles : undefined}
onRolePicked={(role) => setSelectedRoleId(role.id)}
assistantName={currentRole?.name}
onTurnFinished={onTurnFinished}
/>
)}

View File

@@ -7,7 +7,11 @@ import { useChat, type UIMessage } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import MessageList from "@/features/ai-chat/components/message-list.tsx";
import ChatInput from "@/features/ai-chat/components/chat-input.tsx";
import { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
import RoleCards from "@/features/ai-chat/components/role-cards.tsx";
import {
IAiChatMessageRow,
IAiRole,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
@@ -29,6 +33,15 @@ interface ChatThreadProps {
* in the request body so the server persists it on chat creation; ignored by
* the server for existing chats (the role is read from the chat row). */
roleId?: string | null;
/** Enabled roles for the new-chat empty state (only meaningful when
* `chatId === null`). Rendered as the colored role cards. */
roles?: IAiRole[];
/** Notify the parent which role was picked via a card, so it can update the
* header badge / assistant name for the brand-new chat. */
onRolePicked?: (role: IAiRole) => void;
/** Display name for the assistant label / typing line (the role name);
* forwarded to MessageList. Absent => the generic "AI agent". */
assistantName?: string;
/** Called when a turn finishes; the parent refreshes the chat list and, for
* a new chat, adopts the freshly created chat id. */
onTurnFinished: () => void;
@@ -66,6 +79,9 @@ export default function ChatThread({
initialRows,
openPage,
roleId,
roles,
onRolePicked,
assistantName,
onTurnFinished,
}: ChatThreadProps) {
const { t } = useTranslation();
@@ -159,9 +175,28 @@ export default function ChatThread({
const isStreaming = status === "submitted" || status === "streaming";
// Clicking a role card both binds the role to THIS new chat and immediately
// starts the conversation. roleIdRef is set synchronously here because the
// parent's selectedRoleId state update would only reach roleIdRef on the next
// render — after this synchronous sendMessage has already read it.
const handleRolePick = (role: IAiRole): void => {
roleIdRef.current = role.id;
onRolePicked?.(role);
sendMessage({ text: t("Take a look at the current document") });
};
const showRoleCards = chatId === null && (roles?.length ?? 0) > 0;
const roleCardsEmptyState = showRoleCards ? (
<RoleCards roles={roles ?? []} onPick={handleRolePick} />
) : undefined;
return (
<Box className={classes.panel}>
<MessageList messages={messages} isStreaming={isStreaming} />
<MessageList
messages={messages}
isStreaming={isStreaming}
emptyState={roleCardsEmptyState}
assistantName={assistantName}
/>
{error && (
<Alert

View File

@@ -43,7 +43,7 @@ interface MessageListProps {
const BOTTOM_THRESHOLD = 40;
/**
* Whether to show the standalone "AI agent is typing…" indicator. It bridges the
* Whether to show the standalone "AI is typing…" indicator. It bridges the
* gap between sending and the first streamed content, so it shows only while a
* turn is in flight AND the latest assistant message has nothing visible yet:
* - the last message is still the user's (assistant hasn't started a row), or

View File

@@ -0,0 +1,61 @@
/* Layout only — per-card colors are injected inline via Mantine CSS vars. */
.container {
display: flex;
flex-wrap: wrap;
justify-content: center;
/* flex-start keeps the first row reachable when the wrapped cards overflow and
the container scrolls. With align-content: center, an overflowing top row is
pushed out of the scrollable area and becomes unreachable. The parent Mantine
Center still vertically centers the whole block when it fits. */
align-content: flex-start;
gap: 10px;
/* Cap the height so a large number of roles scrolls instead of blowing out
the empty chat area. */
max-height: 100%;
overflow-y: auto;
padding: 8px;
}
.card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 140px;
max-width: 200px;
min-height: 90px;
padding: 12px 10px;
border-radius: var(--mantine-radius-md);
border: 2px solid transparent;
cursor: pointer;
text-align: center;
transition:
transform 120ms ease,
box-shadow 120ms ease,
border-color 120ms ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--mantine-shadow-sm);
}
.emoji {
font-size: 22px;
line-height: 1;
}
/* The description: small and slightly muted, inheriting the card's color. We
reduce opacity instead of using Mantine's `c="dimmed"` so it doesn't clash
with the card's inline color. */
.description {
opacity: 0.8;
line-height: 1.3;
/* Break long unbreakable tokens (URLs, long foreign words) in the
admin-configured description so they wrap instead of overflowing the card
width now that the line clamp no longer caps the text. */
overflow-wrap: anywhere;
}

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeAll } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import RoleCards from "./role-cards";
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
// MantineProvider reads window.matchMedia (color scheme) on mount, which jsdom
// does not implement. Provide a minimal stub so the provider can render.
beforeAll(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}),
});
});
const roles: IAiRole[] = [
{
id: "r1",
name: "Pirate",
emoji: "🏴‍☠️",
description: "Talks like a pirate",
enabled: true,
},
{
id: "r2",
name: "Grandpa",
emoji: null,
description: null,
enabled: true,
},
];
function renderCards(onPick = vi.fn()) {
render(
<MantineProvider>
<RoleCards roles={roles} onPick={onPick} />
</MantineProvider>,
);
return onPick;
}
describe("RoleCards", () => {
it("renders one card per role with name, emoji, and description", () => {
renderCards();
expect(screen.getByText("Pirate")).toBeDefined();
expect(screen.getByText("Talks like a pirate")).toBeDefined();
expect(screen.getByText("Grandpa")).toBeDefined();
// The emoji is shown for the role that has one.
expect(screen.getByText("🏴‍☠️")).toBeDefined();
});
it("does NOT render a Universal assistant card", () => {
renderCards();
expect(screen.queryByText("Universal assistant")).toBeNull();
});
it("calls onPick with the role object when a card is clicked", () => {
const onPick = renderCards();
fireEvent.click(screen.getByText("Pirate"));
expect(onPick).toHaveBeenCalledWith(roles[0]);
});
});

View File

@@ -0,0 +1,78 @@
import { UnstyledButton, Text } from "@mantine/core";
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
import { roleCardColor } from "@/features/ai-chat/utils/role-card-color.ts";
import classes from "@/features/ai-chat/components/role-cards.module.css";
interface RoleCardsProps {
/** The enabled roles to render (one card each). */
roles: IAiRole[];
/** Called with the picked role when a card is clicked. The parent starts the
* chat with this role (binds it and sends the opening message). */
onPick: (role: IAiRole) => void;
}
/**
* One role card. Colors are injected inline via theme-aware Mantine CSS vars so
* they render correctly in both light and dark themes; the CSS module owns only
* the layout. The card shows the emoji (if any), the role name, and a small
* dimmed description line (if any).
*/
function RoleCard({
color,
name,
emoji,
description,
onClick,
}: {
color: string;
name: string;
emoji?: string | null;
description?: string | null;
onClick: () => void;
}) {
return (
<UnstyledButton
className={classes.card}
style={{
backgroundColor: `var(--mantine-color-${color}-light)`,
color: `var(--mantine-color-${color}-light-color)`,
}}
title={description ?? name}
onClick={onClick}
>
{emoji && <span className={classes.emoji}>{emoji}</span>}
<Text size="sm" fw={600} lineClamp={2}>
{name}
</Text>
{description && (
<Text size="xs" className={classes.description}>
{description}
</Text>
)}
</UnstyledButton>
);
}
/**
* Colored role cards rendered as the empty-state of a brand-new chat. There is
* no Universal assistant card — the universal assistant is the implicit default
* the user gets by simply typing into the composer without picking a card.
* Clicking a card immediately STARTS the chat with that role (the parent binds
* the role to the new chat and sends the opening message).
*/
export default function RoleCards({ roles, onPick }: RoleCardsProps) {
return (
<div className={classes.container}>
{roles.map((role, index) => (
<RoleCard
key={role.id}
color={roleCardColor(index)}
name={role.name}
emoji={role.emoji}
description={role.description}
onClick={() => onPick(role)}
/>
))}
</div>
);
}

View File

@@ -5,7 +5,7 @@ import { showTypingIndicator } from "@/features/ai-chat/components/message-list.
/**
* Pure-helper tests for the typing-indicator bridging logic that the internal
* chat and the public share widget now share. This is the behavior that decides
* whether the animated "AI agent is typing…" placeholder shows in the gap
* whether the animated "AI is typing…" placeholder shows in the gap
* between sending and the first streamed token.
*/
const msg = (

View File

@@ -19,8 +19,10 @@ interface TypingIndicatorProps {
* the real assistant message once content starts arriving.
*
* Mirrors the assistant row layout in MessageItem (the dimmed label), so it reads
* as the assistant's bubble taking shape. The label and typing line use the
* configured identity name when provided, otherwise the generic "AI agent".
* as the assistant's bubble taking shape. The dimmed label uses the configured
* identity name when provided (otherwise the generic "AI agent"), while the
* typing line is always the generic "AI is typing…" (it never includes the
* role/identity name).
*/
export default function TypingIndicator({ assistantName }: TypingIndicatorProps) {
const { t } = useTranslation();
@@ -38,7 +40,7 @@ export default function TypingIndicator({ assistantName }: TypingIndicatorProps)
<span />
</span>
<Text size="sm" c="dimmed">
{name ? t("{{name}} is typing…", { name }) : t("AI agent is typing…")}
{t("AI is typing…")}
</Text>
</Group>
</Box>

View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { ROLE_CARD_PALETTE, roleCardColor } from "./role-card-color";
describe("roleCardColor", () => {
it("has a 10-color palette", () => {
expect(ROLE_CARD_PALETTE).toHaveLength(10);
});
it("maps index 0 to the first palette color (blue)", () => {
expect(roleCardColor(0)).toBe("blue");
expect(roleCardColor(1)).toBe("grape");
});
it("wraps around at the end of the palette", () => {
expect(roleCardColor(10)).toBe("blue");
expect(roleCardColor(11)).toBe("grape");
});
it("is safe for negative indices", () => {
expect(roleCardColor(-1)).toBe("violet");
expect(roleCardColor(-10)).toBe("blue");
});
});

View File

@@ -0,0 +1,25 @@
// Fixed Mantine color palette for the new-chat role cards. Cards cycle through
// these names by index; the colors are applied via theme-aware Mantine CSS vars
// (`--mantine-color-<name>-light` etc.) so they are correct in both themes.
// Universal assistant uses neutral `gray` separately (not part of this palette).
export const ROLE_CARD_PALETTE = [
"blue",
"grape",
"teal",
"orange",
"pink",
"cyan",
"lime",
"indigo",
"red",
"violet",
] as const;
/**
* Pick a palette color name for a role card by its index. Cycles through the
* palette and is safe for negative indices.
*/
export function roleCardColor(index: number): string {
const len = ROLE_CARD_PALETTE.length;
return ROLE_CARD_PALETTE[((index % len) + len) % len];
}

View File

@@ -12,6 +12,15 @@ i18n
// init i18next
// for all options read: https://www.i18next.com/overview/configuration-options
.init({
// i18n maintenance policy:
// - en-US is the source of truth for all UI strings (keys are the English text).
// - en-US and ru-RU are the fully-maintained locales; in particular, the
// AI-chat string set is kept complete in both so the UI never renders
// mixed-language (no per-key en-US fallback within a single widget).
// - The other 10 locales (fr-FR, de-DE, es-ES, nl-NL, ja-JP, zh-CN, ko-KR,
// pt-BR, it-IT, uk-UA) are partial and intentionally rely on the
// `fallbackLng: "en-US"` fallback below until translations are
// contributed (e.g. via Crowdin).
fallbackLng: "en-US",
debug: false,
showSupportNotice: false,

View File

@@ -25,6 +25,7 @@
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"pretest": "pnpm --filter @docmost/editor-ext build",
"test": "jest",
"test:int": "jest --config test/jest-integration.json",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",

View File

@@ -82,3 +82,82 @@ describe('buildSystemPrompt role layering', () => {
expect(prompt).toContain(SAFETY_MARKER);
});
});
/**
* Unit tests for the "current page" context injected by buildSystemPrompt. When
* the client supplies an openedPage with a non-blank id, a CONTEXT line names
* the page (title or "Untitled") and its pageId so the agent can resolve "this
* page". When no usable id is present, nothing is added. The line always sits
* inside the safety sandwich, before the trailing SAFETY copy.
*/
describe('buildSystemPrompt current-page context', () => {
const workspace = { name: 'Acme' } as unknown as Workspace;
const SAFETY_MARKER = 'Operating rules (always in effect)';
it('includes the page title and pageId when both are present', () => {
const prompt = buildSystemPrompt({
workspace,
openedPage: { id: 'pg-123', title: 'Audio Tract' },
});
expect(prompt).toContain('currently viewing the page');
expect(prompt).toContain('pageId: pg-123');
expect(prompt).toContain('"Audio Tract"');
});
it('falls back to "Untitled" when the title is missing', () => {
const prompt = buildSystemPrompt({
workspace,
openedPage: { id: 'pg-123' },
});
expect(prompt).toContain('pageId: pg-123');
expect(prompt).toContain('"Untitled"');
});
it('falls back to "Untitled" when the title is only whitespace', () => {
const prompt = buildSystemPrompt({
workspace,
openedPage: { id: 'pg-123', title: ' ' },
});
expect(prompt).toContain('pageId: pg-123');
expect(prompt).toContain('"Untitled"');
});
it('adds no page context when openedPage is null', () => {
const prompt = buildSystemPrompt({ workspace, openedPage: null });
expect(prompt).not.toContain('currently viewing the page');
expect(prompt).not.toContain('pageId:');
});
it('adds no page context when openedPage is omitted', () => {
const prompt = buildSystemPrompt({ workspace });
expect(prompt).not.toContain('currently viewing the page');
expect(prompt).not.toContain('pageId:');
});
it('adds no page context when openedPage has no id', () => {
const prompt = buildSystemPrompt({ workspace, openedPage: { title: 'x' } });
expect(prompt).not.toContain('currently viewing the page');
expect(prompt).not.toContain('pageId:');
});
it('adds no page context when the id is only whitespace', () => {
const prompt = buildSystemPrompt({
workspace,
openedPage: { id: ' ' },
});
expect(prompt).not.toContain('currently viewing the page');
expect(prompt).not.toContain('pageId:');
});
it('places the page context inside the safety sandwich (before the closing SAFETY)', () => {
const prompt = buildSystemPrompt({
workspace,
openedPage: { id: 'pg-123', title: 'Audio Tract' },
});
const pageIdx = prompt.indexOf('currently viewing the page');
const firstSafety = prompt.indexOf(SAFETY_MARKER);
const lastSafety = prompt.lastIndexOf(SAFETY_MARKER);
expect(pageIdx).toBeGreaterThan(firstSafety);
expect(pageIdx).toBeLessThan(lastSafety);
});
});

View File

@@ -386,7 +386,7 @@ describe('resolveShareAiWorkspaceMax (env-overridable per-workspace cap)', () =>
it('falls back to the default for an unparseable / NaN value', () => {
process.env[ENV] = 'not-a-number';
expect(resolveShareAiWorkspaceMax()).toBe(SHARE_AI_WORKSPACE_MAX_PER_WINDOW);
expect(SHARE_AI_WORKSPACE_MAX_PER_WINDOW).toBe(300);
expect(SHARE_AI_WORKSPACE_MAX_PER_WINDOW).toBe(100);
});
it('falls back to the default when unset', () => {

View File

@@ -42,7 +42,7 @@ import type { Redis } from 'ioredis';
*/
/** Default cap: anonymous share-AI calls allowed per workspace per window. */
export const SHARE_AI_WORKSPACE_MAX_PER_WINDOW = 300;
export const SHARE_AI_WORKSPACE_MAX_PER_WINDOW = 100;
/** Default window length: one rolling hour. */
export const SHARE_AI_WORKSPACE_WINDOW_MS = 60 * 60 * 1000;

View File

@@ -12,6 +12,8 @@ import {
loadDocmostMcp,
type DocmostClientLike,
} from './docmost-client.loader';
import { resolveCurrentPageResult } from './current-page.util';
import { parseNodeArg } from './parse-node-arg';
/**
* Per-user, per-request adapter that exposes Docmost READ operations to the
@@ -222,14 +224,7 @@ export class AiChatToolsService {
'or null if the user is not currently on a page. Call this first whenever ' +
'the user refers to the current page without giving an explicit id.',
inputSchema: z.object({}),
execute: async () => {
if (!openedPage?.id) {
return { page: null };
}
return {
page: { id: openedPage.id, title: openedPage.title ?? '' },
};
},
execute: async () => resolveCurrentPageResult(openedPage),
}),
getPage: tool({
@@ -711,14 +706,7 @@ export class AiChatToolsService {
// Parity with the standalone MCP server (index.ts patch_node): the
// model sometimes serializes the node as a JSON string. Parse it
// before the client's typeof-object guard rejects it.
let parsedNode = node;
if (typeof node === 'string') {
try {
parsedNode = JSON.parse(node);
} catch {
throw new Error('node was a string but not valid JSON');
}
}
const parsedNode = parseNodeArg(node);
return await client.patchNode(pageId, nodeId, parsedNode);
},
}),
@@ -770,14 +758,7 @@ export class AiChatToolsService {
// Parity with the standalone MCP server (index.ts insert_node): the
// model sometimes serializes the node as a JSON string. Parse it
// before the client's typeof-object guard rejects it.
let parsedNode = node;
if (typeof node === 'string') {
try {
parsedNode = JSON.parse(node);
} catch {
throw new Error('node was a string but not valid JSON');
}
}
const parsedNode = parseNodeArg(node);
return await client.insertNode(pageId, parsedNode, {
position,
anchorNodeId,
@@ -826,14 +807,9 @@ export class AiChatToolsService {
let doc;
if (content === undefined || content === null) {
doc = undefined;
} else if (typeof content === 'string') {
try {
doc = JSON.parse(content);
} catch {
throw new Error('content was a string but not valid JSON');
}
} else {
doc = content;
// String -> JSON.parse (throwing on invalid); object passes through.
doc = parseNodeArg(content, 'content was a string but not valid JSON');
}
return await client.updatePageJson(pageId, doc, title);
},

View File

@@ -0,0 +1,43 @@
import { resolveCurrentPageResult } from './current-page.util';
/**
* Unit tests for resolveCurrentPageResult (pure function). Mirrors the
* getCurrentPage tool's contract: { page: null } when no page is open (no id),
* otherwise { page: { id, title } } with title defaulting to ''.
*/
describe('resolveCurrentPageResult', () => {
it('returns { page: null } when openedPage is undefined', () => {
expect(resolveCurrentPageResult(undefined)).toEqual({ page: null });
});
it('returns { page: null } when openedPage is null', () => {
expect(resolveCurrentPageResult(null)).toEqual({ page: null });
});
it('returns { page: null } when openedPage has no id', () => {
expect(resolveCurrentPageResult({})).toEqual({ page: null });
expect(resolveCurrentPageResult({ title: 'x' })).toEqual({ page: null });
});
it('returns { page: null } when id is an empty string', () => {
expect(resolveCurrentPageResult({ id: '' })).toEqual({ page: null });
});
it('returns the page id and title when both are present', () => {
expect(resolveCurrentPageResult({ id: 'p1', title: 'Hello' })).toEqual({
page: { id: 'p1', title: 'Hello' },
});
});
it('defaults title to "" when it is missing', () => {
expect(resolveCurrentPageResult({ id: 'p1' })).toEqual({
page: { id: 'p1', title: '' },
});
});
it('keeps an explicit empty-string title as ""', () => {
expect(resolveCurrentPageResult({ id: 'p1', title: '' })).toEqual({
page: { id: 'p1', title: '' },
});
});
});

View File

@@ -0,0 +1,21 @@
export interface CurrentPageInput {
id?: string;
title?: string;
}
export interface CurrentPageResult {
page: { id: string; title: string } | null;
}
// Resolve the "current page" tool result from the client-supplied open-page
// context. Returns { page: null } when no page is open (no id), otherwise the
// page id + title (title defaults to '' when absent). Mirrors the getCurrentPage
// tool's contract so it can be unit-tested without the ESM Docmost client.
export function resolveCurrentPageResult(
openedPage?: CurrentPageInput | null,
): CurrentPageResult {
if (!openedPage?.id) {
return { page: null };
}
return { page: { id: openedPage.id, title: openedPage.title ?? '' } };
}

View File

@@ -0,0 +1,37 @@
import { parseNodeArg } from './parse-node-arg';
/**
* Unit tests for the in-app `parseNodeArg` helper. It mirrors the standalone
* MCP helper (packages/mcp/src/lib/parse-node-arg.ts) and is used by the
* patchNode / insertNode / updatePageJson tool adapters. Behavior must be
* byte-identical: object passthrough, valid-string parse, invalid-string throw.
*/
describe('parseNodeArg', () => {
it('passes an object through unchanged', () => {
const obj = { type: 'paragraph', content: [] };
expect(parseNodeArg(obj)).toBe(obj);
});
it('passes undefined/null through unchanged', () => {
expect(parseNodeArg(undefined)).toBeUndefined();
expect(parseNodeArg(null)).toBeNull();
});
it('parses a valid JSON string into an object', () => {
expect(parseNodeArg('{"type":"paragraph"}')).toEqual({
type: 'paragraph',
});
});
it('throws the default message on an invalid JSON string', () => {
expect(() => parseNodeArg('{not json')).toThrow(
'node was a string but not valid JSON',
);
});
it('throws a custom message on an invalid JSON string', () => {
expect(() =>
parseNodeArg('{not json', 'content was a string but not valid JSON'),
).toThrow('content was a string but not valid JSON');
});
});

View File

@@ -0,0 +1,26 @@
// The model sometimes serializes a ProseMirror node arg as a JSON string
// instead of an object. Normalize: parse a string to an object (throwing on
// invalid JSON), pass an object through unchanged. Shared by patchNode /
// insertNode (and the analogous updatePageJson content parsing).
//
// This is behaviorally identical to `packages/mcp/src/lib/parse-node-arg.ts`
// (the function logic, default/explicit throw messages and branch order match;
// only comments and quote style differ). We cannot import that helper here:
// `@docmost/mcp` is ESM-only and this server
// compiles with module:commonjs, so it is loaded at runtime via the
// `new Function('import()')` trick (see docmost-client.loader.ts). Sharing
// runtime code across that ESM/CJS boundary by a normal import is impossible,
// hence the mirrored copy.
export function parseNodeArg(
node: unknown,
errMsg = 'node was a string but not valid JSON',
): unknown {
if (typeof node === 'string') {
try {
return JSON.parse(node);
} catch {
throw new Error(errMsg);
}
}
return node;
}

View File

@@ -16,9 +16,9 @@ import {
* fan-out per user, sockets with no userId skipped).
*
* Both private methods are exercised through their public entry points:
* spaceHasRestrictions via emitTreeEvent, broadcastToAuthorizedUsers via
* emitToAuthorizedUsers. WsService is constructed with mocked cache + repo and a
* mocked socket.io server, so no live infra is needed.
* spaceHasRestrictions via emitTreeEvent, broadcastToAuthorizedUsers via the
* restricted-page path of emitTreeEvent. WsService is constructed with mocked
* cache + repo and a mocked socket.io server, so no live infra is needed.
*/
describe('WsService.spaceHasRestrictions (cache lifecycle, via emitTreeEvent)', () => {
@@ -127,7 +127,7 @@ describe('WsService.spaceHasRestrictions (cache lifecycle, via emitTreeEvent)',
});
});
describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUsers)', () => {
describe('WsService.broadcastToAuthorizedUsers fan-out (via emitTreeEvent restricted path)', () => {
let service: WsService;
let pagePermissionRepo: {
hasRestrictedPagesInSpace: jest.Mock;
@@ -167,6 +167,12 @@ describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUser
in: serverIn,
};
service.setServer(server as never);
// Reach broadcastToAuthorizedUsers through emitTreeEvent's restricted path:
// the space has restrictions (cache miss -> repo says true) and the page has
// a restricted ancestor, so the emit is scoped to the authorized users.
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(true);
pagePermissionRepo.hasRestrictedAncestor.mockResolvedValue(true);
});
it('only sockets whose userId is in getUserIdsWithPageAccess receive the event', async () => {
@@ -180,7 +186,7 @@ describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUser
]);
const data = { operation: 'moveTreeNode' };
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
await service.emitTreeEvent('space-1', 'page-1', data);
// The authorized set is resolved from the candidate userIds present on the
// sockets (deduped), then only those users' sockets get the event.
@@ -203,7 +209,7 @@ describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUser
]);
const data = { operation: 'moveTreeNode' };
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
await service.emitTreeEvent('space-1', 'page-1', data);
// Both of the authorized user's sockets (e.g. two browser tabs) receive it.
expect(tab1).toHaveBeenCalledWith('message', data);
@@ -227,7 +233,7 @@ describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUser
]);
const data = { operation: 'moveTreeNode' };
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
await service.emitTreeEvent('space-1', 'page-1', data);
expect(okEmit).toHaveBeenCalledWith('message', data);
expect(anonEmit).not.toHaveBeenCalled();
@@ -241,7 +247,7 @@ describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUser
it('no sockets in the room -> no repo lookup, no emit', async () => {
fetchSockets.mockResolvedValue([]);
await service.emitToAuthorizedUsers('space-1', 'page-1', { op: 'x' });
await service.emitTreeEvent('space-1', 'page-1', { op: 'x' });
expect(pagePermissionRepo.getUserIdsWithPageAccess).not.toHaveBeenCalled();
});
@@ -252,7 +258,7 @@ describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUser
{ id: 's1', data: { userId: 'u' }, emit: jest.fn() },
]);
await service.emitToAuthorizedUsers('space-7', 'page-1', { op: 'x' });
await service.emitTreeEvent('space-7', 'page-1', { op: 'x' });
expect(serverIn).toHaveBeenCalledWith(getSpaceRoomName('space-7'));
});

View File

@@ -27,8 +27,7 @@ describe('WsTreeService', () => {
let wsService: {
emitTreeEvent: jest.Mock;
emitToSpaceRoom: jest.Mock;
emitDeleteToUnauthorized: jest.Mock;
emitToAuthorizedUsers: jest.Mock;
emitMoveWithRestrictionSplit: jest.Mock;
};
let pagePermissionRepo: { hasRestrictedAncestor: jest.Mock };
@@ -36,8 +35,7 @@ describe('WsTreeService', () => {
wsService = {
emitTreeEvent: jest.fn().mockResolvedValue(undefined),
emitToSpaceRoom: jest.fn(),
emitDeleteToUnauthorized: jest.fn().mockResolvedValue(undefined),
emitToAuthorizedUsers: jest.fn().mockResolvedValue(undefined),
emitMoveWithRestrictionSplit: jest.fn().mockResolvedValue(undefined),
};
pagePermissionRepo = {
// Default: not restricted, so broadcastPageMoved skips the compensating
@@ -150,14 +148,13 @@ describe('WsTreeService', () => {
await service.broadcastPageMoved(event);
// Normal path: move goes to the whole room via emitTreeEvent, and neither
// the authorized-only move path nor the compensating delete fire.
// Normal path: move goes to the whole room via emitTreeEvent, and the
// single-snapshot move/delete split does not fire.
expect(wsService.emitTreeEvent).toHaveBeenCalledTimes(1);
expect(wsService.emitToAuthorizedUsers).not.toHaveBeenCalled();
expect(wsService.emitDeleteToUnauthorized).not.toHaveBeenCalled();
expect(wsService.emitMoveWithRestrictionSplit).not.toHaveBeenCalled();
});
it('broadcastPageMoved into a RESTRICTED subtree routes the move to authorized users only AND emits a compensating delete to unauthorized — from one fresh decision', async () => {
it('broadcastPageMoved into a RESTRICTED subtree drives the move + compensating delete from ONE single-snapshot split call', async () => {
// Destination is now under a restricted ancestor.
pagePermissionRepo.hasRestrictedAncestor.mockResolvedValue(true);
@@ -180,11 +177,18 @@ describe('WsTreeService', () => {
// which could leak the move to the whole room during the stale-cache window.
expect(wsService.emitTreeEvent).not.toHaveBeenCalled();
// The move is delivered to authorized users only.
expect(wsService.emitToAuthorizedUsers).toHaveBeenCalledTimes(1);
expect(wsService.emitToAuthorizedUsers).toHaveBeenCalledWith(
'space-1',
'page-1',
// BOTH the move and the compensating delete are driven from ONE call, so a
// single socket/access snapshot partitions the room (no race window).
expect(wsService.emitMoveWithRestrictionSplit).toHaveBeenCalledTimes(1);
const [spaceId, pageId, movePayload, deletePayload] =
wsService.emitMoveWithRestrictionSplit.mock.calls[0];
expect(spaceId).toBe('space-1');
expect(pageId).toBe('page-1');
// The move payload is the moveTreeNode for the moved page.
expect(movePayload).toEqual(
expect.objectContaining({
operation: 'moveTreeNode',
spaceId: 'space-1',
@@ -192,20 +196,23 @@ describe('WsTreeService', () => {
}),
);
// The users who lost access get a deleteTreeNode for the moved node, scoped
// to the same page id (same fresh authorized set → disjoint from the move).
expect(wsService.emitDeleteToUnauthorized).toHaveBeenCalledTimes(1);
expect(wsService.emitDeleteToUnauthorized).toHaveBeenCalledWith(
'space-1',
'page-1',
// The delete payload is the compensating deleteTreeNode, scoped to the same
// page id and carrying the OLD parent id (so it disappears from where it was
// last visible).
expect(deletePayload).toEqual(
expect.objectContaining({
operation: 'deleteTreeNode',
spaceId: 'space-1',
payload: {
node: expect.objectContaining({ id: 'page-1', slugId: 'slug-1' }),
node: expect.objectContaining({
id: 'page-1',
slugId: 'slug-1',
parentPageId: 'old-parent',
}),
},
}),
);
expect(deletePayload.payload.node.parentPageId).toBe(event.oldParentId);
});
it('broadcastRefetchRoot emits refetchRootTreeNodeEvent to the space room', async () => {
@@ -339,7 +346,7 @@ describe('WsService.emitTreeEvent', () => {
);
});
it('emitDeleteToUnauthorized sends ONLY to sockets whose user lacks page access', async () => {
it('emitMoveWithRestrictionSplit partitions the room from one snapshot: authorized -> move, unauthorized + anonymous -> delete', async () => {
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']);
const okEmit = jest.fn();
@@ -348,38 +355,49 @@ describe('WsService.emitTreeEvent', () => {
const sockets = [
{ id: 's1', data: { userId: 'user-ok' }, emit: okEmit },
{ id: 's2', data: { userId: 'user-no' }, emit: noEmit },
// Unauthenticated socket (no userId) — must also receive the delete.
// Unauthenticated socket (no userId) — must receive the delete.
{ id: 's3', data: {}, emit: anonEmit },
];
server.in.mockReturnValue({
fetchSockets: jest.fn().mockResolvedValue(sockets),
});
const data = { operation: 'deleteTreeNode' };
await service.emitDeleteToUnauthorized('space-1', 'page-1', data);
const movePayload = { operation: 'moveTreeNode' };
const deletePayload = { operation: 'deleteTreeNode' };
await service.emitMoveWithRestrictionSplit(
'space-1',
'page-1',
movePayload,
deletePayload,
);
// Authorized user does NOT get the delete (they got the move instead).
expect(okEmit).not.toHaveBeenCalled();
// Unauthorized + anonymous sockets DO get the delete.
expect(noEmit).toHaveBeenCalledWith('message', data);
expect(anonEmit).toHaveBeenCalledWith('message', data);
// Authorized socket gets ONLY the move.
expect(okEmit).toHaveBeenCalledWith('message', movePayload);
expect(okEmit).not.toHaveBeenCalledWith('message', deletePayload);
// Unauthorized + anonymous sockets get ONLY the delete.
expect(noEmit).toHaveBeenCalledWith('message', deletePayload);
expect(noEmit).not.toHaveBeenCalledWith('message', movePayload);
expect(anonEmit).toHaveBeenCalledWith('message', deletePayload);
expect(anonEmit).not.toHaveBeenCalledWith('message', movePayload);
});
});
describe('move-into-restricted disjointness contract (WsTreeService + real WsService)', () => {
// CONTRACT: a move under a restricted ancestor PARTITIONS the room. The
// authorized set (gets the moveTreeNode via emitToAuthorizedUsers) and its
// complement (gets the deleteTreeNode via emitDeleteToUnauthorized) are
// disjoint and together cover every socket — and an anonymous (no-userId)
// socket lands in the delete set. We wire a REAL WsService (only its repo,
// cache and socket server mocked) so both broadcasts run against the SAME fixed
// socket set, the way they do in production.
// CONTRACT: a move under a restricted ancestor PARTITIONS the room from a
// SINGLE snapshot. emitMoveWithRestrictionSplit performs exactly one
// fetchSockets + one getUserIdsWithPageAccess; the authorized set (gets the
// moveTreeNode) and its complement (gets the deleteTreeNode) are disjoint and
// together cover every socket — and an anonymous (no-userId) socket lands in
// the delete set. We wire a REAL WsService (only its repo, cache and socket
// server mocked) so the partition runs against the SAME fixed socket set, the
// way it does in production.
let treeService: WsTreeService;
let pagePermissionRepo: {
hasRestrictedPagesInSpace: jest.Mock;
hasRestrictedAncestor: jest.Mock;
getUserIdsWithPageAccess: jest.Mock;
};
let fetchSockets: jest.Mock;
// Fixed room: two authorized users (one with two sockets), one unauthorized
// user, one anonymous socket.
@@ -429,11 +447,12 @@ describe('move-into-restricted disjointness contract (WsTreeService + real WsSer
}).compile();
const wsService = module.get<WsService>(WsService);
// Capture fetchSockets so the test can assert the SINGLE-snapshot contract:
// exactly one fetchSockets call drives the whole partition.
fetchSockets = jest.fn().mockResolvedValue(sockets);
const server = {
to: jest.fn().mockReturnValue({ emit: jest.fn() }),
in: jest.fn().mockReturnValue({
fetchSockets: jest.fn().mockResolvedValue(sockets),
}),
in: jest.fn().mockReturnValue({ fetchSockets }),
};
wsService.setServer(server as never);
@@ -469,5 +488,12 @@ describe('move-into-restricted disjointness contract (WsTreeService + real WsSer
// The anonymous socket specifically lands in the DELETE set, never the move.
expect(deleteSet.has('s-anon')).toBe(true);
expect(moveSet.has('s-anon')).toBe(false);
// SINGLE SNAPSHOT: the whole partition (move + compensating delete) is driven
// from exactly ONE fetchSockets and exactly ONE getUserIdsWithPageAccess.
// This is what closes the race window — there is no second, independent
// snapshot that could disagree with the first.
expect(fetchSockets).toHaveBeenCalledTimes(1);
expect(pagePermissionRepo.getUserIdsWithPageAccess).toHaveBeenCalledTimes(1);
});
});

View File

@@ -131,22 +131,20 @@ export class WsTreeService {
}
// Restricted case: a move can push a previously-visible page UNDER a
// restricted ancestor. Route the move to authorized users ONLY (same fresh
// getUserIdsWithPageAccess set the delete uses) and send the compensating
// delete to everyone else. Both sets come from one fresh decision, so they
// are guaranteed disjoint: authorized users get exactly the moveTreeNode,
// unauthorized users get exactly the deleteTreeNode, nobody gets both.
// restricted ancestor. The move (to authorized users) and the compensating
// delete (to everyone else) are now driven from ONE socket/access snapshot:
// emitMoveWithRestrictionSplit performs a single fetchSockets + a single
// getUserIdsWithPageAccess and partitions the room from that one snapshot.
// This eliminates the race window that existed when the move and the delete
// each resolved the audience independently — a socket could otherwise have
// landed in both sets (leaking the restricted node) or in neither (losing the
// compensating delete). Authorized users get exactly the moveTreeNode,
// everyone else (unauthorized + anonymous) gets exactly the deleteTreeNode.
//
// Users who LOSE visibility need the delete because otherwise the node would
// linger in their tree at its old parent with its real title/slugId/icon
// (existence + metadata leak).
await this.wsService.emitToAuthorizedUsers(
node.spaceId,
node.id,
movePayload,
);
await this.wsService.emitDeleteToUnauthorized(node.spaceId, node.id, {
await this.wsService.emitMoveWithRestrictionSplit(node.spaceId, node.id, movePayload, {
operation: 'deleteTreeNode',
spaceId: node.spaceId,
payload: {

View File

@@ -118,19 +118,42 @@ export class WsService {
this.server.to(getSpaceRoomName(spaceId)).emit('message', data);
}
// Broadcast `data` (a deleteTreeNode) to every socket in the space room whose
// user is NOT authorized to see `pageId`. Used to compensate a move that pushes
// a previously-visible page UNDER a restricted ancestor: authorized users get
// the moveTreeNode (via emitTreeEvent), everyone else gets a deleteTreeNode so
// the now-restricted node disappears from their tree instead of lingering with
// its real title/slugId/icon. The two event sets are disjoint by construction
// (a user is either authorized or not), so no socket receives both.
async emitDeleteToUnauthorized(
// Single-snapshot move broadcast. This is the ONE place that fans out a move
// under a restricted ancestor together with its compensating delete, resolving
// the audience EXACTLY ONCE so the two never disagree.
//
// It takes a SINGLE socket snapshot (`this.server.in(room).fetchSockets()` is
// called exactly once) and a SINGLE authorization resolution
// (`getUserIdsWithPageAccess` is called exactly once). From that one snapshot it
// partitions the room into two groups and emits to each:
// - authorized users (their userId is in the authorized set) receive
// `movePayload` (the moveTreeNode);
// - everyone else — unauthorized users AND anonymous/no-userId sockets —
// receive `deletePayload` (the compensating deleteTreeNode) so a now-hidden
// node disappears from their tree instead of lingering with its real
// title/slugId/icon.
// Because both groups are derived from the same socket array and the same
// authorized set, the partition is guaranteed DISJOINT (no socket gets both)
// and COMPLETE (every socket gets exactly one). This closes the race window
// that existed when the move and the compensating delete each ran their own
// independent fetchSockets + getUserIdsWithPageAccess: between those two
// snapshots a socket could connect/disconnect or its access change, so a socket
// could end up in both sets (leaking the restricted node, then no delete) or in
// neither (losing the compensating delete).
//
// It deliberately does NOT consult the cached spaceHasRestrictions: the caller
// (broadcastPageMoved) has already established, freshly and uncached, that the
// page is restricted, so we must not risk a stale cache fanning the move out to
// the whole room.
async emitMoveWithRestrictionSplit(
spaceId: string,
pageId: string,
data: any,
movePayload: any,
deletePayload: any,
): Promise<void> {
const room = getSpaceRoomName(spaceId);
// ONE socket snapshot for the whole partition.
const sockets = await this.server.in(room).fetchSockets();
if (sockets.length === 0) return;
@@ -141,39 +164,26 @@ export class WsService {
.filter((id): id is string => !!id),
),
);
if (userIds.length === 0) return;
const authorizedUserIds =
await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, userIds);
// ONE authorization resolution for the whole partition.
const authorizedUserIds = userIds.length
? await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, userIds)
: [];
const authorizedSet = new Set(authorizedUserIds);
for (const socket of sockets) {
const userId = socket.data.userId as string;
// Unauthenticated sockets (no userId) cannot see restricted content; send
// them the delete too so a leaked node can't linger.
if (!userId || !authorizedSet.has(userId)) {
socket.emit('message', data);
if (userId && authorizedSet.has(userId)) {
// Authorized: deliver the move.
socket.emit('message', movePayload);
} else {
// Unauthorized OR anonymous (no userId): deliver the compensating
// delete so the now-hidden node can't linger.
socket.emit('message', deletePayload);
}
}
}
// Server-origin broadcast of `data` to exactly the users in the space room who
// ARE authorized to see `pageId`. This is the counterpart of
// emitDeleteToUnauthorized: both resolve the authorized set from the SAME
// fetchSockets + getUserIdsWithPageAccess call shape, so a caller that drives
// both from one decision gets two disjoint sets (authorized vs. not) with no
// socket in both. Unlike emitTreeEvent, this does NOT consult the cached
// spaceHasRestrictions: the caller already knows the page is restricted, so we
// must not risk a stale cache fanning the move out to the whole room.
async emitToAuthorizedUsers(
spaceId: string,
pageId: string,
data: any,
): Promise<void> {
const room = getSpaceRoomName(spaceId);
await this.broadcastToAuthorizedUsers(room, null, pageId, data);
}
private async broadcastToAuthorizedUsers(
room: string,
excludeSocketId: string | null,

View File

@@ -0,0 +1,78 @@
import { Kysely } from 'kysely';
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
import { getTestDb, destroyTestDb, createWorkspace } from './db';
/**
* B — AiAgentRoleRepo: tenant isolation + soft-delete-aware lookups + the
* partial unique index `WHERE deleted_at IS NULL` (migration
* 20260620T120000-ai-agent-roles.ts). Exercises real SQL constraints.
*/
describe('AiAgentRoleRepo isolation + partial unique index [integration]', () => {
let db: Kysely<any>;
let repo: AiAgentRoleRepo;
let w1: string;
let w2: string;
beforeAll(async () => {
db = getTestDb();
repo = new AiAgentRoleRepo(db as any);
w1 = (await createWorkspace(db)).id;
w2 = (await createWorkspace(db)).id;
});
afterAll(async () => {
await destroyTestDb();
});
it('findById / listByWorkspace exclude soft-deleted rows', async () => {
const live = await repo.insert({ workspaceId: w1, name: 'Live', instructions: 'x' });
const dead = await repo.insert({ workspaceId: w1, name: 'Dead', instructions: 'x' });
await repo.softDelete(dead.id, w1);
expect(await repo.findById(live.id, w1)).toBeDefined();
expect(await repo.findById(dead.id, w1)).toBeUndefined();
const names = (await repo.listByWorkspace(w1)).map((r) => r.name);
expect(names).toContain('Live');
expect(names).not.toContain('Dead');
});
it('findById of a W2 role from W1 context returns undefined (tenant isolation)', async () => {
const w2role = await repo.insert({ workspaceId: w2, name: 'W2Role', instructions: 'x' });
expect(await repo.findById(w2role.id, w2)).toBeDefined();
// Same id, wrong workspace context -> not visible.
expect(await repo.findById(w2role.id, w1)).toBeUndefined();
});
it('duplicate (name, workspace) while not-deleted throws 23505 unique violation', async () => {
await repo.insert({ workspaceId: w1, name: 'Dup', instructions: 'x' });
let code: string | undefined;
try {
await repo.insert({ workspaceId: w1, name: 'Dup', instructions: 'x' });
} catch (err: any) {
code = err?.code ?? err?.cause?.code;
}
expect(code).toBe('23505');
});
it('same name is reusable after softDelete (partial unique index WHERE deleted_at IS NULL)', async () => {
const first = await repo.insert({ workspaceId: w1, name: 'Reusable', instructions: 'x' });
await repo.softDelete(first.id, w1);
// Now inserting the same name must succeed because the soft-deleted row is
// excluded from the partial unique index.
const second = await repo.insert({ workspaceId: w1, name: 'Reusable', instructions: 'x' });
expect(second.id).toBeDefined();
expect(second.id).not.toBe(first.id);
});
it('same name in W1 and W2 is allowed (unique is per-workspace)', async () => {
const a = await repo.insert({ workspaceId: w1, name: 'CrossTenant', instructions: 'x' });
const b = await repo.insert({ workspaceId: w2, name: 'CrossTenant', instructions: 'x' });
expect(a.id).toBeDefined();
expect(b.id).toBeDefined();
expect(a.id).not.toBe(b.id);
});
});

View File

@@ -0,0 +1,96 @@
import { Kysely } from 'kysely';
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
import {
getTestDb,
destroyTestDb,
createWorkspace,
createUser,
createRole,
createChat,
} from './db';
/**
* E (stretch) — AiChatRepo.findByCreator role-badge LEFT JOIN. The badge
* (roleName/roleEmoji) is populated ONLY when the bound role is live AND
* enabled; a soft-deleted or disabled role resolves to NULL, matching the
* stream's resolveRoleForRequest downgrade. Real SQL join, not a mock.
*/
describe('AiChatRepo.findByCreator role-badge join [integration]', () => {
let db: Kysely<any>;
let repo: AiChatRepo;
let roleRepo: AiAgentRoleRepo;
let workspaceId: string;
let creatorId: string;
beforeAll(async () => {
db = getTestDb();
repo = new AiChatRepo(db as any);
roleRepo = new AiAgentRoleRepo(db as any);
workspaceId = (await createWorkspace(db)).id;
creatorId = (await createUser(db, workspaceId)).id;
});
afterAll(async () => {
await destroyTestDb();
});
async function badgeFor(chatId: string) {
const { items } = await repo.findByCreator(creatorId, workspaceId, {
limit: 50,
} as any);
const row = items.find((c: any) => c.id === chatId);
expect(row).toBeDefined();
return { roleName: (row as any).roleName, roleEmoji: (row as any).roleEmoji };
}
it('enabled role -> roleName/roleEmoji populated', async () => {
const role = await createRole(db, {
workspaceId,
name: 'Proofreader',
emoji: '📝',
enabled: true,
});
const chat = await createChat(db, { workspaceId, creatorId, roleId: role.id });
const badge = await badgeFor(chat.id);
expect(badge.roleName).toBe('Proofreader');
expect(badge.roleEmoji).toBe('📝');
});
it('soft-deleted role -> badge NULL', async () => {
const role = await createRole(db, {
workspaceId,
name: 'Deleted Persona',
emoji: '🗑️',
enabled: true,
});
const chat = await createChat(db, { workspaceId, creatorId, roleId: role.id });
await roleRepo.softDelete(role.id, workspaceId);
const badge = await badgeFor(chat.id);
expect(badge.roleName).toBeNull();
expect(badge.roleEmoji).toBeNull();
});
it('disabled role -> badge NULL (mirrors resolveRoleForRequest downgrade)', async () => {
const role = await createRole(db, {
workspaceId,
name: 'Disabled Persona',
emoji: '🚫',
enabled: false,
});
const chat = await createChat(db, { workspaceId, creatorId, roleId: role.id });
const badge = await badgeFor(chat.id);
expect(badge.roleName).toBeNull();
expect(badge.roleEmoji).toBeNull();
});
it('chat with no role -> badge NULL', async () => {
const chat = await createChat(db, { workspaceId, creatorId, roleId: null });
const badge = await badgeFor(chat.id);
expect(badge.roleName).toBeNull();
expect(badge.roleEmoji).toBeNull();
});
});

View File

@@ -0,0 +1,194 @@
import { randomUUID } from 'node:crypto';
import { CamelCasePlugin, Kysely } from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js';
import * as postgres from 'postgres';
/**
* Isolated test database connection string. The dev DB is `docmost`; tests run
* against a dedicated `docmost_test` that global-setup drops + recreates +
* migrates so nothing here touches dev data. Overridable via env (global-setup
* also sets it so the value is consistent across the run).
*/
export const TEST_DATABASE_URL =
process.env.TEST_DATABASE_URL ??
'postgresql://docmost:docmost_dev_pw@localhost:5432/docmost_test';
/**
* Build a Kysely instance that MIRRORS the app's setup in database.module.ts:
* PostgresJSDialect over postgres(), CamelCasePlugin, and the bigint type
* parsing (to:20 / from:[20,1700] / serialize toString / parse parseInt). The
* repos rely on camelCase columns + bigint-as-number, so the test Kysely must
* match or queries break.
*/
export function buildTestDb(url: string = TEST_DATABASE_URL): Kysely<any> {
return new Kysely<any>({
dialect: new PostgresJSDialect({
postgres: postgres(url, {
max: 5,
onnotice: () => {},
types: {
bigint: {
to: 20,
from: [20, 1700],
serialize: (value: number) => value.toString(),
parse: (value: string) => Number.parseInt(value),
},
},
}),
}),
plugins: [new CamelCasePlugin()],
});
}
let singleton: Kysely<any> | undefined;
/** Lazily-built shared Kysely for the test suite (one per worker; maxWorkers=1). */
export function getTestDb(): Kysely<any> {
if (!singleton) {
singleton = buildTestDb();
}
return singleton;
}
export async function destroyTestDb(): Promise<void> {
if (singleton) {
await singleton.destroy();
singleton = undefined;
}
}
// --- Seeding helpers ---------------------------------------------------------
// Insert minimal valid rows (only the columns the tests need + NOT NULL ones).
// Plain randomUUID() is fine for FK integrity in tests (the app uses uuid v7).
export async function createWorkspace(
db: Kysely<any>,
overrides: { settings?: unknown; name?: string } = {},
): Promise<{ id: string; settings: any }> {
const id = randomUUID();
const row = await db
.insertInto('workspaces')
.values({
id,
name: overrides.name ?? `ws-${id.slice(0, 8)}`,
// hostname is uniquely constrained; keep it unique per workspace.
hostname: `host-${id.slice(0, 8)}`,
settings: overrides.settings === undefined ? null : (overrides.settings as any),
})
.returning(['id', 'settings'])
.executeTakeFirstOrThrow();
return { id: row.id as string, settings: row.settings };
}
export async function createUser(
db: Kysely<any>,
workspaceId: string,
overrides: { email?: string; name?: string } = {},
): Promise<{ id: string }> {
const id = randomUUID();
const row = await db
.insertInto('users')
.values({
id,
email: overrides.email ?? `user-${id.slice(0, 8)}@example.test`,
name: overrides.name ?? `user-${id.slice(0, 8)}`,
workspaceId,
})
.returning(['id'])
.executeTakeFirstOrThrow();
return { id: row.id as string };
}
export async function createSpace(
db: Kysely<any>,
workspaceId: string,
overrides: { slug?: string; name?: string } = {},
): Promise<{ id: string }> {
const id = randomUUID();
const row = await db
.insertInto('spaces')
.values({
id,
name: overrides.name ?? `space-${id.slice(0, 8)}`,
// slug is unique per workspace + NOT NULL.
slug: overrides.slug ?? `space-${id.slice(0, 8)}`,
workspaceId,
})
.returning(['id'])
.executeTakeFirstOrThrow();
return { id: row.id as string };
}
export async function createPage(
db: Kysely<any>,
args: { workspaceId: string; spaceId: string; title?: string },
): Promise<{ id: string }> {
const id = randomUUID();
const row = await db
.insertInto('pages')
.values({
id,
// slug_id is NOT NULL + globally unique.
slugId: `slug-${id.slice(0, 8)}`,
title: args.title ?? `page-${id.slice(0, 8)}`,
spaceId: args.spaceId,
workspaceId: args.workspaceId,
})
.returning(['id'])
.executeTakeFirstOrThrow();
return { id: row.id as string };
}
export async function createRole(
db: Kysely<any>,
args: {
workspaceId: string;
creatorId?: string | null;
name: string;
emoji?: string | null;
instructions?: string;
enabled?: boolean;
deletedAt?: Date | null;
},
): Promise<{ id: string }> {
const id = randomUUID();
const row = await db
.insertInto('aiAgentRoles')
.values({
id,
workspaceId: args.workspaceId,
creatorId: args.creatorId ?? null,
name: args.name,
emoji: args.emoji ?? null,
instructions: args.instructions ?? 'be helpful',
enabled: args.enabled ?? true,
deletedAt: args.deletedAt ?? null,
})
.returning(['id'])
.executeTakeFirstOrThrow();
return { id: row.id as string };
}
export async function createChat(
db: Kysely<any>,
args: {
workspaceId: string;
creatorId: string;
roleId?: string | null;
title?: string;
},
): Promise<{ id: string }> {
const id = randomUUID();
const row = await db
.insertInto('aiChats')
.values({
id,
workspaceId: args.workspaceId,
creatorId: args.creatorId,
roleId: args.roleId ?? null,
title: args.title ?? `chat-${id.slice(0, 8)}`,
})
.returning(['id'])
.executeTakeFirstOrThrow();
return { id: row.id as string };
}

View File

@@ -0,0 +1,79 @@
import * as path from 'node:path';
import { promises as fs } from 'node:fs';
import { Kysely, Migrator, FileMigrationProvider } from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js';
import * as postgres from 'postgres';
import { TEST_DATABASE_URL, buildTestDb } from './db';
const MAINTENANCE_URL =
process.env.TEST_MAINTENANCE_DATABASE_URL ??
'postgresql://docmost:docmost_dev_pw@localhost:5432/docmost';
const TEST_DB_NAME = 'docmost_test';
// migrate.ts points FileMigrationProvider at src/database/migrations; mirror it.
const migrationFolder = path.resolve(
__dirname,
'../../src/database/migrations',
);
/**
* Jest globalSetup: (re)create the isolated test database and migrate it to
* latest. Mirrors apps/server/src/database/migrate.ts (Kysely Migrator +
* FileMigrationProvider) so the schema is exactly what the app expects.
*/
export default async function globalSetup(): Promise<void> {
// 1. DROP/CREATE the test DB via the maintenance connection. These statements
// cannot run inside a transaction; use the raw postgres client's simple
// query (`.simple()`) so the driver does not wrap them.
const maintenance = postgres(MAINTENANCE_URL, { max: 1, onnotice: () => {} });
try {
await maintenance`DROP DATABASE IF EXISTS docmost_test WITH (FORCE)`.simple();
await maintenance`CREATE DATABASE docmost_test`.simple();
} finally {
await maintenance.end({ timeout: 5 });
}
// 2. Enable pgvector on the fresh DB (migrations create vector columns).
const ext = postgres(TEST_DATABASE_URL, { max: 1, onnotice: () => {} });
try {
await ext`CREATE EXTENSION IF NOT EXISTS vector`.simple();
} finally {
await ext.end({ timeout: 5 });
}
// 3. Run all migrations to latest against docmost_test.
const db: Kysely<any> = new Kysely<any>({
dialect: new PostgresJSDialect({
postgres: postgres(TEST_DATABASE_URL, { onnotice: () => {} }),
}),
});
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({ fs, path, migrationFolder }),
});
const { error, results } = await migrator.migrateToLatest();
// Fail loud on ANY errored migration, even if Migrator did not also surface a
// top-level `error` — never run the suite against a half-migrated schema.
const failed = (results ?? []).filter((r) => r.status === 'Error');
await db.destroy();
if (error || failed.length > 0) {
const names = failed.map((r) => r.migrationName).join(', ');
throw new Error(
`Test DB migration failed${names ? ` (${names})` : ''}: ${
(error as Error)?.message ?? error ?? 'errored migration result'
}`,
);
}
// 4. Pin the URL for the test workers (db.ts reads it from env).
process.env.TEST_DATABASE_URL = TEST_DATABASE_URL;
// Sanity touch: open + close the shared test Kysely once so a bad connection
// surfaces here rather than mid-suite.
const probe = buildTestDb();
await probe.selectFrom('workspaces').select('id').limit(1).execute();
await probe.destroy();
}

View File

@@ -0,0 +1,11 @@
import { destroyTestDb } from './db';
/**
* Jest globalTeardown: close any pools opened in the setup-process scope so jest
* exits cleanly. The test workers destroy their own connections in afterAll.
* We intentionally LEAVE docmost_test in place for post-mortem debuggability;
* global-setup drops + recreates it on the next run.
*/
export default async function globalTeardown(): Promise<void> {
await destroyTestDb();
}

View File

@@ -0,0 +1,68 @@
import { Kysely } from 'kysely';
import {
getTestDb,
destroyTestDb,
createWorkspace,
createSpace,
createPage,
} from './db';
/**
* C — page_template_references FK onDelete('cascade') (migration
* 20260620T131000-page-template-references.ts). Both reference_page_id and
* source_page_id reference pages.id ON DELETE CASCADE; deleting either page
* must remove the reference row.
*/
describe('page_template_references FK cascade [integration]', () => {
let db: Kysely<any>;
let workspaceId: string;
let spaceId: string;
beforeAll(async () => {
db = getTestDb();
workspaceId = (await createWorkspace(db)).id;
spaceId = (await createSpace(db, workspaceId)).id;
});
afterAll(async () => {
await destroyTestDb();
});
async function seedRef() {
const source = await createPage(db, { workspaceId, spaceId, title: 'source' });
const reference = await createPage(db, { workspaceId, spaceId, title: 'reference' });
const ref = await db
.insertInto('pageTemplateReferences')
.values({ workspaceId, sourcePageId: source.id, referencePageId: reference.id })
.returning(['id'])
.executeTakeFirstOrThrow();
return { source, reference, refId: ref.id as string };
}
async function refExists(refId: string): Promise<boolean> {
const row = await db
.selectFrom('pageTemplateReferences')
.select('id')
.where('id', '=', refId)
.executeTakeFirst();
return Boolean(row);
}
it('deleting the referenced page cascades the reference row away', async () => {
const { reference, refId } = await seedRef();
expect(await refExists(refId)).toBe(true);
await db.deleteFrom('pages').where('id', '=', reference.id).execute();
expect(await refExists(refId)).toBe(false);
});
it('deleting the source page also cascades the reference row away', async () => {
const { source, refId } = await seedRef();
expect(await refExists(refId)).toBe(true);
await db.deleteFrom('pages').where('id', '=', source.id).execute();
expect(await refExists(refId)).toBe(false);
});
});

View File

@@ -0,0 +1,75 @@
import Redis from 'ioredis';
import { PublicShareWorkspaceLimiter } from 'src/core/ai-chat/public-share-workspace-limiter';
/**
* D — PublicShareWorkspaceLimiter against REAL Redis (logical DB 15, so nothing
* touches dev data). This exercises the actual Lua EVAL — including
* ZREMRANGEBYSCORE eviction and the `ZCARD >= max` boundary — which a FakeRedis
* cannot faithfully reproduce.
*/
describe('PublicShareWorkspaceLimiter vs real Redis [integration]', () => {
let redis: Redis;
beforeAll(async () => {
// db:15 keeps this off the app's db 0, so dev Redis data is never touched.
const url = process.env.TEST_REDIS_URL ?? 'redis://127.0.0.1:6379';
redis = new Redis(url, { db: 15, lazyConnect: false });
// Surface an unreachable/wrong Redis here with a clear error, not mid-test.
await redis.ping();
});
beforeEach(async () => {
await redis.flushdb();
});
afterAll(async () => {
await redis.quit();
});
it('admits the first max calls and denies the next, then re-admits after the window slides', async () => {
let nowMs = 1_000_000;
const now = () => nowMs;
const limiter = new PublicShareWorkspaceLimiter(redis, 3, 1000, now);
const key = 'ws-sliding';
// First 3 admitted.
expect(await limiter.tryConsume(key)).toBe(true);
expect(await limiter.tryConsume(key)).toBe(true);
expect(await limiter.tryConsume(key)).toBe(true);
// 4th denied (cap reached; ZCARD >= max).
expect(await limiter.tryConsume(key)).toBe(false);
// Advance time past the window so all 3 entries fall out of the trailing
// windowMs and ZREMRANGEBYSCORE evicts them.
nowMs += 1500;
expect(await limiter.tryConsume(key)).toBe(true);
});
it('counts 3 distinct same-millisecond calls distinctly, then denies the 4th', async () => {
// Fixed `now` => all attempts share the same timestamp. Unique member ids
// (counter + random suffix) keep them distinct in the sorted set so the
// count is not under-reported by score collision.
const now = () => 2_000_000;
const limiter = new PublicShareWorkspaceLimiter(redis, 3, 1000, now);
const key = 'ws-same-ms';
expect(await limiter.tryConsume(key)).toBe(true);
expect(await limiter.tryConsume(key)).toBe(true);
expect(await limiter.tryConsume(key)).toBe(true);
expect(await limiter.tryConsume(key)).toBe(false);
// Confirm the sorted set actually holds 3 distinct members at one score.
const card = await redis.zcard('share-ai:ws:' + key);
expect(card).toBe(3);
});
it('keys are isolated per workspace', async () => {
const now = () => 3_000_000;
const limiter = new PublicShareWorkspaceLimiter(redis, 1, 1000, now);
expect(await limiter.tryConsume('ws-a')).toBe(true);
expect(await limiter.tryConsume('ws-a')).toBe(false);
// Different key has its own independent budget.
expect(await limiter.tryConsume('ws-b')).toBe(true);
});
});

View File

@@ -0,0 +1,60 @@
import { Kysely } from 'kysely';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { getTestDb, destroyTestDb, createWorkspace } from './db';
/**
* A — WorkspaceRepo.updateSetting jsonb-MERGE (the html-embed kill-switch
* write-half). Setting a single top-level key must NOT clobber sibling
* settings namespaces. This is real SQL: the repo does
* `COALESCE(settings,'{}') || jsonb_build_object(key, value)`.
*/
describe('WorkspaceRepo.updateSetting (jsonb merge) [integration]', () => {
let db: Kysely<any>;
let repo: WorkspaceRepo;
beforeAll(() => {
db = getTestDb();
// Repos are plain classes taking @InjectKysely() db — instantiate directly.
repo = new WorkspaceRepo(db as any);
});
afterAll(async () => {
await destroyTestDb();
});
it('persists htmlEmbed:true without clobbering sibling ai/sharing settings', async () => {
const ws = await createWorkspace(db, {
settings: { ai: { chat: true }, sharing: { x: 1 } },
});
const updated = await repo.updateSetting(ws.id, 'htmlEmbed', true);
// Returned row carries the merged settings.
expect(updated.settings).toMatchObject({
htmlEmbed: true,
ai: { chat: true },
sharing: { x: 1 },
});
// Re-read from the DB to confirm it actually persisted (not just returning()).
const row = await db
.selectFrom('workspaces')
.select(['settings'])
.where('id', '=', ws.id)
.executeTakeFirstOrThrow();
expect(row.settings).toEqual({
ai: { chat: true },
sharing: { x: 1 },
htmlEmbed: true,
});
});
it('initializes settings from NULL via COALESCE without error', async () => {
const ws = await createWorkspace(db, { settings: undefined });
const updated = await repo.updateSetting(ws.id, 'htmlEmbed', false);
expect(updated.settings).toEqual({ htmlEmbed: false });
});
});

View File

@@ -0,0 +1,23 @@
{
"moduleFileExtensions": ["js", "json", "ts", "tsx"],
"rootDir": "..",
"testRegex": ".*\\.int-spec\\.ts$",
"testPathIgnorePatterns": ["/node_modules/"],
"transform": {
"^.+\\.(t|j)sx?$": "ts-jest"
},
"transformIgnorePatterns": [
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))"
],
"testEnvironment": "node",
"testTimeout": 60000,
"maxWorkers": 1,
"globalSetup": "<rootDir>/test/integration/global-setup.ts",
"globalTeardown": "<rootDir>/test/integration/global-teardown.ts",
"moduleNameMapper": {
"^@docmost/db/(.*)$": "<rootDir>/src/database/$1",
"^@docmost/transactional/(.*)$": "<rootDir>/src/integrations/transactional/$1",
"^@docmost/ee/(.*)$": "<rootDir>/src/ee/$1",
"^src/(.*)$": "<rootDir>/src/$1"
}
}

View File

@@ -1,263 +0,0 @@
# Авто-сворачивание AI-чата в заголовок при фокусе на странице, разворот по клику
## Контекст (запрос)
Плавающее окно AI-чата (`AiChatWindow`) сейчас перекрывает контент страницы:
если открыть чат и начать читать/листать вики-страницу под ним, окно остаётся
во весь рост и закрывает таблицу/текст (см. скриншот: окно поверх «Аудио-тракт в
умных колонках»). Свернуть можно только вручную — кнопкой «—» (Minimize) в шапке.
Хотим, чтобы окно **само сворачивалось в свою шапку, как только пользователь
переключается на страницу** (кликает мимо окна — в редактор/в контент), и
**разворачивалось обратно по клику на шапку**. Тогда чат не мешает читать
страницу, но остаётся под рукой одним кликом.
Важно: сворачивание — это именно визуальный коллапс (как нынешний Minimize), а
**не** закрытие. Поток ответа агента не должен прерываться.
## Как сейчас устроено (цепочка)
Всё во фронтенде, в одном компоненте окна:
`apps/client/src/features/ai-chat/components/ai-chat-window.tsx`
(+ его CSS `ai-chat-window.module.css`).
- **Состояние «свёрнуто»** уже есть: `const [minimized, setMinimized] = useState(false)`
— строка ~108.
- **Переключатель** `toggleMinimize` (строки ~319-321) просто инвертирует флаг;
привязан к кнопке «—» (`IconMinus`) в шапке (строки ~366-374).
- **Визуальный коллапс уже реализован в CSS** (`ai-chat-window.module.css`):
- `.minimized { height: auto !important; min-height: 0 !important; resize: none; }`
(строки ~40-44) — окно схлопывается до высоты шапки;
- `.minimized .content { display: none; }` (строки ~56-58) — тело
(история + тред) скрывается, но **не размонтируется**: `ChatThread` остаётся
в DOM, поэтому идущий стрим/`AbortController` не обрывается (это явно описано в
комментариях у `.content` и в `toggleMinimize`).
- При `minimized` инлайновая `height` не задаётся (строка ~334), чтобы победила
auto-высота из CSS; резайз-ручка скрыта (строки ~454-458).
- **Шапка = `.dragBar`** (JSX строки ~338-385) с `onMouseDown={startDrag}`.
- `startDrag` (строки ~262-314) игнорирует нажатия на кнопках
(`if ((e.target).closest("button")) return;`, строка ~264) — чтобы «—»/«×»/«+»
не таскали окно.
- В `mouseup` (`up`, строки ~290-308) сохраняется итоговая позиция в `geom`.
- **Клика-для-разворота сейчас нет**: одиночный клик по шапке только инициирует
перетаскивание, развернуть свёрнутое окно можно лишь повторным нажатием «—».
- Окно смонтировано глобально и плавает над всем: `<AiChatWindow />` в
`apps/client/src/components/layouts/global/global-app-shell.tsx` (строка ~159),
`position: fixed`, `z-index: 105` (ниже оверлеев Mantine: modal=200, menu=300,
notifications=400 — это нам важно, см. «Тонкие моменты»).
- Композер автофокусится при монтировании треда (`autoFocus` в
`chat-input.tsx`) — это фокус **внутри** окна, не на странице.
Итого: «свёрнутый» вид готов. Нужно добавить **два триггера**: (1) авто-сворот при
взаимодействии со страницей и (2) разворот по клику на шапку.
## Решение (точечное, только клиент)
Файл: `apps/client/src/features/ai-chat/components/ai-chat-window.tsx`
(+ пара строк CSS, опционально + i18n-ключ).
### Часть 1 — авто-сворачивание при взаимодействии со страницей
Слушаем `mousedown`/`pointerdown` на `document` (в capture-фазе), но **только**
когда окно открыто и ещё не свёрнуто. Если нажатие пришло **вне окна** и **не
внутри портала Mantine** — сворачиваем.
```ts
// Auto-collapse the window into its header as soon as the user interacts with
// anything outside it (clicks the page/editor). Active only while open and
// expanded. Capture phase so a child's stopPropagation can't hide the event.
useEffect(() => {
if (!windowOpen || minimized) return;
const onPointerDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
const el = winRef.current;
if (!el || !target) return;
// Inside the window itself -> not an "away" interaction.
if (el.contains(target)) return;
// Inside a Mantine portal the chat owns (kebab Menu dropdown, delete-confirm
// modal, the context-size Tooltip, notifications). Mantine's Portal sets
// data-portal="true" on its node, so this reliably excludes ALL of them.
if (target.closest("[data-portal]")) return;
setMinimized(true);
};
document.addEventListener("mousedown", onPointerDown, true);
return () => document.removeEventListener("mousedown", onPointerDown, true);
}, [windowOpen, minimized]);
```
Почему `mousedown` (а не `focusin`):
- Клик по **не-фокусируемому** элементу страницы (ячейка таблицы, обычный текст —
ровно случай со скриншота) фокус-событие не порождает, но это и есть «ушёл на
страницу». `mousedown` ловит любой клик. `focusin` пропустил бы такие клики.
- Минус: `mousedown` не ловит переход фокуса с клавиатуры (Tab в редактор). Если
это нужно — добавить параллельно `focusin`-слушатель с тем же гардом (см.
«Открытые вопросы»). По умолчанию — только указатель, как и просит запрос
(«смена фокуса на страницу» = клик мимо окна).
Почему гард `[data-portal]` обязателен:
- Кебаб-меню списка чатов рендерит `Menu.Dropdown` в портал (вне DOM окна) —
`conversation-list.tsx` строки ~123-149; удаление — `modals.openConfirmModal`
(строка ~56), тоже портал. Без гарда клик по пункту «Rename»/«Delete» свернул
бы чат прямо в момент выбора. Mantine на узле портала ставит
`data-portal="true"` (подтверждено в `node_modules/@mantine/core`
`Portal.cjs`), поэтому `target.closest("[data-portal]")` исключает их все
(а заодно Tooltip размера контекста и нотификации — они тоже порталы).
Регистрация в `useEffect` с deps `[windowOpen, minimized]`: слушатель вешается
только когда `windowOpen && !minimized`, и снимается при сворачивании/закрытии —
не делаем лишней работы и не дёргаем `setMinimized(true)` повторно.
### Часть 2 — разворот по клику на шапку
Нужно отличить **клик** по шапке (развернуть) от **перетаскивания** свёрнутой
плашки (она остаётся таскаемой). Нельзя просто навесить `onClick` на `.dragBar`:
браузер шлёт `click` и в конце драга (mousedown+mouseup на том же элементе), и
плашка разворачивалась бы после любого перетаскивания.
Решение — доработать существующий `startDrag`: запомнить стартовые координаты,
а в `mouseup` посчитать смещение; если оно ниже порога (≈4px) **и** окно сейчас
свёрнуто — развернуть.
```ts
const startDrag = useCallback((e: React.MouseEvent): void => {
if ((e.target as HTMLElement).closest("button")) return;
const el = winRef.current;
if (!el) return;
const sx = e.clientX;
const sy = e.clientY;
// ... (ol/ot + move() unchanged)
const up = (ev: MouseEvent): void => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
document.body.style.userSelect = "";
// Treat a near-zero-movement press as a click. When minimized, a click on
// the header expands the window (drag still repositions the collapsed bar).
const moved =
Math.abs(ev.clientX - sx) > 4 || Math.abs(ev.clientY - sy) > 4;
if (!moved && minimizedRef.current) {
setMinimized(false);
return; // nothing to persist: position didn't change
}
// ... (persist geom as before)
};
// ...
}, []);
```
Подводный камень — **stale closure**: `startDrag` обёрнут в `useCallback([])`,
поэтому замыкает устаревший `minimized`. Два варианта:
- держать `minimizedRef = useRef(minimized)` и синхронизировать его в эффекте
(`minimizedRef.current = minimized`) — тогда `useCallback([])` остаётся (как в
коде выше); **рекомендуется**, не пересоздаёт хендлер;
- либо добавить `minimized` в deps `useCallback` — проще, но пересоздаёт `startDrag`
на каждом тоггле (дёшево, но дёргает `onMouseDown`-проп).
Кнопка «—» остаётся как явный тоггл (`toggleMinimize` уже инвертирует флаг), так
что развернуть можно и ей. Менять её не нужно.
### Часть 3 (рекомендуется) — аффорданс и доступность шапки
- **Курсор**: в свёрнутом виде шапка кликабельна — заменить `grab` на `pointer`:
```css
/* ai-chat-window.module.css — hint that the collapsed header expands on click */
.minimized .dragBar { cursor: pointer; }
```
- **Клавиатура/скринридер**: `.dragBar` — это `div`. В свёрнутом состоянии дать
ему `role="button"`, `tabIndex={0}`, `aria-label={t("Expand")}` и обработчик
Enter/Space → `setMinimized(false)`. Иначе развернуть без мыши нельзя.
## Тонкие моменты / edge cases
- **Стрим не прерывается.** Авто-сворот выставляет `minimized=true` — `ChatThread`
остаётся смонтированным (только `.content` скрывается). Ответ агента
достреливается в фоне; развернув шапку, пользователь видит результат. Это
желаемое поведение (он специально ушёл читать страницу).
- **Автофокус композера при открытии.** Открытие окна автофокусит textarea —
это `focus` **внутри** окна, а не внешний `mousedown`, поэтому ложного
немедленного сворота не будет.
- **Перетаскивание окна** (mousedown по шапке) — это нажатие **внутри**
`winRef.current`, гард `el.contains(target)` его пропускает: drag не сворачивает.
- **Резайз** нативной ручкой — mousedown тоже внутри окна, не сворачивает.
- **Порталы дочерних компонентов** (кебаб-меню, confirm-модалка, tooltip,
нотификации) исключены гардом `[data-portal]` — клик по ним не сворачивает.
Это ключевая причина не использовать «голый» contains-only outside-click.
- **Capture-фаза** слушателя: ловим `mousedown` даже если кто-то на странице
вызывает `stopPropagation` в bubble-фазе. На клики внутри окна/порталов не
влияет (их отсекают гарды).
- **Повторный авто-сворот** не происходит: при `minimized` слушатель снят (deps
эффекта). Разворот по клику снова навешивает слушатель — цикл корректен.
- **Состояние при закрытии/открытии.** Компонент при `!windowOpen` возвращает
`null`, но **не размонтируется**, поэтому `minimized` переживает закрытие.
Желательно при каждом открытии показывать окно **развёрнутым**: добавить
`setMinimized(false)` в эффект, срабатывающий на переход `windowOpen → true`
(можно в тот же `useLayoutEffect`, что вычисляет геометрию, строки ~238-241).
См. «Открытые вопросы».
- **z-index/оверлеи.** Окно (105) ниже modal/menu/notifications — поэтому
confirm-модалка удаления и кебаб-меню рисуются **над** окном; даже если бы чат
свернулся за ними, они продолжали бы работать. Но гард `[data-portal]` всё равно
не даёт сворачиваться при работе с ними.
- **Touch.** Драг сейчас на mouse-событиях (десктоп-фича). Для единообразия
внешний слушатель можно сделать `pointerdown` вместо `mousedown` (покроет тач),
но тогда и порог-клик в `up` стоит считать на pointer-событиях. По умолчанию —
`mousedown`, как у драга.
## i18n
- Новые пользовательские строки — **только через `t(...)`** и добавить ключ в
`apps/client/public/locales/en-US/translation.json` (каталог ключ==значение).
Достаточно `"Expand"` (для `aria-label`/`title` шапки в свёрнутом виде).
В шапке уже есть `t("Minimize")`, `t("Close")`, `t("New chat")`.
- Комментарии в коде — на английском (правило проекта).
## Тесты
- Вынести чистые хелперы и покрыть Vitest:
- `shouldCollapseOnOutsidePointer(target, windowEl): boolean`
(`windowEl.contains(target)` + `target.closest("[data-portal]")`) —
`(внутри окна) → false`, `(в портале) → false`, `(на странице) → true`.
- `isHeaderClick(dx, dy, threshold=4): boolean` — порог клик-vs-драг.
- Компонентный тест (`@testing-library/react`): открыть окно → диспатчить
`mousedown` по `document.body` → окно получает класс `.minimized`; клик по
`.dragBar` (без движения) в свёрнутом виде → класс снят. Проверить, что
`mousedown` по узлу с `data-portal` сворота не вызывает.
- Прогнать `pnpm --filter client lint` и `pnpm --filter client test`.
## Файлы к изменению
- `apps/client/src/features/ai-chat/components/ai-chat-window.tsx`
— внешний `mousedown`-эффект (Часть 1); доработка `startDrag` + `minimizedRef`
(Часть 2); опц. `setMinimized(false)` при открытии; a11y-атрибуты на `.dragBar`.
- `apps/client/src/features/ai-chat/components/ai-chat-window.module.css`
— опц. `.minimized .dragBar { cursor: pointer; }`.
- `apps/client/public/locales/en-US/translation.json` — ключ `"Expand"` (если
добавляем aria/title).
## Альтернативы / расширения (вне базового объёма)
- **`useClickOutside` из `@mantine/hooks`** вместо ручного слушателя. Минус:
порталы дочерних меню/модалок нужно явно передавать как `nodes` для игнора, а
они создаются динамически — ручной гард `[data-portal]` проще и надёжнее.
Поэтому ручной слушатель предпочтительнее.
- **Учитывать клавиатурный фокус** (`focusin`) дополнительно к `mousedown` — если
захотим сворачивать и при Tab в редактор.
- **Не сворачивать во время стрима** — если решим, что во время генерации окно
должно оставаться раскрытым (противоречит идее «ушёл читать страницу», поэтому
по умолчанию сворачиваем всегда).
- **Анимация коллапса/разворота** (height/opacity transition) — косметика, можно
добавить позже в `.window`/`.content`.
## Принятые решения (базовый объём)
- **Триггер авто-сворота — только клик** (`mousedown` в capture-фазе).
`focusin` не добавляем: запрос — про переключение на страницу кликом, а клик по
не-фокусируемому контенту (ячейка таблицы) фокус-событие не даёт.
- **При каждом открытии окна показываем его развёрнутым** —
`setMinimized(false)` на переход `windowOpen → true`. Свёрнутое состояние не
«залипает» между сессиями открытия.
- **Во время стрима сворачиваем как обычно.** Поток не прерывается (`ChatThread`
остаётся смонтированным), результат виден после разворота — это и есть смысл
«ушёл читать страницу».
- **Клавиатурный разворот шапки входит в базовый объём** — в свёрнутом виде
`.dragBar` получает `role="button"`, `tabIndex={0}`, `aria-label={t("Expand")}`
и обработку Enter/Space. Доступность без мыши обязательна.

View File

@@ -1,129 +0,0 @@
# Хрупкая передача «текущей страницы» в AI-агента
Контекст: агент не понимает «эта/текущая страница». В разговоре через
CLIProxyAPI он отвечает «я не вижу текущую страницу» и просит уточнить
id/название. Пользователь сообщает: **без CLIProxyAPI (прямой эндпоинт)
работает**. То есть проблема воспроизводится на прокси-пути, но сама
механика передачи страницы хрупкая по двум независимым причинам (см. ниже),
поэтому фиксируем в беклоге целиком.
## Как сейчас инжектится текущая страница (цепочка)
Страница передаётся **только текстом в системный промпт** — отдельной
строкой. Это единственная точка, где агент узнаёт pageId «этой страницы».
Нет ни инструмента «get current page», ни поля в user-сообщении.
1. Клиент вычисляет `openPage` из роута:
`apps/client/src/features/ai-chat/components/ai-chat-window.tsx:124-131`
`const { pageSlug } = useParams();`
`usePageQuery({ pageId: extractPageSlugId(pageSlug) })`
`openPage = openPageData ? { id, title } : null`. Передаётся в `ChatThread`
(`:391`).
2. Транспорт кладёт `openPage` в тело запроса:
`apps/client/src/features/ai-chat/components/chat-thread.tsx:107-127`
(`prepareSendMessagesRequest`, поле на `:121`), POST `/api/ai-chat/stream`.
3. Контроллер читает тело СЫРЫМ (намеренно без DTO, чтобы глобальный
`ValidationPipe { whitelist: true }` не выкинул незадекларированное поле):
`apps/server/src/core/ai-chat/ai-chat.controller.ts:103-135`
(`const body = (req.body ?? {}) as AiChatStreamBody;`).
4. Сервис прокидывает `body.openPage``openedPage`:
`apps/server/src/core/ai-chat/ai-chat.service.ts:146-149`
(тип поля — `:32`, `openPage?: { id?; title? } | null`).
5. `buildSystemPrompt` дописывает строку контекста в системный промпт:
`apps/server/src/core/ai-chat/ai-chat.prompt.ts:94-101`
`The user is currently viewing the page "<title>" (pageId: <id>)...`.
Добавляется в секцию контекста (после persona, ПЕРЕД safety-framework).
6. Уходит как роль `system` в `streamText({ system, ... })`:
`apps/server/src/core/ai-chat/ai-chat.service.ts:237-239`
на OpenAI-совместимый `/chat/completions` по настроенному `baseURL`
(это и есть CLIProxyAPI):
`apps/server/src/integrations/ai/ai.service.ts:46-52`
(`createOpenAI({ apiKey, baseURL }).chat(model)`).
## Хрупкость №1 — клиентская: openPage по исходнику всегда null
`AiChatWindow` примонтирован в глобальной оболочке:
`apps/client/src/components/layouts/global/global-app-shell.tsx:159`,
которую рендерит `Layout` (`apps/client/src/components/layouts/global/layout.tsx:7-19`).
`Layout` — это **pathless родительский layout-роут**
(`<Route element={<Layout/>}>` без своего пути), а сегмент `:pageSlug`
матчится только дочерним роутом `/s/:spaceSlug/p/:pageSlug``<Page/>`
(`apps/client/src/App.tsx:56-66`).
В react-router-dom@7.13.1 `useParams()` возвращает
`matches[matches.length-1].params` (проверено в исходнике
`node_modules/react-router/dist/development/chunk-XOLAXE2Z.js:6891-6895`).
На уровне шелла последний матч — это pathless `Layout` (params `{}`),
параметры дочернего роута через `<Outlet/>` родителю НЕ видны. Значит в
`AiChatWindow` `pageSlug === undefined``extractPageSlugId(undefined)`
возвращает `undefined` (`apps/client/src/lib/utils.tsx:14-23`) →
`usePageQuery` отключён (`enabled: !!pageInput.pageId`,
`apps/client/src/features/page/queries/page-query.ts:44-52`) →
`openPage = null`.
Ловушка — комментарий «same source the breadcrumb uses». Хлебные крошки
используют ТОТ ЖЕ `useParams()` (`apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx:37`)
и работают — но лишь потому, что рендерятся ВНУТРИ `<Page/>` (дочерний роут,
где `:pageSlug` уже заматчен). Один хук, разная глубина в дереве → разный
результат.
Косвенное подтверждение того же антипаттерна рядом: `Layout` тоже делает
`const { spaceSlug } = useParams()` (`layout.tsx:8`) и тоже получает
`undefined``SearchSpotlight` получает `spaceId={undefined}` и тихо
работает без привязки к спейсу. Никем не замечено, потому что некритично.
**ПРОТИВОРЕЧИЕ, которое надо разрешить перед фиксом:** по исходнику
`openPage` должен быть `null` В ОБОИХ режимах (и через прокси, и напрямую),
а пользователь говорит, что напрямую РАБОТАЕТ. Значит либо рантайм/сборка
расходится с рабочим деревом, либо страница доезжает иным путём. Проверить
фактом (см. открытые вопросы) ДО того, как чинить клиент.
## Хрупкость №2 — прокси: контекст живёт только в system-сообщении
Поскольку pageId передаётся ТОЛЬКО строкой в роли `system`, любой прокси,
который переписывает/дополняет системный промпт, может её потерять или
«утопить». gitmost формирует `system` одинаково независимо от эндпоинта —
строка идентична для direct и для прокси. Значит если напрямую работает, а
через CLIProxyAPI нет, расхождение возникает ВНУТРИ трансляции прокси
(CLIProxyAPI оборачивает CLI-бэкенды — Gemini CLI / Claude Code / Codex /
Qwen — у которых свой объёмный системный промпт; наш system может быть
склеен с их преамбулой, перенесён в `systemInstruction`, обрезан или
недооценён моделью). Пользователь ранее отмечал «она вроде не стирает
системный промпт, а просто дополняет» — это надо подтвердить захватом
реального запроса.
## Открытые вопросы (проверить ДО реализации)
- [ ] Что реально уходит в `system`? Залогировать строку перед `streamText`
(`ai-chat.service.ts:~237`) и сравнить direct vs proxy — строка должна
быть БАЙТ-В-БАЙТ одинаковой.
- [ ] Долетает ли `openPage` непустым до сервера? Залогировать `body.openPage`
в `ai-chat.service.ts:~149` в обоих режимах. Если null даже на direct —
проблема №1 реальна и для direct (тогда «работает» означало что-то иное).
Если непустой — клиентская теория про `useParams` неверна для рантайма,
надо понять почему (другая сборка? другой м压онт?).
- [ ] Что CLIProxyAPI шлёт апстриму? Снять HTTP апстрим-запрос прокси
(логи прокси / mitmproxy) — присутствует ли строка `pageId: ...` в
системной инструкции, что отдаётся модели.
## Варианты фикса (выбрать после разрешения противоречия)
Клиентская часть (проблема №1), если подтвердится:
- A. В `AiChatWindow` заменить `useParams()` на `useMatch("/s/:spaceSlug/p/:pageSlug")`
или `matchPath` по `useLocation().pathname` — матчится по полному URL
независимо от позиции в дереве. Минимально и точечно.
- B. Завести jotai-атом текущей страницы, который выставляет `Page`
(он внутри дочернего роута, видит params), и читать его в окне чата.
Заодно чинит тот же баг в `Layout`/`SearchSpotlight`.
Прокси-устойчивость (проблема №2):
- C. Дублировать контекст страницы НЕ только в system: добавить короткий
скрытый префикс в user-сообщение, либо дать агенту инструмент
`get_current_page` (берёт pageId из серверной сессии запроса), чтобы
идентичность страницы не зависела от сохранности system-промпта прокси.
- D. Если CLIProxyAPI обрезает/переносит system — настроить его так, чтобы
наш system сохранялся (вне кода gitmost; задокументировать требование).
Рекомендация: сначала разрешить противоречие логами (дёшево), потом A или B
для клиента + C для устойчивости к прокси (C — единственное, что реально
лечит исходный симптом «через прокси не видит страницу»).

View File

@@ -1,165 +0,0 @@
# Выбор agent role карточками в пустом окне чата (вместо выпадающего списка)
Контекст: при создании нового чата identity (agent role) выбирается из
выпадающего списка Mantine `<Select>`. Просьба: заменить список на **карточки
разных цветов с названием identity по центру пустого окна чата**. Клик по
карточке применяет роль; если пользователь карточку не нажал и просто написал
сообщение — срабатывает дефолтный Universal assistant.
Скриншот текущего поведения приложил пользователь: «Agent role» + раскрытый
список (Universal assistant ✓, Пират, Дедушка).
## Как сейчас устроен выбор роли (цепочка)
1. Picker рисуется только для нового чата (`activeChatId === null`), когда есть
включённые роли, как `<Select label="Agent role">`:
`apps/client/src/features/ai-chat/components/ai-chat-window.tsx:543-561`.
Значение `""` → «Universal assistant» (роль `null`); остальные опции —
`enabledRoles` (эмодзи + имя).
2. Список включённых ролей фильтруется клиентом из всех живых ролей:
`ai-chat-window.tsx:144-147` (`enabledRoles = roles.filter(r => r.enabled)`).
Источник — `useAiRolesQuery(windowOpen)`
(`apps/client/src/features/ai-chat/queries/ai-chat-query.ts:131-137`).
3. Выбранный id хранится в jotai-атоме:
`apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts:23`
(`selectedAiRoleIdAtom`, `null` = Universal assistant). Сбрасывается в `null`
при «New chat»: `ai-chat-window.tsx:168-174` (`startNewChat`).
4. Выбранный id прокидывается в тред и уходит в теле первого запроса:
`ai-chat-window.tsx:570-578` (`roleId={activeChatId === null ? selectedRoleId : null}`)
`apps/client/src/features/ai-chat/components/chat-thread.tsx:95-96, 128-138`
(`roleIdRef``prepareSendMessagesRequest` кладёт `roleId` в body).
Сервер учитывает `roleId` ТОЛЬКО при создании чата и фиксирует роль навсегда;
для существующего чата роль читается из строки чата (бейдж в шапке окна:
`ai-chat-window.tsx:433-440`).
5. Пустая область чата сейчас — бледный текст по центру:
`apps/client/src/features/ai-chat/components/message-list.tsx:130-140`
(`<Center>` + `emptyState ?? t("Ask the AI agent anything...")`).
Важно: `MessageList` УЖЕ принимает произвольный `emptyState: ReactNode`
(`message-list.tsx:10-33, 64-70`) — этим пользуется публичный шэр.
Данные роли в picker-представлении (доступны не-админам):
`id, name, emoji, description, enabled`
`apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts:35-41, 164-173`.
То есть для карточек есть эмодзи и название (описание опционально).
## Желаемое поведение
- Вместо `<Select>` — карточки разных цветов по центру пустого окна чата.
- Каждая карточка = identity (роль), отдельный цвет, по центру эмодзи + имя.
- Отдельная карточка **Universal assistant** (дефолт), подсвечена по умолчанию.
- Клик по карточке выбирает/применяет identity (визуальная подсветка выбранной).
- Если ни одна карточка не нажата и пользователь отправил сообщение → роль `null`
→ Universal assistant (текущая дефолтная ветка сервера).
- После отправки первого сообщения карточки исчезают (чат больше не пуст).
## Ключевое архитектурное решение
Рисовать карточки **как empty-state** окна чата через уже существующий проп
`emptyState` у `MessageList`, а НЕ отдельным блоком над полем ввода. Почему так:
- «посреди пустого окна чата» получается само: `MessageList` оборачивает
`emptyState` в `<Center>` (`message-list.tsx:130-140`).
- «не нажал и написал сообщение → дефолт» получается само: как только
`messages.length > 0`, empty-state (и карточки) не рендерится, а
`selectedRoleId` остаётся `null` → Universal assistant. Никакой логики
«сбросить выбор при отправке» не нужно.
- Состояние выбора остаётся в том же `selectedAiRoleIdAtom`, поэтому вся
серверная обвязка (`roleId` в body, фиксация роли при создании чата) **не
меняется** — изменения чисто фронтовые.
Поток: `AiChatWindow` собирает узел карточек → новый проп `emptyState` у
`ChatThread` → форвард в `MessageList`.
## Состав изменений
1. **Новый компонент `role-cards.tsx`** (+ `role-cards.module.css`),
`apps/client/src/features/ai-chat/components/`:
- Пропсы: `roles: IAiRole[]`, `selectedRoleId: string | null`,
`onSelect: (id: string | null) => void`.
- Рендер: контейнер карточек с переносом (flex-wrap), по центру:
- первая карточка — Universal assistant (значение `null`), нейтрально-серая,
подсвечена когда `selectedRoleId === null`;
- по карточке на каждую роль: цвет по индексу, по центру эмодзи (если есть)
+ имя; подсвечена когда `selectedRoleId === r.id`.
- Карточка — `UnstyledButton` (доступность + темизация Mantine). Клик →
`onSelect(value)`. Выбранная — более яркий бордер/кольцо + галочка.
- Цвета — фиксированная палитра имён Mantine, циклично по индексу:
`blue, grape, teal, orange, pink, cyan, lime, indigo, red, violet`.
Через theme-aware CSS-переменные (корректны и в светлой, и в тёмной теме):
фон `var(--mantine-color-${c}-light)`, текст
`var(--mantine-color-${c}-light-color)`, бордер выбранной
`var(--mantine-color-${c}-filled)`. Universal — `gray`.
- Раскладка (размер карточек ~100–130px, отступы, hover, кольцо выбора,
прокрутка при большом числе ролей) — в CSS-модуле; цвет инжектится инлайн.
2. **`ai-chat-window.tsx`**:
- Удалить блок `<Select>` (`:543-561`) и импорт `Select` (`:9`, используется
только там — проверить, что `Group/Loader/Tooltip` остаются нужны).
- Собрать узел карточек только когда `activeChatId === null &&
enabledRoles.length > 0`, иначе `undefined`.
- Передать его в `<ChatThread emptyState={...} />` (`:570-578`). Существующее
`roleId={...}` без изменений.
3. **`chat-thread.tsx`**:
- Добавить необязательный проп `emptyState?: ReactNode` (импорт `ReactNode`)
и форварднуть в `<MessageList emptyState={...} />` (`:164`).
4. **`message-list.tsx`** — без изменений (проп `emptyState` уже поддержан).
Иллюстративный набросок (НЕ финальный код), `AiChatWindow`:
```tsx
// Role cards become the empty-state ONLY for a brand-new chat that has roles.
const roleCardsNode =
activeChatId === null && enabledRoles.length > 0 ? (
<RoleCards
roles={enabledRoles}
selectedRoleId={selectedRoleId}
onSelect={setSelectedRoleId}
/>
) : undefined;
// ...
<ChatThread
...
roleId={activeChatId === null ? selectedRoleId : null}
emptyState={roleCardsNode}
/>
```
## Краевые случаи
- **Нет включённых ролей** → карточки не показываем (`emptyState = undefined`),
остаётся обычный дефолтный текст empty-state.
- **Существующий чат** (`activeChatId !== null`) → карточек нет; роль уже
зафиксирована и показана бейджем в шапке (`ai-chat-window.tsx:433-440`).
- **Сброс выбора** при «New chat» уже делается (`setSelectedRoleId(null)`,
`startNewChat`) — поведение сохраняется.
- **Много ролей** → контейнер с переносом и прокруткой, чтобы не ломать пустую
область чата.
- **Тёмная тема** → за счёт `-light`/`-filled` переменных Mantine цвета
корректны в обеих темах.
- **Эмодзи нет** → карточка показывает только имя (как сейчас в `<Select>`:
`r.emoji ? ... : ''`).
## Локализация
Новых ключей не требуется: переиспользуем существующие `t("Agent role")` и
`t("Universal assistant")` (есть в `apps/client/public/locales/en-US/translation.json:1220-1221`;
остальные локали падают на ключ — как сейчас у `<Select>`). Если решим добавить
подпись-подсказку (например «или просто начните печатать») — это один новый ключ
в `en-US/translation.json`; по умолчанию в объём не закладываю.
## Режим работы при реализации
Изменение нетривиальное (новый компонент + логика выбора/цветов + интеграция с
empty-state), поэтому — делегирование кодеру с обязательным последующим ревью
(`review` subagent), затем верификация перечитыванием файлов.
## Открытые вопросы (решить перед/во время реализации)
- [ ] Нужна ли карточка Universal assistant отдельной плиткой, или достаточно
«ничего не выбрано = дефолт»? Предлагается отдельная карточка (явный
возврат к дефолту после клика по роли) — подтвердить.
- [ ] Показывать ли `description` роли на карточке (есть в picker-view) или
только эмодзи + имя? По умолчанию — только эмодзи + имя, описание в `title`.
- [ ] Нужна ли подпись-подсказка над карточками (тогда +1 ключ локали).

View File

@@ -0,0 +1,33 @@
# Отложенные интеграционные тесты `AiChatService.stream`
Статус: **открыто.** Это остаток от прежнего документа
`feature-test-coverage-deferred.md` (хвост тест-плана PR #49). Два из трёх
его разделов уже закрыты новой интеграционной обвязкой против реального
Postgres/Redis (`apps/server/test/integration/`, PR #115):
-**Раздел 1 — repo-тесты против БД.** Закрыт `ai-agent-roles-repo`,
`ai-chat-repo-find-by-creator`, `page-template-references-cascade`,
`workspace-repo-update-setting` (`*.int-spec.ts`).
-**Раздел 2 — достоверность Lua-окна cost-cap против реального Redis.**
Закрыт `public-share-workspace-limiter.int-spec.ts`.
-**Раздел 3 (ниже) — полная интеграция `AiChatService.stream`.** Всё ещё
не реализован; держим запись открытой, чтобы тест-долг не потерялся при
удалении исходного документа.
## Полная интеграция `AiChatService.stream` (рефактор R1-stream)
`apps/server/src/core/ai-chat/ai-chat.service.ts`. В PR #49 извлечён и
покрыт только чистый `buildErrorAssistantRecord`. Полные интеграционные
сценарии всё ещё отложены:
- **Запись чата, упавшего на первом ходу** (`onError`) — ассистентская
запись об ошибке должна сохраняться, даже когда первый ход стрима падает.
- **Жизненный цикл external-MCP клиентов** — клиенты закрываются и при
`throw`, и при `onFinish` (нет утечки соединений).
- **Анти-tamper: история восстанавливается из БД, а не из `body.messages`** —
клиент не может подменить историю через тело запроса.
Эти сценарии требуют сидирования SDK `streamText` (инъекция/seam колбэков
`onError` / `onFinish` / `onAbort` + `res.hijack`). Отложено, чтобы не
дестабилизировать 287-строчный `stream()`; делать вместе с выносом testable
turn-pipeline.

View File

@@ -1,8 +1,26 @@
# Дублирование определений инструментов: in-app агент vs standalone MCP-пакет
Статус: **зафиксировано в беклоге, код не менялся.** Это forward-looking
стоимость поддержки, НЕ баг — код корректен сегодня. Фиксируем, чтобы при
росте набора инструментов (см. §16) долг не разъезжался молча.
Статус: **частично закрыто.** Квирк «node как объект ИЛИ JSON-строка» вынесен
в общий хелпер `parseNodeArg` (см. «Прогресс» ниже); остальной долг (единый
реестр спеков + унификация конвертера) всё ещё открыт. Это forward-looking
стоимость поддержки, НЕ баг — код корректен сегодня. Держим запись открытой,
чтобы при росте набора инструментов долг не разъезжался молча.
## Прогресс
-**Квирк node-arg вынесен в хелпер** (`refactor/ai-chat-tool-spec-registry`,
PR #114). Шесть рукописных копий нормализации «node как объект ИЛИ
JSON-строка» свёрнуты в `parseNodeArg`: по одному источнику на пакет —
`packages/mcp/src/lib/parse-node-arg.ts` (standalone) и
`apps/server/src/core/ai-chat/tools/parse-node-arg.ts` (in-app). Две копии
намеренны (ESM/CJS-граница), поведение тождественно.
-**Единый реестр спеков** (схема + описание на инструмент) и **вывод
`DocmostClientLike` из реального типа** — отложены (см. «Фикс»): требуют
пересечения ESM/CJS-границы для данных+zod и ломают тест-стабы in-app
инструментов при точных типах. Делать инкрементально.
-**Унификация конвертера ProseMirror ↔ Markdown** — открыта (см. раздел
«Расширение …» ниже); на неё опирается план git-синка
(`docs/git-sync-plan.md`).
## Суть
@@ -28,12 +46,13 @@ parity-баги (расхождение копий) приходится чин
## Что именно продублировано (с подтверждением по коду)
- **zod-схема + описание** каждого инструмента — в слоях 1 и 2 целиком.
- **Квирк «node как объект ИЛИ JSON-строка»** реализован дважды (НЕ в общем
клиенте):
- in-app: `ai-chat-tools.service.ts:686` (patchNode), `:745` (insertNode),
`:800` (updatePageJson);
- standalone: `index.ts:526` (patch_node), `:578` (insert_node), `:350`
(update_page_json).
- ~~**Квирк «node как объект ИЛИ JSON-строка»** реализован дважды (НЕ в общем
клиенте)~~ — **закрыто (PR #114):** вынесен в `parseNodeArg` (по хелперу на
пакет), 6 inline-копий устранены:
- in-app: `patchNode`, `insertNode`, `updatePageJson`
`apps/server/src/core/ai-chat/tools/parse-node-arg.ts`;
- standalone: `patch_node`, `insert_node`, `update_page_json`
`packages/mcp/src/lib/parse-node-arg.ts`.
- **Guardrail/семантика `transformPage` (dryRun)** описана в обоих:
`ai-chat-tools.service.ts:~935` и `index.ts:~1006`.

View File

@@ -1,93 +0,0 @@
# Отложенные тесты по фичам с коммита 053a9c0d (хвост от PR #49)
## Контекст
PR #49 («test: cover features since 053a9c0d + repair test tooling») закрыл
основную массу покрытия новых фич gitmost (+~330 тестов: server/Jest,
client/Vitest, editor-ext/Vitest, packages/mcp/node:test) и починил
тест-инструментарий (FIX-0 сломанные спеки transclusion, BUILD-0 сборка
editor-ext перед серверными тестами, INFRA-0 резолв `.tsx` email-шаблонов).
Часть тестов из принятого тест-плана **намеренно отложена** — им нужен
тестовый Postgres, реальный Redis или HTTP/e2e-харнес, которых в проекте
сейчас нет, либо инвазивный рефактор продакшн-кода. Ниже — что осталось и
почему, чтобы не потерять.
---
## 1. Интеграционные тесты против БД (нужен тестовый Postgres)
Сейчас все repo-зависимые проверки делаются на моках; SQL-уровень не
исполняется. Чтобы покрыть это честно, нужен поднимаемый в CI Postgres
(testcontainers или сервис в pipeline) + хелпер миграций.
- **`AiAgentRoleRepo` — изоляция и индексы.**
`apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.ts`.
Проверить против реальной БД: `findById`/`listByWorkspace` исключают
soft-deleted строки; `findById` для roleId из ЧУЖОГО workspace → undefined
(tenant-изоляция); дубль имени в одном workspace → 23505; то же имя
переиспользуемо после softDelete (partial unique index
`WHERE deleted_at IS NULL`, миграция `20260620T120000-ai-agent-roles.ts`);
одинаковое имя в разных workspace разрешено. Это «хребет» безопасности —
сейчас только предполагается unit-моками.
- **`AiChatRepo.findByCreator` — join role-badge.**
`apps/server/src/database/repos/ai-chat/ai-chat.repo.ts` (~:27-70).
Чат с enabled-ролью → roleName/roleEmoji заполнены; с soft-deleted ролью →
бейдж NULL; с DISABLED ролью → бейдж NULL (должно совпадать с
`resolveRoleForRequest`); ORDER BY квалифицирован `aiChats.*` (нет
ambiguous column после join). Не проверяемо чистым unit-ом.
- **`WorkspaceService.update` / `WorkspaceRepo.updateSetting` — jsonb-merge.**
`apps/server/src/core/workspace/services/workspace.service.ts` (~:514),
`apps/server/src/database/repos/workspace/workspace.repo.ts` (~:275).
Сейчас покрыта только форма вызова сервиса
(`workspace-html-embed.spec.ts`). Не покрыто (нужна БД): `htmlEmbed:true`
персистится через jsonb-merge **не затирая** соседние настройки (ai,
sharing). Это и есть «kill-switch пишется» — критично, что write-половина
тоггла не ломает остальной settings-namespace.
- **FK `page_template_references` onDelete('cascade').**
Миграция `20260620T131000-page-template-references.ts`. Проверить, что
удаление source/reference-страницы каскадит строки ссылок.
## 2. HTTP / e2e-харнес (его нет в apps/server)
- **Public-share ассистент: обход per-IP throttle ротацией XFF, но
per-workspace cap держит.**
Контроллер использует стоковый `@UseGuards(ThrottlerGuard)`
(`apps/server/src/core/ai-chat/public-share-chat.controller.ts`), IP берётся
из Fastify `trustProxy``X-Forwarded-For`. Единственный оправданный e2e
(named journey «аноним спамит ассистента»): ротация XFF обходит per-IP
лимит 5/min, но per-workspace cost-cap всё равно отдаёт 429. Требует
поднятого HTTP-слоя Nest + trusted-proxy конфигурации.
- **Достоверность Lua-окна cost-cap против реального Redis.**
`apps/server/src/core/ai-chat/public-share-workspace-limiter.ts`
(`SLIDING_WINDOW_LUA`). Сейчас cap тестируется против TS-реализации
`FakeRedis` в `public-share-chat.spec.ts` — баг в самой Lua-строке
(`>=` vs `>`, неверный PEXPIRE) не поймается. Нужен интеграционный тест
против реального/testcontainers Redis.
## 3. Полная интеграция `AiChatService.stream` (рефактор R1-stream)
`apps/server/src/core/ai-chat/ai-chat.service.ts`. В PR #49 извлечён и
покрыт только чистый `buildErrorAssistantRecord`. Полные интеграционные
сценарии — **запись чата, упавшего на первом ходу** (onError), жизненный
цикл external-MCP клиентов (закрытие при throw/onFinish), и
**история восстанавливается из БД, а не из `body.messages`** (анти-tamper) —
требуют сидирования SDK `streamText` (инъекция/seam колбэков `onError`/
`onFinish`/`onAbort` + `res.hijack`). Отложено, чтобы не дестабилизировать
287-строчный `stream()`; делать вместе с выносом testable turn-pipeline.
---
## Сопутствующие НЕ-тестовые находки
Вынесены в отдельные issues (всплыли во время написания тестов):
- #52 — ai-roles: нет серверной валидации модели роли + дрейф enum драйверов.
- #53 — ws: `invalidateSpaceRestrictionCache` без вызывающих (30с stale-окно).
- #54 — page-embed: серверный guard глубины/циклов раскрытия.
- #55 — transclusion: cycle-guard в `collectPageEmbedsFromPmJson`.
- #56 — test-infra: jest DI + lib0 ESM (16 падающих сьютов).

View File

@@ -4,6 +4,7 @@ import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { DocmostClient } from "./client.js";
import { parseNodeArg } from "./lib/parse-node-arg.js";
// Re-export the client and its config type so embedding hosts (e.g. the gitmost
// NestJS server) can `import('@docmost/mcp')` and construct a DocmostClient
// directly — for the credentials variant OR the per-user getToken variant.
@@ -245,16 +246,9 @@ export function createDocmostMcpServer(config) {
if (content === undefined || content === null) {
doc = undefined;
}
else if (typeof content === "string") {
try {
doc = JSON.parse(content);
}
catch {
throw new Error("content was a string but not valid JSON");
}
}
else {
doc = content;
// String -> JSON.parse (throwing on invalid); object passes through.
doc = parseNodeArg(content, "content was a string but not valid JSON");
}
const result = await docmostClient.updatePageJson(pageId, doc, title);
return jsonContent(result);
@@ -379,18 +373,7 @@ export function createDocmostMcpServer(config) {
"JSON object or JSON string both accepted."),
},
}, async ({ pageId, nodeId, node }) => {
let parsedNode;
if (typeof node === "string") {
try {
parsedNode = JSON.parse(node);
}
catch {
throw new Error("node was a string but not valid JSON");
}
}
else {
parsedNode = node;
}
const parsedNode = parseNodeArg(node);
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
return jsonContent(result);
});
@@ -425,18 +408,7 @@ export function createDocmostMcpServer(config) {
anchorText: z.string().optional(),
},
}, async ({ pageId, node, position, anchorNodeId, anchorText }) => {
let parsedNode;
if (typeof node === "string") {
try {
parsedNode = JSON.parse(node);
}
catch {
throw new Error("node was a string but not valid JSON");
}
}
else {
parsedNode = node;
}
const parsedNode = parseNodeArg(node);
const result = await docmostClient.insertNode(pageId, parsedNode, {
position,
anchorNodeId,

View File

@@ -0,0 +1,15 @@
// The model sometimes serializes a ProseMirror node arg as a JSON string
// instead of an object. Normalize: parse a string to an object (throwing on
// invalid JSON), pass an object through unchanged. Shared by patch_node /
// insert_node (and the analogous update_page_json content parsing).
export function parseNodeArg(node, errMsg = "node was a string but not valid JSON") {
if (typeof node === "string") {
try {
return JSON.parse(node);
}
catch {
throw new Error(errMsg);
}
}
return node;
}

View File

@@ -4,6 +4,7 @@ import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { DocmostClient, DocmostMcpConfig } from "./client.js";
import { parseNodeArg } from "./lib/parse-node-arg.js";
// Re-export the client and its config type so embedding hosts (e.g. the gitmost
// NestJS server) can `import('@docmost/mcp')` and construct a DocmostClient
@@ -354,14 +355,9 @@ server.registerTool(
let doc;
if (content === undefined || content === null) {
doc = undefined;
} else if (typeof content === "string") {
try {
doc = JSON.parse(content);
} catch {
throw new Error("content was a string but not valid JSON");
}
} else {
doc = content;
// String -> JSON.parse (throwing on invalid); object passes through.
doc = parseNodeArg(content, "content was a string but not valid JSON");
}
const result = await docmostClient.updatePageJson(pageId, doc, title);
return jsonContent(result);
@@ -529,16 +525,7 @@ server.registerTool(
},
},
async ({ pageId, nodeId, node }) => {
let parsedNode;
if (typeof node === "string") {
try {
parsedNode = JSON.parse(node);
} catch {
throw new Error("node was a string but not valid JSON");
}
} else {
parsedNode = node;
}
const parsedNode = parseNodeArg(node);
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
return jsonContent(result);
},
@@ -581,16 +568,7 @@ server.registerTool(
},
},
async ({ pageId, node, position, anchorNodeId, anchorText }) => {
let parsedNode;
if (typeof node === "string") {
try {
parsedNode = JSON.parse(node);
} catch {
throw new Error("node was a string but not valid JSON");
}
} else {
parsedNode = node;
}
const parsedNode = parseNodeArg(node);
const result = await docmostClient.insertNode(pageId, parsedNode, {
position,
anchorNodeId,

View File

@@ -0,0 +1,17 @@
// The model sometimes serializes a ProseMirror node arg as a JSON string
// instead of an object. Normalize: parse a string to an object (throwing on
// invalid JSON), pass an object through unchanged. Shared by patch_node /
// insert_node (and the analogous update_page_json content parsing).
export function parseNodeArg(
node: unknown,
errMsg = "node was a string but not valid JSON",
): unknown {
if (typeof node === "string") {
try {
return JSON.parse(node);
} catch {
throw new Error(errMsg);
}
}
return node;
}

View File

@@ -0,0 +1,32 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseNodeArg } from "../../build/lib/parse-node-arg.js";
test("parseNodeArg passes an object through unchanged", () => {
const obj = { type: "paragraph", content: [] };
assert.strictEqual(parseNodeArg(obj), obj);
});
test("parseNodeArg passes undefined/null through unchanged", () => {
assert.strictEqual(parseNodeArg(undefined), undefined);
assert.strictEqual(parseNodeArg(null), null);
});
test("parseNodeArg parses a valid JSON string", () => {
const parsed = parseNodeArg('{"type":"paragraph"}');
assert.deepStrictEqual(parsed, { type: "paragraph" });
});
test("parseNodeArg throws the default message on invalid JSON string", () => {
assert.throws(() => parseNodeArg("{not json"), {
message: "node was a string but not valid JSON",
});
});
test("parseNodeArg throws a custom message on invalid JSON string", () => {
assert.throws(
() => parseNodeArg("{not json", "content was a string but not valid JSON"),
{ message: "content was a string but not valid JSON" },
);
});