Compare commits

...

27 Commits

Author SHA1 Message Date
claude code agent 227
0643cd1d82 test(share): exercise 70-char title-slug clamp in alias redirect
The controller's buildPageSlug truncates the page title via
`title?.substring(0, 70)` before slugifying, but no test drove that
branch (the only titled case was 16 chars). Add a resolvable-alias
case with a 119-char title whose 70-char boundary falls mid-word and
assert the 302 target's slug reflects only the first 70 characters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:05:21 +03:00
claude code agent 227
1043fe3b51 test(share): cover alias controllers; address PR #214 review
Add the two blocking test-coverage specs requested in the PR #214 review and
clear the cheap non-blocking items.

Must-fix:
- share-alias-redirect.controller.spec.ts: routing/leak guard for the public
  GET /l/:alias resolver (modeled on share-seo.controller.routing.spec). Pins
  302-to-canonical on a hit; SPA index without a 302 for unknown/dangling/
  unreadable aliases and a null workspace (no name-existence leak); defensive
  percent-decoding treated as unknown; self-hosted findFirst vs subdomain
  findByHostname workspace resolution; 404 when no built client index exists.
- share-alias.controller.spec.ts: authz gates with mocked PageRepo/ShareService/
  ShareAliasService/PageAccessService. Covers cross-workspace/nonexistent page
  -> NotFoundException, validateCanEdit, resolveReadableSharePage null ->
  BadRequestException, isSharingAllowed false -> ForbiddenException, set happy
  path delegation, remove() of a dangling alias (pageId null) skipping
  validateCanEdit but still deleting, and for-page validateCanView.

Cheap review items:
- Remove dead Logger import/field from ShareAliasRedirectController.
- Remove dead PagePermissionRepo import/dependency from ShareAliasController.
- Register the new share-alias UI strings in en-US and ru-RU catalogs.
- Add an [Unreleased]/Added CHANGELOG entry for /l/:alias (#205).
- Drop the tautological boilerplate assertions from the migration spec
  (exports up/down; runtime checks of typed entity literals).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:22:29 +03:00
claude code agent 227
fdeede003b feat(share): custom /l/:alias pretty links (share_aliases table) (#205)
Add a retargetable, human-readable vanity link namespace /l/<alias> that
sits alongside the untouched /share/... routes.

- New share_aliases table (workspace-scoped, UNIQUE(workspace_id, alias),
  page_id nullable ON DELETE SET NULL so the address outlives its target).
- ShareAliasRepo + ShareAliasService (create / no-op / 409 reassign guard /
  availability / request-time readable-target resolution through the single
  existing share boundary).
- Public ShareAliasRedirectController (GET /l/:alias) issues a 302 (never 301,
  the target is mutable) to the canonical /share/:key/p/:slug page; unknown /
  dangling / no-longer-readable aliases serve the SPA index with no leak.
  'l/:alias' excluded from the global /api prefix.
- Authenticated ShareAliasController (set/remove/availability/for-page).
- Shared ASCII-only normalize/validate util (server + client copies).
- Client: Custom address block in the share modal (live normalize + debounced
  availability + copy + reassign confirmation dialog).
- Unit tests: util, repo SQL-shape, service semantics, migration/entity sanity
  (server jest) + client alias util (vitest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:28:26 +03:00
claude_code
3ddc329bba Merge pull request 'Батч: ai-chat/footnotes/mcp/db/tree + red-team (#163 #181 #164 #173 #168 #180 + #159 8/10)' (#185) from batch/issues-2026-06-25 into develop 2026-06-25 12:49:15 +03:00
claude code agent 227
ed3b65c36b Merge remote-tracking branch 'gitea/develop' into batch/issues-2026-06-25
# Conflicts:
#	apps/server/src/core/ai-chat/ai-chat.service.spec.ts
#	apps/server/src/core/ai-chat/ai-chat.service.ts
2026-06-25 12:48:47 +03:00
claude_code
de115ade1e Merge pull request 'feat(ai-chat): persistent history as source of truth — step durability + server export (#183)' (#186) from feat/ai-chat-persistent-history into develop 2026-06-25 12:40:36 +03:00
claude code agent 227
364838d0b2 test(review): close the two test-coverage gaps from PR #185 auto-review
Approve-with-comments auto-review (8 axes); no blockers. Closes the two flagged
test gaps; the two forward-looking dedup suggestions (reconcileHasChildren helper;
unifying reconcileChildren/mergeRootTrees) are non-blocking architecture notes and
left for a follow-up (as with #186's forward-looking point).

1. Ambiguous-id refusal end-to-end (#159): the patch_node/delete_node guard
   `if (replaced/deleted !== 1) return null` was only covered in pieces — the
   replaceNodeById/deleteNodeById counts and assertUnambiguousMatch in isolation —
   so loosening the guard would not have failed a test. New mock test stands up a
   REAL Hocuspocus collab server seeded (via buildYDoc, same docmost extensions)
   with a two-blocks-one-id document and drives the real client methods: both must
   reject with /ambiguous/ AND never write to collab. Tracked via Hocuspocus
   onChange (fires synchronously per update, unlike the debounced onStoreDocument)
   so a clobbering write is actually observed — verified the test FAILS when the
   guard is loosened to `< 1`.

2. scrollToReference zero-match bail: the branch "non-empty id but querySelectorAll
   returns 0 -> matches[index] ?? matches[0] is undefined -> return false" (the real
   desync: definition present, inline ref removed from the DOM) was uncovered. Added
   a footnote.test.ts case: a definition for 'ghost' with no rendered ref -> false,
   no scroll.

Verified: 313 mcp tests + 24 editor-ext footnote tests; prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:39:18 +03:00
claude code agent 227
aa7a115f66 refactor(review): address PR #186 re-review (approve-with-comments)
Approve-with-comments re-review; no blockers. All 7 actionable points (8 is a
forward-looking architecture note — recommendation A, keep as-is):

1. chat-markdown.util spec: restore parity coverage of the removed client spec —
   tool error state (+ errorText), unknown-tool fallback (`Ran tool <name>` en /
   `Выполнил инструмент <name>` ru), and the circular-output stringify catch.
2. findAllByChat row cap is now testable (injectable limit) + an int-spec proves
   truncation on a modest volume.
3. Stability: the per-step durability updates are SERIALIZED via a promise chain
   (stepUpdateChain) so they commit in step order — onlyIfStreaming already
   closed the finalize race, this closes inter-step ordering.
4. findAllByChat keeps the NEWEST messages on truncation (order DESC + reverse,
   like findRecent) and logs a warning with chatId, instead of silently dropping
   the newest tail.
5. The LABELS parity comment already references the real path (tool-parts.tsx /
   toolLabelKey) — confirmed accurate.
6. Removed the redundant 'off-by-one boundary' test (strict subset of the two
   adjacent prepareAgentStep cases).
7. Extracted the terminal-finalize dispatch into a shared `applyFinalize`, used
   by BOTH the service's finalizeAssistant and its test — the test now exercises
   the real path, not a copy, so a production drift fails it.

Verified: server build + 325 ai-chat unit + 6 integration; prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:28:35 +03:00
claude code agent 227
30c358a2f8 test(review): add the 4 new test-coverage points from PR #185 re-review
The re-review's blocking/structural points (lease leak, dup-id guard test,
body-before-title test, CHANGELOG, pg18, shared jsonb decoder) were already
addressed in commit 24264ef; this adds the 4 genuinely-new coverage requests:

- pt 6: `scrollToReference(id, index?)` exercised against a live editor DOM —
  selects the index-th `sup[data-footnote-ref][data-id]` occurrence, falls back
  to the first for out-of-range, returns false for an empty id (scrollIntoView
  stubbed). (#168)
- pt 7: export `backlinkLabel` and pin the base-26 carry boundary
  (25->z, 26->aa, 27->ab, 51->az, 52->ba). (#168)
- pt 8: integration fail-open — a PRESENT-but-corrupt tool_allowlist (jsonb
  string scalar holding non-array JSON) reads back as null ("no restriction"),
  covering normalizeRow's degrade branch. (#159 #172/#173)
- pt 9: getFootnoteRefCount cache invalidation — adding a `[^a]` reference bumps
  the cached count 2 -> 3. (#168)

Verified: editor-ext footnote 23; client structure 7 + tsc; server int 8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:08:21 +03:00
claude code agent 227
ea61c96a7c refactor(review): address PR #186 review (#183 — recency sweep, #174 export, tests, cleanups)
15-point review of the persistent-history PR. Architecture decisions: crash
recovery = recency threshold; tool-label duplication = leave as-is.

Must-fix:
1. Boot-sweep bounded by recency. sweepStreaming now also requires
   `updatedAt < now() - SWEEP_STREAMING_STALE_MS` (10 min), so a fresh replica's
   startup sweep can't abort a turn another replica is actively streaming
   (multi-instance deploy). Int-spec: a FRESH 'streaming' row is NOT swept, a
   STALE one IS.
2. Restore export during the FIRST streaming turn of a new chat (#174). The
   server chatId is now adopted EARLY (in-place, on the start-chunk metadata) via
   a new `onServerChatId` callback wired through use-chat-session → chat-thread,
   so `activeChatId` is set at turn start and the Copy button is live mid-first-
   turn (canExport = !!activeChatId). Hook tests for early/in-place/no-op adopt.
3. Cover finalizeAssistant's fallback-insert branch: extracted pure
   `planFinalizeAssistant(assistantId)` (update when id present, insert when the
   upfront insert failed) + a dispatch harness test for both arms.

Tests: onModuleInit lifecycle spec (sweep called; throw → resolves + warns);
int-spec updatedAt assertion → toBeGreaterThan.

Cleanups: cap findAllByChat at 5000 rows; upfront-insert-failure log carries
chatId+workspaceId; removed the now-dead buildPartialAssistantRecord (only the
spec consumed it; shapes still pinned by the flushAssistant suite); controller
passes `lang: dto.lang` (normalizeLang handles undefined); dropped a no-op
`?? undefined` in errorOf; documented the content-column semantics change
(concatenated step text, UI renders from metadata.parts); CHANGELOG [Unreleased]
entry (#183, #174); reworded the stale LABELS parity comment.

Verified: server build + 323 ai-chat unit + 5 integration; client tsc + 160
ai-chat unit; prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:53:25 +03:00
claude code agent 227
f80276d41a refactor(review): address PR #185 review (lease leak, tests, changelog, jsonb seam)
8-point multi-aspect review of the batch PR; security/regressions were clean.

1. Lease leak: the #180 reorder moved `toolsFor` (which leases external MCP
   clients, refCount+1) ahead of buildSystemPrompt + forUser, but the only
   release (closeExternalClients) was bound to the streamText callbacks. A throw
   in between leaked the lease (refCount stuck, undici sockets held until
   restart). Define closeExternalClients right after the lease and wrap
   buildSystemPrompt+forUser in try/catch that closes-then-rethrows.
2. Cover the patch_node/delete_node dup-id refusal (#159 #6): extract the guard
   into a pure `assertUnambiguousMatch` (node-ops) and unit-test 0/1/>1.
3. Regress the body-before-title order (#159 #10): mock-HTTP test (collab fails
   fast against a server with no WS upgrade) asserts /pages/update (title) is
   NEVER posted when the body write fails — for updatePage AND updatePageJson.
4. CHANGELOG [Unreleased]: #180, #168 (Added); #163 (Fixed).
5. Add the missing en-US i18n keys (Back to references / {{label}}).
6. Drop the duplicate content/empty/blank cases in ai-chat.prompt.spec.ts (they
   repeat the buildMcpToolingBlock unit tests); keep only sandwich placement +
   both-safety-copies.
7. CI Postgres pg16 -> pg18 (match docker-compose).
8. jsonb decode seam: shared `parseJsonbValue(value, guard)` in database/utils.ts
   holds the legacy double-encoding self-heal in one place; parseToolAllowlist /
   parseModelConfig keep only a type-guard.

Verified: server build + 124 unit + 15 integration; mcp 311; prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
8218c1a8ef fix(tree): refresh loaded branches on reconnect so they don't go stale (#159)
Third tree-sync finding (#8). On a socket reconnect after a missed-events gap
(laptop sleep / wifi blip), the resync only invalidated the ROOT sidebar query;
a move/rename/delete that happened INSIDE an already-loaded, expanded branch was
never reflected — the branch stayed stale until the user manually interacted.
(The #2 fix reconciles the root level; this covers the deeper loaded branches.)

- `treeModel.reconcileChildren(tree, parentId, fresh)`: replace a loaded
  branch's DIRECT children with the authoritative fresh set (drop removed, add
  new, reorder to server) while PRESERVING each surviving child's already-loaded
  grandchildren, so deeper expansion is not collapsed. An unloaded branch
  (children === undefined) is left untouched (lazy-load fetches it fresh).
- `loadedOpenBranchIds(tree, openIds)`: the branches a reconnect should refresh
  (open AND loaded). `fetchAllAncestorChildren(..., { fresh: true })` bypasses
  the 30-min sidebar cache so the reconcile sees current data (handler-order
  independent).
- space-tree: on socket `connect`, re-fetch + reconcile each open loaded branch
  of the active space (space-switch-guarded; an unloaded branch is skipped).

Tests: reconcileChildren (drop/add/reorder + preserve grandchildren + unloaded
no-op) and loadedOpenBranchIds (open+loaded only, skip unloaded, nested). The
pure logic is unit-tested; the live socket-reconnect round-trip is not
browser-automated (simulating a reconnect gap is impractical) — sidebar render +
expand were smoke-tested with no regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
d7e7489654 fix(tree): stop silent page loss on move-to-unloaded-parent + reconnect ghost roots (#159)
Two confirmed P1 data-loss findings in the sidebar tree sync.

#1 — Move into an unloaded/collapsed parent silently dropped pages. When a
moveTreeNode (or addTreeNode) broadcast targeted a parent whose children were
NOT yet lazy-loaded, `insertByPosition` did `kids = parent.children ?? []` and
inserted the moved node, MATERIALIZING a misleading partial child list
(`[movedNode]`) out of an unloaded (`children === undefined`) parent. The
lazy-load gate fetches only when children are absent/empty, so it then refused
to fetch — leaving the parent showing ONLY the moved node and HIDING all its
other real children (and, when the parent wasn't in the tree at all, the node
was removed and never re-fetched). Fix: `insertByPosition` distinguishes
`children === undefined` (not loaded) from `[]` (loaded-empty) and, for an
unloaded parent, does NOT insert — it leaves children unloaded and just flags
`hasChildren`, so expanding fetches the FULL set (including the moved/added
node) via the existing lazy-load.

#2 — After a socket reconnect, a deleted/moved-away root lingered as a 404
"ghost". `mergeRootTrees` was append-only: it kept every previously-loaded root
and only added new ones, so a root removed during the missed-events gap was
never dropped. It runs only once all root pages are fetched, so the incoming
list is the authoritative complete root set — fix reconciles to it (drop roots
absent from incoming) while PRESERVING each surviving root's lazy-loaded
subtree and refreshing its own fields.

Tests: insertByPosition unloaded-vs-loaded-empty parent; the move reducer
keeps a collapsed destination lazy-loadable instead of partial; mergeRootTrees
drops a ghost root, preserves a surviving subtree, adds new roots, refreshes
fields. The existing "remove when parent not in tree" reducer test still holds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
8f1af676ba fix(mcp): write page body before title to avoid split-brain on failure (#159)
updatePage (markdown) and updatePageJson wrote the title via REST FIRST, then
the body via collab. If the body write failed (e.g. a collab persist timeout),
the page was left with the NEW title over its OLD body — a split-brain the tool
reported as an error but never repaired (red-team finding #10).

Reorder both: write the body first, and only set the title after the body has
persisted. Now a body-write failure leaves the title untouched (no split-brain).
A title write failing after a successful body is rarer (REST is fast) and leaves
correct content under a stale title — the strictly lesser inconsistency — which
is the same trade-off the issue's "atomic, or roll back the title" intends,
without the fragility of a rollback write that could itself fail.

No unit test: both paths require a live collab provider and the suite has no
provider mock; the change is a pure reordering. All 306 mcp tests still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
34c5b557ef fix(share): SEO route must not leak a restricted page's title (#159)
`ShareSeoController.getShare` resolved the inherited share with the RAW
`getShareForPage`, which does NOT run the restricted-ancestor gate. So for a
page shared with includeSubPages whose descendant is permission-restricted, the
SEO route served that descendant's real title in <title>/og:title/twitter:title
to anonymous visitors and crawlers — even though the content API returns 404 for
it (red-team finding #3).

Funnel the SEO path through the canonical `resolveReadableSharePage` boundary
(the single place that checks `hasRestrictedAncestor`): a non-readable page now
serves the plain SPA index with no meta. Also honour `isSharingAllowed` — a
share whose workspace/space sharing toggle was flipped off after creation no
longer leaks its title via SEO. Title comes from the server-resolved page;
`buildShareMetaHtml` already emits robots=noindex when the share opted out of
indexing.

Tests (controller routing, fs spied at call time so bcrypt's native loader is
untouched): non-readable page => plain index, no title; sharing-disabled =>
plain index; readable+indexing => title + og:title, no noindex; readable+no-
indexing => noindex. Asserts getShareForPage is never called by the SEO path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
59f0c8b22d fix(ai-chat): validate the open page server-side so the agent edits the right one (#159)
The client sends the "current page" as { id, title } in the request body and the
server echoed BOTH verbatim into the system prompt context and the
getCurrentPage tool. id and title are independently attacker/desync-controllable
(two tabs, stale navigation), so openPage.id could point at page B while
openPage.title said "Page A" — the model then reported "updated Page A" while it
actually edited page B (CASL still allowed it; the user has access). Red-team
finding #4.

Resolve the open page ONCE against the DB via a new `resolveOpenPageContext`:
workspace-scoped lookup + access check, returning the AUTHORITATIVE { id, title }
(title from the DB row, never the client) or null (fail-closed) for a missing /
foreign / inaccessible page. That validated value now feeds the system prompt,
the getCurrentPage tool, AND the new-chat history origin (which previously did
this validation inline, for the id only — now shared, and the title is fixed
too).

Tests: resolveOpenPageContext covers no-id, not-found, foreign-workspace,
Forbidden, non-Forbidden-fault (fail-closed), the DB-title-wins-over-client case,
and null-title coercion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
77ccc596ea feat(ai-chat): per-MCP-server instructions in the agent system prompt (#180)
Admins can now give each EXTERNAL MCP server a free-text instruction ("how/
when to use this server's tools") that the agent receives in its SYSTEM
PROMPT next to the tool descriptions — porting the built-in SERVER_INSTRUCTIONS
idea to admin-configured servers. Trusted, admin-authored text (like a system
prompt); NON-secret, so unlike headersEnc it IS returned in views/forms.

- Migration: nullable `instructions text` on ai_mcp_servers (old rows = null =
  no guidance). Table type + repo insert/update (blank/whitespace -> null via
  blankToNull). DTO `@MaxLength(4000)`. Service threads it through
  McpServerView/toView.
- mcp-clients: `McpServerInstruction { serverName, toolPrefix, instructions }`
  threaded through the toolset/cache/lease. Guidance is built ONLY for a server
  that actually connected AND contributed >=1 callable tool (the allowlist may
  filter all of them out) AND has non-blank text — so a guide never appears for
  tools the agent cannot call. Cached with the toolset, so an edit is picked up
  next turn via the existing CRUD cache invalidation.
- System prompt: `buildMcpToolingBlock` renders an <mcp_tooling> block INSIDE
  the safety sandwich (after context, before the trailing SAFETY_FRAMEWORK) so
  it informs tool choice but cannot override the rules; each section is headed
  by the server's `prefix_*` namespace. Empty/blank -> block omitted. The
  caller (ai-chat.service) now builds the external toolset BEFORE the prompt and
  passes external.instructions; client-handle lifecycle (close-once) unchanged.
- Client: instructions field in types + a Textarea (autosize, maxLength 4000)
  in the MCP-server form with a namespace-prefix hint; i18n (en/ru).

Tests across every layer (prompt block placement + both SAFETY copies; view
blank->null; buildEntry includes guidance only for connected+>=1-tool+non-blank;
DTO MaxLength; repo + integration round-trip; service wiring). Delegated impl
reviewed (APPROVE); applied the import-type follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
e536c6f9a9 ci(test): run the server integration suite against real Postgres/Redis (#159)
The only test command in CI was `pnpm -r test` (unit `.spec.ts` on mocks).
`test:int` (`.int-spec.ts`, real Postgres/Redis) ran nowhere in CI — there
were no DB `services:` — so the cost-cap, FK-cascade, jsonb round-trip and
real AI-apply integration tests never gated a PR, and regressions in those
high-severity paths stayed green (red-team finding #7).

Add `services: postgres (pgvector) + redis` and a `pnpm --filter server
test:int` step. The pgvector image is required because migrations create
vector columns and global-setup runs `CREATE EXTENSION vector`. Service
credentials/db match the defaults in apps/server/test/integration (docmost /
docmost_dev_pw, maintenance db `docmost`, redis 6379), so no TEST_*_URL
overrides are needed; global-setup drops/recreates the isolated docmost_test
DB and migrates it.

NOTE: the workflow change itself can only be validated by an actual CI run
(YAML parses locally); the int-spec suite is verified passing locally on this
branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
fdaf20ca7b fix(mcp): refuse ambiguous patch_node/delete_node on duplicated ids (#159)
Docmost duplicates block ids on copy/paste, and copyPageContent writes the
source document verbatim with the same ids. `patchNode`/`deleteNode` address a
block by `attrs.id` via replaceNodeById/deleteNodeById, which act on EVERY node
sharing the id — so a single patch_node/delete_node could silently
replace/remove multiple unrelated blocks with no signal to the model
(red-team finding #6).

Guard both write paths: when more than one node matches the id, skip the write
entirely (the transform returns null -> no mutation) and throw a clear
"ambiguous id — N nodes share it" error so the model re-targets with a more
specific anchor. Only an unambiguous single match is written; the 0-match and
1-match behavior is unchanged.

The duplicate-count basis is covered by node-ops.test.mjs (replaceNodeById /
deleteNodeById report count===2 for a 2-duplicate doc). The end-to-end guard
is not unit-tested because patchNode/deleteNode require a live collab provider
and the test suite has no provider mock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
47a2ae420b feat(footnotes): multi-backlinks — definition returns to ALL its references (#168)
After #166 a repeated `[^a]` is one footnote (reuse): one number, one
definition, N forward links. But the definition's ↩ only returned to the
FIRST reference. Now a definition with N references shows ↩ a b c …, each
backlink scrolling to its own occurrence (Pandoc/Wikipedia convention); a
single-reference footnote keeps the plain ↩ unchanged.

- editor-ext: `computeFootnoteRefCounts(doc)` (id -> occurrence count) cached
  alongside the number map in the numbering plugin state; `getFootnoteRefCount`
  getter (O(1), no per-render doc walk). `scrollToReference(id, index?)` picks
  the index-th `sup[data-footnote-ref][data-id]` occurrence (document order),
  falling back to the first.
- client: FootnoteDefinitionView renders one lettered link (a, b, c, … aa …)
  per occurrence when refCount > 1; the chrome stays after the contentDOM so
  the #146 caret invariant holds. i18n keys (ru) added.

Tests: computeFootnoteRefCounts + getFootnoteRefCount (reuse counts, unknown
id => 0); structure test gains 3 cases (N lettered links render, click jumps
to the n-th occorrence, single ref => one ↩). NOTE: the visual layout of the
backlink row needs a real browser to verify (jsdom can't); the structural and
behavioral contract is covered headless.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
1cfad1f6fb fix(db): jsonb double-encoding follow-ups from PR #172 review (#173)
PR #172 fixed the jsonb double-encoding for `tool_allowlist` but the same
class of bug, and the same re-derived workaround, remained elsewhere.

1. model_config (agent roles): jsonbObject still used the buggy `::jsonb`
   bind, so `ai_agent_roles.model_config` round-tripped as a jsonb STRING
   SCALAR. The read-path `typeof === 'object'` check then failed and the
   model override was SILENTLY dropped (role fell back to the default model).
   Fixed to `::text::jsonb` and added `parseModelConfig` + `normalizeRow` so
   every read self-heals already-corrupted rows (no migration).

2. Centralized the write workaround as `jsonbBind()` in database/utils.ts —
   one implementation with one explanation of the quirk — replacing the
   per-repo `jsonbArray` (mcp) and `jsonbObject` (roles).

3. Integration coverage (the fix is a DB round-trip a unit test cannot see;
   the read-side parser MASKS a write regression): new
   ai-mcp-server-repo.int-spec asserts `jsonb_typeof(tool_allowlist)='array'`
   after insert + heals a seeded string-scalar row; ai-agent-roles-repo
   int-spec gains the same for `model_config` (`'object'` + heal).

4. Updated the stale `ai-mcp-servers.types.ts` comment (the driver returns a
   JSON string for legacy rows; the repo normalizes every read).

5. Fail-open logging: a corrupt tool_allowlist degrades to "no restriction"
   (agent gets ALL tools) — normalizeRow now warns (server id only, never
   contents) so the silent widening leaves a trace.

6. Simplified parseToolAllowlist (normalize the string once, then a single
   array-of-strings check) — identical behaviour, all 12 cases still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
a766672574 fix(mcp): replaceImage no longer yanks the cursor (#164)
`mutateLiveContentUnlocked` — the write path used by `replaceImage` — still
did the pre-#152 destructive write (delete the whole fragment + applyUpdate a
fresh Y.Doc), discarding every Yjs node id. y-prosemirror anchors the editor
selection to those ids, so an open editor's cursor snapped to the document
end on every image swap, exactly the #152 jump that the main write path no
longer causes.

Switch it to the same `applyDocToFragment(ydoc, newDoc)` structural diff
(updateYFragment) as the main path, so unchanged nodes keep their ids and the
live cursor stays put. It runs its own atomic transact, so the old explicit
transact/delete is gone; the now-unused docmostExtensions import is dropped.

Regression tests (cursor-stability suite): a sibling paragraph's
RelativePosition survives a top-level image src/attachmentId swap, and an
image nested in a callout, matching the shapes replaceImage produces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
5e8cb628f0 feat(ai-chat): compact reasoning rendering — collapse blank lines (#181)
The "Thinking" (reasoning) block rendered with large vertical gaps: models
emit reasoning with a blank line (\n\n) between every list item and
paragraph, which `marked` turns into loose lists (each <li> wrapped in a
<p>) and separate <p> paragraphs, each carrying a margin.

- Add `collapseBlankLines(text)`: collapse 2+ newlines to one, EXCEPT inside
  fenced code blocks (``` / ~~~) where blank lines are significant. Applied
  in reasoning-block.tsx before renderChatMarkdown, so loose lists become
  tight (no <li><p>) and paragraphs join; `breaks: true` keeps single \n as
  <br>, preserving line breaks. Reasoning-only — the normal answer is
  untouched.
- Drop `white-space: pre-wrap` from `.reasoningText`: on the rendered
  markdown <div> it turned the newlines between block tags into visible
  blank lines on top of the margins. The plain-text fallback <Text> that
  needs pre-wrap already sets it inline.

Tests: collapseBlankLines unit (collapse, fence preservation incl. tilde and
unclosed fences) + rendered-HTML assertions that a blank-line-separated list
becomes a tight list and still parses as a list after a paragraph.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227
8413185a1d fix(ai-chat): tick the live token counter between agent steps (#163)
The header token badge (and the "Thinking… · N tokens" line) froze between
agent steps and jumped in chunks instead of ticking smoothly. liveTurnTokens
returned the authoritative server `usage` VERBATIM as soon as it appeared, but
the server only attaches usage at a step boundary and it is cumulative over
COMPLETED steps — so during the next (in-flight) step the figure stayed frozen
at the previous boundary and the running text estimate was ignored.

Combine both sources per component via max: always compute the running estimate
(chars/≈4 over the message's reasoning/text parts, which includes the in-flight
step) and take max(authoritativeBase, estimate). Between boundaries the estimate
ticks the number up; at a boundary the authoritative figure snaps it exact; and
because the server usage is cumulative and we only ever take the max, the counter
is monotonic (never drops). Reasoning/output stay split; the #151 reasoning-only
authoritative count is preserved.

Backward compatible: in every existing test the estimate is <= the authoritative
figure, so max returns the same value. +4 tests for the in-flight-step-exceeds-
base case (output + reasoning), the authoritative-wins case, and monotonicity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude_code
8fee6a86c2 fix(ai-chat): style GFM tables in assistant chat markdown
Assistant answers containing GFM tables rendered badly in the narrow AI
side panel: `.markdown` only styled p/pre/code/ul/ol and had no table
rules, so tables used the browser default `table-layout: auto`. Combined
with the inherited `word-break: break-word`, columns collapsed to a
single glyph and headers wrapped mid-word ("Секция" -> "Секци / я").

Add table styling scoped to `.markdown`, in line with the editor's
table.css house style:
- make the table a horizontally scrollable block (display:block +
  overflow-x:auto) so wide tables scroll instead of squishing;
- give cells a 6em min-width and restore word-boundary wrapping
  (word-break:normal + overflow-wrap:break-word);
- add 1px borders, padding and a th background (light-dark for dark
  mode); zero out the default <p> margin inside cells.

CSS-only; no markdown-pipeline change (marked already emits GFM tables,
DOMPurify already allows table tags). Applies to the public share too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:53:58 +03:00
claude code agent 227
ae6faf3abc fix(ai-chat): guard step-update vs finalize race with WHERE status='streaming' (#183 review)
Review caught a real race: onStepFinish fires `updateStreaming()` fire-and-
forget (not awaited), so the FINAL step's streaming UPDATE and the terminal
`finalizeAssistant` UPDATE run as two concurrent statements on different pool
connections — commit order is not guaranteed. If the late streaming update
lands AFTER finalize, the completed row is clobbered back to status='streaming'
with no usage/finishReason, and the next startup sweep then mis-marks the
finished turn 'aborted'. Green unit/integration tests don't reproduce a
cross-connection race.

Fix: scope the per-step update with `onlyIfStreaming` → SQL `WHERE
status='streaming'`. Once finalize has set a terminal status the late update
matches zero rows and no-ops, regardless of commit order; finalize runs
unguarded so it always wins. A cheap `if (finalized) return` short-circuit
avoids most wasted queries, but the SQL guard is the authoritative fix (the
flag can be set after a query is already in flight).

Integration test: finalize to 'completed', then a late onlyIfStreaming update
is a no-op — status/content/usage preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 06:14:02 +03:00
claude code agent 227
e7b719bbb8 feat(ai-chat): persistent history as source of truth — step durability + server export (#183)
The chat lived in inconsistent paradigms (in-memory stream + client export vs.
DB-as-context), which made export flaky and lost the assistant answer if the
process died mid-turn. Make the DB the single source of truth.

A. STEP-GRANULAR DURABILITY (server)
- ai_chat_messages gains a nullable `status` column (migration; NULL = legacy =
  completed). The assistant row is now INSERTED UPFRONT as `status:'streaming'`
  and UPDATEd on every onStepFinish with all finished steps (text + tool calls +
  tool RESULTS), then finalized once to completed/error/aborted on the terminal
  callback. So a process death mid-turn keeps every finished step; a startup
  sweep (OnModuleInit → sweepStreaming) flips any dangling 'streaming' row to
  'aborted'. The write path no longer depends on a live socket.
- Pure exported `flushAssistant(steps, inProgressText, status, extra?)` builds
  the persist payload (metadata.parts byte-identical to the old builder), so a
  future background worker can call the same path. AiChatMessageRepo gains
  `update`, `sweepStreaming`, and `findAllByChat`.
- consumeStream drain, external-MCP client close-once, SSE heartbeat preserved.

B. SERVER-SIDE EXPORT
- New pure `chat-markdown.util.ts` renders Markdown from DB rows ONLY (server
  port of the client builder). Because A persists the in-progress row, the
  export now includes an interrupted turn up to its last finished step (flagged
  "still generating"). `POST /ai-chat/export` (owner-gated via assertOwnedChat,
  workspace-scoped) returns it; `lang` accepts a full client locale tag
  ('en-US'/'ru-RU') and is normalized server-side (normalizeLang) — a strict
  @IsIn(['en','ru']) DTO rejected the real client's i18n.language with a 400,
  caught in real-browser testing.
- Client: handleCopy calls the endpoint; `canExport = !!activeChatId`. The whole
  liveThreadRef/liveStateRef/onLiveContentChange/hasLiveContent hybrid (and the
  client chat-markdown util + test) is removed — the server is now authoritative.

Tests: flushAssistant unit (status shapes + parts parity), chat-markdown.util
unit (incl. legacy NULL-status + interrupted note + ru + normalizeLang locale
tags), controller export wiring + owner-gate, integration update/sweepStreaming.
Verified: server build + 318 ai-chat unit + 3 integration; client tsc + 157
ai-chat unit; and END-TO-END in a real browser — a chat turn persists mid-stream
and the Copy button exports the DB-sourced markdown (showing the in-progress
row), HTTP 200 after the locale fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 06:05:26 +03:00
178 changed files with 16584 additions and 2604 deletions

View File

@@ -15,6 +15,38 @@ permissions:
jobs:
test:
runs-on: ubuntu-latest
# Real Postgres + Redis so the server integration suite (`*.int-spec.ts`,
# behind `pnpm --filter server test:int`) runs in CI (red-team finding #7).
# Without it, cost-cap / FK-cascade / jsonb-round-trip / real-apply tests
# only ran locally, so regressions in those paths stayed green in CI.
# Postgres uses the pgvector image because migrations create vector columns
# and global-setup runs `CREATE EXTENSION vector`. Credentials/db match the
# defaults in apps/server/test/integration/db.ts + global-setup.ts
# (docmost / docmost_dev_pw, maintenance db `docmost`, redis on 6379), so no
# TEST_*_URL overrides are needed.
services:
postgres:
image: pgvector/pgvector:pg18
env:
POSTGRES_USER: docmost
POSTGRES_PASSWORD: docmost_dev_pw
POSTGRES_DB: docmost
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U docmost"
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -36,5 +68,12 @@ jobs:
- name: Build editor-ext
run: pnpm --filter @docmost/editor-ext build
- name: Run tests
- name: Run unit tests
run: pnpm -r test
# Integration suite against the real Postgres/Redis services above. Runs
# the FK-cascade, cost-cap, jsonb-round-trip and real-apply specs that the
# unit run (mocks only) cannot cover. global-setup drops/recreates the
# isolated `docmost_test` DB and migrates it to latest.
- name: Run server integration tests
run: pnpm --filter server test:int

View File

@@ -12,10 +12,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Custom pretty-links for shared pages (`/l/:alias`).** A page editor can give
any publicly shared page a short, memorable, workspace-scoped vanity address
backed by a new `share_aliases` table. Hitting `/l/<alias>` issues a `302`
(never `301`, since the target is retargetable) to the canonical
`/share/<key>/p/<slug>` page; an unknown, dangling, or no-longer-readable alias
serves the plain SPA index so that the existence of a name never leaks. An
alias can be moved to another page (with a confirm-reassign guard) and the
foreign key is `ON DELETE SET NULL`, so deleting the target leaves a dangling
alias any workspace member can reclaim. (#205)
- **Persistent AI-chat history as the source of truth + server-side export.**
An assistant turn is now persisted to the database step by step: the row is
inserted upfront as `streaming` and updated as each agent step finishes, then
finalized once to `completed`/`error`/`aborted`. A process that dies mid-turn
keeps every finished step, and a startup sweep flips any dangling `streaming`
row (untouched for 10 minutes) to `aborted`. Chat "Copy" now exports
server-side from these rows (`POST /ai-chat/export`) rather than from live
client state, so the export is identical whether a chat is freshly streaming,
just switched to, or reloaded — and is available from the first turn of a new
chat. (#183, #174)
- **AI-agent attribution for MCP writes.** Comments (and pages) created through
the MCP endpoint by a dedicated agent account are now badged as "AI", with
unspoofable provenance derived from a per-user `is_agent` flag (not from the
request body). **Operator setup:** use a *dedicated* service account for the
request body). **Operator setup:** use a _dedicated_ service account for the
MCP fallback and set the flag with SQL —
`UPDATE users SET is_agent = true WHERE email = '<mcp-account>'`. Never flag a
human or shared account, or its normal edits get mis-attributed as AI. See the
@@ -32,6 +53,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
OpenRouter, etc.; `openai` uses the official provider (real-OpenAI
reasoning-model request shaping). Chosen explicitly rather than inferred from
the base URL, since a custom URL can front real OpenAI too. (#175, #177)
- **Per-MCP-server instructions in the agent prompt.** Each external MCP server
now has an admin-authored `instructions` field ("how/when to use this server's
tools") that is injected into the agent's system prompt next to that server's
tool descriptions. Trusted text, rendered inside the prompt safety sandwich;
shown only for a server that actually connected and contributed ≥1 callable
tool. (#180)
- **Footnote multi-backlinks.** A footnote referenced more than once now shows a
back-link per reference (↩ a b c …), each scrolling to its own occurrence, like
Pandoc/Wikipedia; a single-reference footnote keeps the plain ↩. (#168)
### Changed
@@ -67,6 +97,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
are nudged after a paste to refresh stale hit-testing geometry. The caret
symptom is macOS-specific and was confirmed manually on macOS; the automated
guard pins the DOM-order invariant, not the caret behavior itself. (#146, #147)
- **AI chat: the live token counter now ticks between agent steps.** During a
multi-step turn the header token badge (and the "Thinking… · N tokens" line)
no longer froze on the previous step's authoritative usage; the current step's
estimate is combined per-component with `max`, so the count rises smoothly and
never jumps backwards. (#163)
## [0.93.0] - 2026-06-21
@@ -150,8 +185,7 @@ embeds — plus a large batch of security hardening and test coverage.
- 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.
- Import: surface the real error cause from `/pages/import` instead of a generic 400.
### Security

View File

@@ -258,6 +258,7 @@
"Copy to space": "Copy to space",
"Copy chat": "Copy chat",
"Copied": "Copied",
"Failed to export chat": "Failed to export chat",
"Duplicate": "Duplicate",
"Select a user": "Select a user",
"Select a group": "Select a group",
@@ -710,6 +711,7 @@
"Authorization header": "Authorization header",
"Tool allowlist": "Tool allowlist",
"Optional. Leave empty to allow all tools the server exposes.": "Optional. Leave empty to allow all tools the server exposes.",
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".",
"Test": "Test",
"Available tools": "Available tools",
"No tools available": "No tools available",
@@ -1077,6 +1079,8 @@
"Undo": "Undo",
"Redo": "Redo",
"Backlinks": "Backlinks",
"Back to references": "Back to references",
"Back to reference {{label}}": "Back to reference {{label}}",
"Last updated by": "Last updated by",
"Last updated": "Last updated",
"Stats": "Stats",
@@ -1311,5 +1315,15 @@
"Protocol": "Protocol",
"How chat requests are sent and how reasoning is surfaced": "How chat requests are sent and how reasoning is surfaced",
"OpenAI-compatible (surfaces reasoning)": "OpenAI-compatible (surfaces reasoning)",
"OpenAI (official)": "OpenAI (official)"
"OpenAI (official)": "OpenAI (official)",
"Custom address": "Custom address",
"A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.",
"Use 2-60 lowercase letters, digits and hyphens": "Use 2-60 lowercase letters, digits and hyphens",
"This address is already in use": "This address is already in use",
"Move custom address?": "Move custom address?",
"Move here": "Move here",
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?",
"The address \"{{alias}}\" is already in use. Move it to this page?": "The address \"{{alias}}\" is already in use. Move it to this page?",
"Failed to set custom address": "Failed to set custom address",
"Failed to remove custom address": "Failed to remove custom address"
}

View File

@@ -257,6 +257,7 @@
"Copy": "Копировать",
"Copy to space": "Копировать в пространство",
"Copied": "Скопировано",
"Failed to export chat": "Не удалось экспортировать чат",
"Duplicate": "Дублировать",
"Select a user": "Выберите пользователя",
"Select a group": "Выберите группу",
@@ -405,6 +406,8 @@
"Footnote {{number}}": "Сноска {{number}}",
"Go to footnote": "Перейти к сноске",
"Back to reference": "Вернуться к ссылке",
"Back to references": "Вернуться к ссылкам",
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
"Empty footnote": "Пустая сноска",
"Math inline": "Строчная формула",
"Insert inline math equation.": "Вставить математическое выражение в строку.",
@@ -749,6 +752,8 @@
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
"View the <anchor>API documentation</anchor> for usage details.": "Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
"View the <anchor>MCP documentation</anchor>.": "Смотрите <anchor>документацию по MCP</anchor>.",
"Instructions": "Инструкции",
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Необязательное указание агенту, как и когда использовать инструменты этого сервера. Добавляется в системный промпт. Инструменты сервера именуются с префиксом «<имя сервера>_*».",
"Sources": "Источники",
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
"No answer available": "Ответ недоступен",
@@ -1164,5 +1169,15 @@
"Protocol": "Протокол",
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
"OpenAI (official)": "OpenAI (официальный)"
"OpenAI (official)": "OpenAI (официальный)",
"Custom address": "Пользовательский адрес",
"A short, memorable link you can point at any shared page.": "Короткая запоминающаяся ссылка, которую можно направить на любую опубликованную страницу.",
"Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов",
"This address is already in use": "Этот адрес уже занят",
"Move custom address?": "Переместить пользовательский адрес?",
"Move here": "Переместить сюда",
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
"The address \"{{alias}}\" is already in use. Move it to this page?": "Адрес «{{alias}}» уже используется. Переместить его на эту страницу?",
"Failed to set custom address": "Не удалось задать пользовательский адрес",
"Failed to remove custom address": "Не удалось удалить пользовательский адрес"
}

View File

@@ -6,7 +6,6 @@ import {
useRef,
useState,
} from "react";
import { type UIMessage } from "@ai-sdk/react";
import { Group, Loader, Tooltip } from "@mantine/core";
import {
IconArrowsDiagonal,
@@ -40,7 +39,7 @@ import {
} from "@/features/ai-chat/queries/ai-chat-query.ts";
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
import { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts";
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
import {
shouldCollapseOnOutsidePointer,
@@ -121,7 +120,7 @@ function clampGeom(g: {
* ported from the GitmostAgent.jsx design.
*/
export default function AiChatWindow() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const clipboard = useClipboard({ timeout: 500 });
const queryClient = useQueryClient();
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
@@ -162,30 +161,11 @@ export default function AiChatWindow() {
const { data: messageRows, isLoading: messagesLoading } =
useAiChatMessagesQuery(activeChatId ?? undefined);
// Live snapshot of the active thread's useChat state, kept up to date by
// ChatThread. Lets the export include the in-progress (not-yet-persisted)
// streaming turn. A ref avoids re-rendering this window on every token.
const liveThreadRef = useRef<{
messages: UIMessage[];
isStreaming: boolean;
banner: string | null;
}>({
messages: [],
isStreaming: false,
banner: null,
});
// Live turn-token total (reasoning + output) for the in-flight turn, pushed up
// (THROTTLED to ~8 Hz inside ChatThread) so the header badge ticks mid-stream.
// `null` means no turn is in flight -> the badge falls back to the persisted
// context size below.
const [liveTurnTokens, setLiveTurnTokens] = useState<number | null>(null);
// Whether the on-screen thread currently holds at least one message. Reported
// reactively by ChatThread (the live snapshot lives in a non-reactive ref). This
// lets the "Copy chat" button stay available for a brand-new, not-yet-persisted
// chat whose first turn is in flight or was interrupted — that case has no
// persisted rows yet, so a persisted-rows-only gate would hide the button (#174).
const [hasLiveContent, setHasLiveContent] = useState(false);
// The page the user is currently viewing. AiChatWindow lives in a pathless
// parent layout route, so useParams() can't see :pageSlug. Match the full
@@ -214,6 +194,7 @@ export default function AiChatWindow() {
threadKey,
waitingForHistory,
onTurnFinished,
onServerChatId,
cancelPendingAdoption,
} = useChatSession({
activeChatId,
@@ -254,20 +235,19 @@ export default function AiChatWindow() {
[cancelPendingAdoption, setActiveChatId, setDraft, setSelectedRoleId],
);
// The active chat object (for its title) and an export gate: only enable the
// export button when an existing chat with loaded persisted rows is active.
// The active chat object (for its title) and an export gate. The export is now
// SERVER-sourced (the DB is the single source of truth — #183): the assistant
// row is persisted upfront + per step, so even a brand-new chat whose first
// turn is streaming/interrupted has a server row to render. Enable the button
// whenever a persisted chat is active (`activeChatId` is set). For a BRAND-NEW
// chat that id is adopted EARLY — at the stream's `start` chunk via
// onServerChatId (#174) — so the Copy button is available during the first
// turn's stream, not only after it terminates.
const activeChat = useMemo(
() => chats?.items?.find((c) => c.id === activeChatId) ?? null,
[chats, activeChatId],
);
// Export is available when there is anything to export: either persisted rows
// for the active chat, OR a live on-screen thread with at least one message.
// The live arm covers a brand-new chat whose first turn is streaming or was
// interrupted before the server persisted any row (#174); the persisted arm is
// the steady-state path for an already-saved chat (#160).
const canExport =
hasLiveContent ||
(!!activeChatId && !!messageRows && messageRows.length > 0);
const canExport = !!activeChatId;
// 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
@@ -284,53 +264,21 @@ export default function AiChatWindow() {
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.
const handleCopy = useCallback(() => {
// Export gate. There must be SOMETHING to export — either a live on-screen
// message or a persisted row. A brand-new chat whose first turn is streaming
// or was interrupted has live messages but no persisted rows yet; it still
// exports the on-screen thread WYSIWYG (#174). Only a truly empty chat (no
// live messages and no rows) is non-exportable (the button is hidden too —
// see `canExport`).
const live = liveThreadRef.current;
const hasRows = !!messageRows && messageRows.length > 0;
if (live.messages.length === 0 && !hasRows) return;
// WYSIWYG export: the live on-screen messages ARE the document (so a partial
// reply from an interrupted turn — which never reached the persisted rows —
// is exported just as it appears). The persisted rows enrich each live
// message (token usage / error / timestamp) by id and serve as the fallback
// when the live mirror is empty. The on-screen banner is appended too. See
// issues #160 and #174. `chatId` may be null for a not-yet-saved chat — use a
// placeholder so the header line still renders.
const markdown = buildChatMarkdown({
title: activeChat?.title ?? null,
chatId: activeChatId ?? "unsaved",
live: live.messages.map((m) => ({
id: m.id,
role: m.role,
parts: (m.parts ?? []) as { type: string; text?: string }[],
metadata: m.metadata as
| {
usage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
};
error?: string;
}
| undefined,
})),
rows: messageRows,
isStreaming: live.isStreaming,
banner: live.banner,
t,
});
clipboard.copy(markdown);
notifications.show({ message: t("Copied") });
}, [activeChatId, messageRows, activeChat, clipboard, t]);
// Fetch the server-rendered Markdown export and copy it to the clipboard. The
// server is the single source of truth (#183): it renders the transcript from
// the persisted rows — including an interrupted turn's in-progress row — so the
// export is identical whether the chat is freshly streaming, just switched to,
// or reloaded. The `lang` of the active i18n drives the few localized labels.
const handleCopy = useCallback(async () => {
if (!activeChatId) return;
try {
const markdown = await exportAiChat(activeChatId, i18n.language);
clipboard.copy(markdown);
notifications.show({ message: t("Copied") });
} catch {
notifications.show({ message: t("Failed to export chat"), color: "red" });
}
}, [activeChatId, clipboard, t, i18n.language]);
// Current context size for the active chat: how much the conversation now
// occupies in the model's context window — NOT the cumulative tokens spent.
@@ -685,9 +633,8 @@ export default function AiChatWindow() {
onRolePicked={(role) => setSelectedRoleId(role.id)}
assistantName={currentRole?.name}
onTurnFinished={onTurnFinished}
liveStateRef={liveThreadRef}
onServerChatId={onServerChatId}
onLiveTurnTokens={setLiveTurnTokens}
onLiveContentChange={setHasLiveContent}
/>
)}
</div>

View File

@@ -55,6 +55,45 @@
padding-inline-start: 1.4em;
}
/* GFM tables in assistant markdown. The chat lives in a NARROW side panel, so a
wide LLM table must scroll horizontally instead of collapsing its columns:
`.markdown` sets `word-break: break-word`, which (with the default table
layout) shrinks columns to a single glyph and wraps headers mid-word
("Секция" -> "Секци / я"). Make the table a horizontally scrollable block,
give cells a readable minimum width, and restore word-boundary wrapping. */
.markdown table {
display: block;
/* lets the table scroll horizontally on its own */
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
margin-block-end: 0.5em;
}
.markdown th,
.markdown td {
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
padding: 3px 8px;
/* readable floor; the block scrolls when the row exceeds the panel */
min-width: 6em;
text-align: left;
vertical-align: top;
/* cancel the inherited break-word so words don't split mid-glyph */
word-break: normal;
/* still wrap genuinely long words / URLs at the cell edge */
overflow-wrap: break-word;
}
.markdown th {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
font-weight: 600;
}
/* GFM wraps cell text in <p>; drop its default block margin inside cells. */
.markdown table p {
margin: 0;
}
/* Animated three-dot "typing" indicator shown while the agent is thinking but
has not yet produced any visible text/tool parts. */
.typingDots {
@@ -122,7 +161,11 @@
margin-top: 4px;
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
white-space: pre-wrap;
/* NOTE: `white-space: pre-wrap` is intentionally NOT set here. On the
rendered markdown <div> it would turn the newlines between block tags
(</li>\n<li>, </p>\n<ol>) into visible blank lines/indents on top of the
margins. The plain-text fallback <Text> that needs pre-wrap sets it
inline itself (see reasoning-block.tsx). */
}
.reasoningText p {

View File

@@ -1,11 +1,4 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MutableRefObject,
} from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { generateId } from "ai";
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
import { IconClockHour4, IconX } from "@tabler/icons-react";
@@ -68,30 +61,18 @@ interface ChatThreadProps {
* authoritative id the server streamed on the assistant message metadata, or
* undefined on a failed turn — see adopt-chat-id.ts for the full #137 design. */
onTurnFinished: (serverChatId?: string) => void;
/** Parent-owned ref that this thread keeps updated with its live useChat
* snapshot (full message list + streaming flag), so the header's
* "Copy chat" export can include the in-progress, not-yet-persisted
* assistant message. A ref (not state) avoids re-rendering the parent on
* every streamed delta. */
liveStateRef?: MutableRefObject<{
messages: UIMessage[];
isStreaming: boolean;
banner: string | null;
}>;
/** Called EARLY (at the stream's `start` chunk) with the authoritative server
* chat id streamed on the assistant message metadata, so a brand-new chat
* adopts its real id WHILE the first turn is still streaming (#174 — makes the
* Copy/export button available mid-stream). Distinct from onTurnFinished,
* which fires only at the terminal outcome. */
onServerChatId?: (serverChatId?: string) => void;
/** Reports the live turn-token total (reasoning + output) for the in-flight
* turn so the parent can show a header badge that ticks mid-stream. THROTTLED
* here (~8 Hz) so the parent re-renders a handful of times a second, not on
* every streamed delta. Called with `null` when no turn is in flight (the
* parent then reverts the badge to the persisted context size). */
onLiveTurnTokens?: (tokens: number | null) => void;
/** Reports whether the live thread currently holds at least one message, so the
* parent can gate the "Copy chat" button on the on-screen thread rather than on
* the persisted rows alone. This stays truthy for a brand-new, not-yet-saved
* chat the moment its first user message appears — so an interrupted very first
* turn (no persisted rows yet) is still exportable (#174). Called with `false`
* on unmount so a thread torn down by `key` on chat switch can't leave the
* button enabled for the next, possibly empty, chat. */
onLiveContentChange?: (hasContent: boolean) => void;
}
/**
@@ -135,9 +116,8 @@ export default function ChatThread({
onRolePicked,
assistantName,
onTurnFinished,
liveStateRef,
onServerChatId,
onLiveTurnTokens,
onLiveContentChange,
}: ChatThreadProps) {
const { t } = useTranslation();
@@ -306,6 +286,26 @@ export default function ChatThread({
// Keep the flush helper pointed at the latest sendMessage instance.
sendMessageRef.current = sendMessage;
// EARLY chat-id adoption (#174): the server streams the authoritative chat id
// on the assistant message metadata at the `start` chunk (message.metadata.
// chatId — see adopt-chat-id.ts / chatStreamMetadata). Forward it to the parent
// AS SOON AS it appears (mid-stream), so a brand-new chat adopts its real id
// WHILE the first turn is still streaming and activeChatId-gated affordances
// (the Copy/export button) light up immediately, instead of only at onFinish.
// Keyed by the last-seen id so we forward each distinct id exactly once. The
// parent's onServerChatId is idempotent and a no-op once the chat has an id.
const lastForwardedChatIdRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (!onServerChatId) return;
const tail = messages[messages.length - 1];
if (tail?.role !== "assistant") return;
const serverChatId = extractServerChatId(tail);
if (!serverChatId || serverChatId === lastForwardedChatIdRef.current)
return;
lastForwardedChatIdRef.current = serverChatId;
onServerChatId(serverChatId);
}, [messages, onServerChatId]);
// Live "turn was interrupted" marker for the CURRENT session. The red error
// banner (driven by `error`) covers the error case; this covers an aborted
// turn, distinguishing a manual Stop (`isAbort`) from a dropped connection
@@ -328,44 +328,6 @@ export default function ChatThread({
// the SAME on-screen banner text can be mirrored into the export (issue #160).
const errorView = error ? describeChatError(error.message ?? "", t) : null;
// The exact banner the user sees under the message list, flattened to a single
// string for the "Copy chat" export so the artifact records the interruption
// WYSIWYG. Mirrors the JSX precedence below: error first, else the stop notice.
const banner = errorView
? errorView.detail
? `${errorView.title}${errorView.detail}`
: errorView.title
: stopNotice === "manual"
? t("Response stopped.")
: stopNotice === "disconnect"
? t("Connection lost — the answer was interrupted.")
: null;
// Mirror the live useChat snapshot into the parent-owned ref so the export
// (handled in AiChatWindow) can include the in-progress streaming turn AND the
// on-screen banner. The cleanup clears the ref on unmount so a thread torn down
// by `key` on chat switch can't leak its (possibly still-streaming) tail into
// the next chat's export before the new thread's effect repopulates the ref.
useEffect(() => {
if (!liveStateRef) return;
liveStateRef.current = { messages, isStreaming, banner };
return () => {
liveStateRef.current = { messages: [], isStreaming: false, banner: null };
};
}, [liveStateRef, messages, isStreaming, banner]);
// Reactively report "the live thread has content" to the parent. `liveStateRef`
// above is a ref (deliberately non-reactive so streaming deltas don't re-render
// the parent), so the export button needs a SEPARATE reactive signal to flip on
// for a not-yet-persisted chat. Keyed on the boolean only — identical values are
// a no-op setState in the parent, so this does not add per-delta re-renders.
const hasLiveContent = messages.length > 0;
useEffect(() => {
if (!onLiveContentChange) return;
onLiveContentChange(hasLiveContent);
return () => onLiveContentChange(false);
}, [onLiveContentChange, hasLiveContent]);
// Report the live turn-token total to the parent header badge, THROTTLED to
// ~8 Hz so the parent re-renders a few times a second instead of on every
// streamed delta. The tail assistant message's reasoning+output (estimate while

View File

@@ -3,6 +3,7 @@ import { Box, Collapse, Group, Text, UnstyledButton } from "@mantine/core";
import { IconChevronDown } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
@@ -33,7 +34,12 @@ export default function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
// Authoritative count wins; otherwise estimate live from the streamed text.
const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
const trimmed = text.trim();
const html = trimmed ? renderChatMarkdown(trimmed, {}) : "";
// Collapse the blank-line gaps the model emits between every list item /
// paragraph so the reasoning renders compactly (tight lists, joined
// paragraphs) — see collapseBlankLines. ONLY here, not in the normal answer.
const html = trimmed
? renderChatMarkdown(collapseBlankLines(trimmed), {})
: "";
return (
<Box className={classes.reasoningBlock} mb={6}>

View File

@@ -64,7 +64,10 @@ describe("useChatSession", () => {
result.current.onTurnFinished(undefined);
expect(setActiveChatId).not.toHaveBeenCalled();
// The refetch lands with the new row => adopt it.
rerender({ activeChatId: null, chats: { items: [{ id: "x" }, { id: "new" }] } });
rerender({
activeChatId: null,
chats: { items: [{ id: "x" }, { id: "new" }] },
});
expect(setActiveChatId).toHaveBeenCalledWith("new");
});
@@ -88,7 +91,10 @@ describe("useChatSession", () => {
});
result.current.onTurnFinished(undefined);
// a was deleted, new was added — same length, but membership changed.
rerender({ activeChatId: null, chats: { items: [{ id: "b" }, { id: "new" }] } });
rerender({
activeChatId: null,
chats: { items: [{ id: "b" }, { id: "new" }] },
});
expect(setActiveChatId).toHaveBeenCalledWith("new");
});
@@ -171,6 +177,40 @@ describe("useChatSession", () => {
expect(setActiveChatId).not.toHaveBeenCalledWith("late");
});
it("#174 early adopt: onServerChatId adopts the streamed id mid-stream (Copy button available during the first turn)", () => {
// Brand-new chat: no id yet. The server streams the real chat id "A" on the
// `start` chunk WHILE the first turn is still streaming (before onTurnFinished
// fires at the terminal outcome). The hook must adopt it immediately so the
// window's activeChatId-gated Copy/export button lights up during the stream.
const { result, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [] },
});
result.current.onServerChatId("A");
expect(setActiveChatId).toHaveBeenCalledWith("A");
});
it("#174 early adopt is in-place: threadKey stays stable (live stream not torn down)", () => {
const chats = { items: [] };
const { result, rerender } = setup({ activeChatId: null, chats });
const keyBefore = result.current.threadKey;
result.current.onServerChatId("A");
// Parent reflects the adopted id back in; the SAME mount key is kept so the
// in-flight useChat store (the streaming turn) is preserved.
rerender({ activeChatId: "A", chats });
expect(result.current.threadKey).toBe(keyBefore);
});
it("#174 early adopt: no-op for an existing chat and for a missing id", () => {
const { result, setActiveChatId } = setup({
activeChatId: "chat-1",
chats: { items: [{ id: "chat-1" }] },
});
result.current.onServerChatId("chat-1"); // already has an id
result.current.onServerChatId(undefined); // no streamed id
expect(setActiveChatId).not.toHaveBeenCalled();
});
it("in-place adopt keeps threadKey stable; an external switch remounts", () => {
const chats = { items: [{ id: "B" }] };
const { result, rerender } = setup({ activeChatId: null, chats });

View File

@@ -34,6 +34,13 @@ export interface UseChatSessionResult {
/** Call when a turn finishes; `serverChatId` is the authoritative streamed id
* (undefined on a failed turn). Handles new-chat id adoption + invalidations. */
onTurnFinished: (serverChatId?: string) => void;
/** Call EARLY (at the stream's `start` chunk) with the authoritative streamed
* chat id so a brand-new chat adopts its real id WHILE its first turn is still
* streaming — making `activeChatId`-gated affordances (e.g. the Copy/export
* button, #174) available immediately. In-place adoption only (same mount key,
* no list/messages invalidation — that is left to onTurnFinished at the end).
* Idempotent and a no-op once the chat already has an id. */
onServerChatId: (serverChatId?: string) => void;
/** Disarm any pending error-path new-chat fallback. The window calls this from
* startNewChat/selectChat so a late refetch can't yank the user back into a
* just-failed chat after they explicitly moved on. */
@@ -85,13 +92,10 @@ export function useChatSession(
// `newThread`/`switchThread` to (re)mount, `adoptThread` for in-place adoption.
// Initial: a non-null activeChatId switches to it; a null one gets a fresh
// session key with no chat id yet.
const [thread, dispatch] = useReducer(
threadSessionReducer,
undefined,
() =>
activeChatId === null
? newThread(`new-${generateId()}`)
: switchThread(activeChatId),
const [thread, dispatch] = useReducer(threadSessionReducer, undefined, () =>
activeChatId === null
? newThread(`new-${generateId()}`)
: switchThread(activeChatId),
);
// Error-path fallback for new-chat id adoption. When a brand-new chat's first
@@ -150,6 +154,31 @@ export function useChatSession(
[chats, setActiveChatId, onInvalidateChatList, onInvalidateChatMessages],
);
// EARLY adoption (#174): adopt the authoritative streamed chat id the moment
// the server emits it on the `start` chunk, so a brand-new chat gets its real
// `activeChatId` WHILE its first turn streams — not only at terminal
// onTurnFinished. This makes the activeChatId-gated Copy/export button
// available during the first turn. Pure in-place adoption (same mount key, like
// the primary path) with NO invalidation: the list/messages refresh stays on
// onTurnFinished at the end of the turn. Reads the live id from the ref so a
// repeat call after adoption is a no-op (resolveAdoptedChatId only fires for a
// still-new chat).
const onServerChatId = useCallback(
(serverChatId?: string) => {
const adopted = resolveAdoptedChatId(
activeChatIdRef.current,
serverChatId,
);
if (!adopted) return;
activeChatIdRef.current = adopted;
setActiveChatId(adopted);
dispatch({ type: "adopt", chatId: adopted });
// Early adoption beat the error-path fallback to it — disarm.
pendingNewChatRef.current = null;
},
[setActiveChatId],
);
// FALLBACK resolver. Armed only by onTurnFinished when a brand-new chat's first
// turn errored before the `start` chunk (no authoritative id streamed). Once
// the per-user list refetch lands with the just-created row, adopt the SINGLE
@@ -233,6 +262,7 @@ export function useChatSession(
threadKey: thread.key,
waitingForHistory,
onTurnFinished,
onServerChatId,
cancelPendingAdoption,
};
}

View File

@@ -50,6 +50,24 @@ export async function deleteAiChat(chatId: string): Promise<void> {
await api.post("/ai-chat/delete", { chatId });
}
/**
* Export a chat to Markdown (#183). The server renders the transcript from the
* persisted rows (the DB is the single source of truth — including an
* interrupted turn's in-progress row, persisted upfront + per step), so the
* client just copies the returned string. `lang` localizes the few fixed
* role/tool labels; defaults to English server-side when omitted.
*/
export async function exportAiChat(
chatId: string,
lang?: string,
): Promise<string> {
const req = await api.post<{ markdown: string }>("/ai-chat/export", {
chatId,
lang,
});
return req.data.markdown;
}
/**
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
* member (for the chat-creation picker); create/update/delete are admin-only
@@ -76,6 +94,8 @@ export async function updateAiRole(data: IAiRoleUpdate): Promise<IAiRole> {
/** Soft-delete a role (admin). */
export async function deleteAiRole(id: string): Promise<{ success: true }> {
const req = await api.post<{ success: true }>("/ai-chat/roles/delete", { id });
const req = await api.post<{ success: true }>("/ai-chat/roles/delete", {
id,
});
return req.data;
}

View File

@@ -1,747 +0,0 @@
import { describe, it, expect } from "vitest";
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
/**
* Tests for the client-only Markdown export builder. The output embeds a live
* `new Date().toISOString()` export timestamp; we never assert that value, only
* the deterministic structure (headings, numbering, fenced blocks, totals).
*
* A pass-through translator keeps role/tool labels predictable so the
* structural assertions are stable without an i18n runtime.
*/
const t = (key: string, values?: Record<string, unknown>): string => {
if (values && typeof values.name === "string") {
return key.replace("{{name}}", values.name);
}
return key;
};
function row(partial: Partial<IAiChatMessageRow>): IAiChatMessageRow {
return {
id: partial.id ?? "id",
role: partial.role ?? "user",
content: partial.content ?? null,
metadata: partial.metadata ?? null,
createdAt: partial.createdAt ?? "2026-06-21T00:00:00.000Z",
};
}
describe("buildChatMarkdown — structure", () => {
it("emits the title heading, chat id and message count", () => {
const md = buildChatMarkdown({
title: "My chat",
chatId: "chat-123",
rows: [],
t,
});
expect(md).toContain("# My chat");
expect(md).toContain("- Chat ID: `chat-123`");
expect(md).toContain("- Messages: 0");
expect(md).toContain("- Exported:"); // timestamp present, value not asserted
});
it("falls back to the translated 'Untitled chat' for empty/blank titles", () => {
expect(
buildChatMarkdown({ title: null, chatId: "c", rows: [], t }),
).toContain("# Untitled chat");
expect(
buildChatMarkdown({ title: " ", chatId: "c", rows: [], t }),
).toContain("# Untitled chat");
});
it("numbers rows sequentially with role headings", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({ role: "user", content: "hi" }),
row({ role: "assistant", content: "hello" }),
row({ role: "user", content: "again" }),
],
t,
});
expect(md).toContain("## 1. You");
expect(md).toContain("## 2. AI agent");
expect(md).toContain("## 3. You");
// Heading numbering is strictly index+1, not e.g. role-relative.
expect(md).not.toContain("## 0.");
});
it("renders the per-row text content from `content` when no metadata.parts", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [row({ role: "user", content: "plain body" })],
t,
});
expect(md).toContain("plain body");
});
});
describe("buildChatMarkdown — text parts", () => {
it("skips empty / whitespace-only text parts", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "ignored-content",
metadata: {
parts: [
{ type: "text", text: " " },
{ type: "text", text: "" },
{ type: "text", text: "kept line" },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any,
},
}),
],
t,
});
expect(md).toContain("kept line");
// Whitespace-only part contributed no block of its own.
expect(md).not.toContain(" \n\n");
// When metadata.parts exists, the plain `content` fallback is NOT used.
expect(md).not.toContain("ignored-content");
});
});
describe("buildChatMarkdown — tool parts", () => {
it("renders a tool label, name, state and fenced Input/Output blocks", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "",
metadata: {
parts: [
{
type: "tool-getPage",
state: "output-available",
input: { pageId: "p1" },
output: { id: "p1", title: "Home" },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
}),
],
t,
});
// Known tool name maps to its label key; raw name in backticks; done state.
expect(md).toContain("**Tool: Read page** (`getPage`) — done");
expect(md).toContain("Input:");
expect(md).toContain("Output:");
// Fenced JSON blocks contain the stringified payloads.
expect(md).toContain('"pageId": "p1"');
expect(md).toContain('"title": "Home"');
expect(md).toContain("```json");
});
it("renders the generic label for an unknown tool and surfaces errorText", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "",
metadata: {
parts: [
{
type: "tool-mysteryTool",
state: "output-error",
input: { a: 1 },
errorText: "boom",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
}),
],
t,
});
expect(md).toContain(
"**Tool: Ran tool mysteryTool** (`mysteryTool`) — error",
);
expect(md).toContain("**Error:** boom");
});
it("does not throw on a circular tool input (falls back to String)", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const circular: any = {};
circular.self = circular;
expect(() =>
buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "",
metadata: {
parts: [
{
type: "tool-getPage",
state: "input-available",
input: circular,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
}),
],
t,
}),
).not.toThrow();
});
});
describe("buildChatMarkdown — fence anti-breakout", () => {
it("lengthens the delimiter so embedded ``` cannot break out of the block", () => {
// Tool input whose stringified string form contains a literal ``` run.
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "",
metadata: {
parts: [
{
type: "tool-getPage",
state: "output-available",
// A bare string passes through stringify() verbatim.
input: "before ``` after",
output: "x",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
}),
],
t,
});
// The fence around the 3-backtick content must use at least 4 backticks so
// the embedded ``` run cannot terminate the block.
expect(md).toContain("````json\nbefore ``` after\n````");
// Robust anti-breakout check: the opening fence delimiter is strictly
// longer than the longest backtick run inside the wrapped content. (A naive
// `not.toContain("```json...")` is a false negative — a 4-backtick fence
// textually contains the 3-backtick substring.)
const open = md.match(/(`{3,})json\nbefore/);
expect(open).not.toBeNull();
expect(open![1].length).toBeGreaterThan(3); // > the 3-backtick run in content
});
it("uses a 5-backtick fence when the content has a 4-backtick run", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "",
metadata: {
parts: [
{
type: "tool-getPage",
state: "output-available",
input: "a ```` b",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
],
},
}),
],
t,
});
expect(md).toContain("`````json\na ```` b\n`````");
});
});
describe("buildChatMarkdown — token totals", () => {
it("prints the total-tokens line only when the summed usage is > 0", () => {
const withTokens = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "x",
metadata: { usage: { inputTokens: 10, outputTokens: 5 } },
}),
],
t,
});
expect(withTokens).toContain("- Total tokens: 15");
// Per-row usage footer too.
expect(withTokens).toContain("_Tokens — in: 10, out: 5, total: 15_");
});
it("omits the total-tokens line when the sum is 0 / usage absent", () => {
const noTokens = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({ role: "user", content: "hi" }),
row({
role: "assistant",
content: "x",
metadata: { usage: { inputTokens: 0, outputTokens: 0 } },
}),
],
t,
});
expect(noTokens).not.toContain("- Total tokens:");
});
it("uses totalTokens when present rather than summing in/out", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "x",
metadata: {
usage: { inputTokens: 3, outputTokens: 4, totalTokens: 99 },
},
}),
],
t,
});
expect(md).toContain("- Total tokens: 99");
});
it("appends the reasoning figure to the row footer when reasoningTokens > 0", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "x",
metadata: {
usage: { inputTokens: 10, outputTokens: 8, reasoningTokens: 3 },
},
}),
],
t,
});
expect(md).toContain("_Tokens — in: 10, out: 8, reasoning: 3, total: 18_");
});
it("omits the reasoning figure when reasoningTokens is 0 / absent", () => {
const zero = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "x",
metadata: {
usage: { inputTokens: 10, outputTokens: 5, reasoningTokens: 0 },
},
}),
],
t,
});
expect(zero).toContain("_Tokens — in: 10, out: 5, total: 15_");
expect(zero).not.toContain("reasoning:");
const absent = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
role: "assistant",
content: "x",
metadata: { usage: { inputTokens: 10, outputTokens: 5 } },
}),
],
t,
});
expect(absent).not.toContain("reasoning:");
});
});
// A minimal on-screen (live) message, matching the subset buildChatMarkdown reads.
function live(partial: {
id?: string;
role?: string;
parts?: { type: string; text?: string }[];
metadata?: { usage?: Record<string, number>; error?: string };
}) {
return {
id: partial.id ?? "live-id",
role: partial.role ?? "assistant",
parts: partial.parts ?? [],
metadata: partial.metadata,
};
}
describe("buildChatMarkdown — live (WYSIWYG) source", () => {
it("uses the live messages as the document (what's on screen), numbered from 1", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
// Persisted rows hold only the user turn; the assistant reply is live-only.
rows: [row({ id: "u1", role: "user", content: "persisted user" })],
live: [
live({
id: "u1",
role: "user",
parts: [{ type: "text", text: "on-screen user" }],
}),
live({
id: "a1",
role: "assistant",
parts: [{ type: "text", text: "on-screen reply" }],
}),
],
isStreaming: false,
t,
});
expect(md).toContain("## 1. You");
expect(md).toContain("## 2. AI agent");
expect(md).toContain("on-screen user");
expect(md).toContain("on-screen reply");
// Message count reflects the LIVE document, not rows + live.
expect(md).toContain("- Messages: 2");
});
it("captures a partial reply from an interrupted (non-streaming) turn — no 'generating' note", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [row({ id: "u1", role: "user", content: "q" })],
live: [
live({ id: "u1", role: "user", parts: [{ type: "text", text: "q" }] }),
live({
id: "a-live",
role: "assistant",
parts: [{ type: "text", text: "partial plan before the drop" }],
}),
],
isStreaming: false, // the stream dropped — not streaming anymore
banner: "Connection lost — the answer was interrupted.",
t,
});
// The partial assistant answer that was on screen IS in the export.
expect(md).toContain("partial plan before the drop");
// It is NOT flagged still-generating (the turn is over, just interrupted).
expect(md).not.toContain("still being generated");
// The on-screen banner is recorded at the end.
expect(md).toContain("Connection lost — the answer was interrupted.");
});
it("flags ONLY the tail assistant as still generating, and only while streaming", () => {
const streaming = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [],
live: [
live({
id: "a",
role: "assistant",
parts: [{ type: "text", text: "done earlier" }],
}),
live({
id: "u",
role: "user",
parts: [{ type: "text", text: "next q" }],
}),
live({
id: "b",
role: "assistant",
parts: [{ type: "text", text: "streaming now" }],
}),
],
isStreaming: true,
t,
});
// Exactly one "still being generated" note (the tail assistant).
expect(streaming.match(/still being generated/g)?.length).toBe(1);
const idle = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [],
live: [
live({
id: "b",
role: "assistant",
parts: [{ type: "text", text: "final" }],
}),
],
isStreaming: false,
t,
});
expect(idle).not.toContain("still being generated");
});
it("does NOT flag a completed assistant as generating when the streaming tail is a user message", () => {
// The `status === "submitted"` window: the user just sent, isStreaming is
// already true, but the new assistant turn has no message yet so the tail is
// the USER message. The previous assistant answer is complete on screen and
// must not be marked still-generating (WYSIWYG; regression for #160 review).
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [],
live: [
live({
id: "a",
role: "assistant",
parts: [{ type: "text", text: "completed answer" }],
}),
live({
id: "u",
role: "user",
parts: [{ type: "text", text: "the new question" }],
}),
],
isStreaming: true,
t,
});
expect(md).toContain("completed answer");
expect(md).not.toContain("still being generated");
});
it("emits the heading + note for a streaming tail assistant with empty parts", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [row({ id: "u1", role: "user", content: "q" })],
live: [
live({ id: "u1", role: "user", parts: [{ type: "text", text: "q" }] }),
live({ id: "a-live", role: "assistant", parts: [] }),
],
isStreaming: true,
t,
});
expect(md).toContain("## 2. AI agent");
expect(md).toContain("still being generated");
});
});
describe("buildChatMarkdown — live enrichment from persisted rows", () => {
it("pulls usage / error / timestamp from the persisted row matched by id", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
id: "a1",
role: "assistant",
content: "x",
createdAt: "2026-06-22T10:00:00.000Z",
metadata: {
usage: { inputTokens: 10, outputTokens: 5 },
error: "rate limited",
},
}),
],
live: [
// Same id as the persisted row, but no usage/error/timestamp on the live msg.
live({
id: "a1",
role: "assistant",
parts: [{ type: "text", text: "reply" }],
}),
],
isStreaming: false,
t,
});
expect(md).toContain("reply");
// Token footer + total come from the enriched row.
expect(md).toContain("_Tokens — in: 10, out: 5, total: 15_");
expect(md).toContain("- Total tokens: 15");
expect(md).toContain("**⚠️ Error:** rate limited");
// The persisted timestamp is carried into the export.
expect(md).toContain("<!-- 2026-06-22T10:00:00.000Z -->");
});
it("prefers authoritative usage already on the live message over the row's", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({
id: "a1",
role: "assistant",
content: "x",
metadata: {
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
},
}),
],
live: [
live({
id: "a1",
role: "assistant",
parts: [{ type: "text", text: "reply" }],
metadata: {
usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },
},
}),
],
isStreaming: false,
t,
});
// The live (authoritative, freshest) usage wins, not the stale row usage.
expect(md).toContain("- Total tokens: 150");
expect(md).not.toContain("- Total tokens: 2");
});
it("a current-turn live message with no matching row renders without a footer", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [row({ id: "u1", role: "user", content: "q" })],
live: [
live({ id: "u1", role: "user", parts: [{ type: "text", text: "q" }] }),
live({
id: "a-live",
role: "assistant",
parts: [{ type: "text", text: "fresh reply" }],
}),
],
isStreaming: false,
t,
});
expect(md).toContain("fresh reply");
// No persisted row for the live assistant -> no token footer, no timestamp.
expect(md).not.toContain("_Tokens —");
expect(md).not.toContain("<!-- undefined -->");
});
});
describe("buildChatMarkdown — fallback + banner", () => {
it("falls back to the persisted rows when there are no live messages", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [
row({ role: "user", content: "from rows" }),
row({
role: "assistant",
content: "answer",
metadata: { usage: { inputTokens: 4, outputTokens: 6 } },
}),
],
live: [], // empty live mirror -> fallback path
isStreaming: false,
t,
});
expect(md).toContain("## 1. You");
expect(md).toContain("## 2. AI agent");
expect(md).toContain("from rows");
expect(md).toContain("- Messages: 2");
expect(md).toContain("- Total tokens: 10");
});
it("appends the on-screen banner once, after the messages", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [row({ role: "user", content: "q" })],
live: [
live({ id: "u", role: "user", parts: [{ type: "text", text: "q" }] }),
],
isStreaming: false,
banner: "Rate limit reached — try again shortly.",
t,
});
expect(md).toContain("_⚠️ Rate limit reached — try again shortly._");
// Banner comes after the (only) message block.
expect(md.indexOf("Rate limit reached")).toBeGreaterThan(
md.indexOf("## 1."),
);
});
it("omits the banner block when there is no banner", () => {
const md = buildChatMarkdown({
title: "t",
chatId: "c",
rows: [row({ role: "user", content: "q" })],
live: [
live({ id: "u", role: "user", parts: [{ type: "text", text: "q" }] }),
],
isStreaming: false,
banner: null,
t,
});
expect(md).not.toContain("_⚠️");
});
});
// #174: a brand-new, not-yet-persisted chat whose first turn is streaming (or was
// interrupted) has live messages but NO persisted rows yet, and its chat id is not
// known (the caller passes a placeholder). The export must still capture the
// on-screen thread WYSIWYG from the live messages alone.
describe("buildChatMarkdown — first-turn export with no persisted base (#174)", () => {
it("builds the document from live messages alone when rows are empty", () => {
const md = buildChatMarkdown({
title: null,
chatId: "unsaved",
rows: [],
live: [
live({
id: "u1",
role: "user",
parts: [{ type: "text", text: "hello" }],
}),
live({
id: "a1",
role: "assistant",
parts: [{ type: "text", text: "partial reply" }],
}),
],
isStreaming: true,
t,
});
// Both on-screen messages are serialized, numbered from 1.
expect(md).toContain("## 1. You");
expect(md).toContain("hello");
expect(md).toContain("## 2. AI agent");
expect(md).toContain("partial reply");
// The streaming tail assistant is flagged as in-progress.
expect(md).toContain("still being generated");
// The placeholder chat id and the live message count are recorded.
expect(md).toContain("- Chat ID: `unsaved`");
expect(md).toContain("- Messages: 2");
// No persisted timestamp exists for a current-turn live message.
expect(md).not.toContain("<!--");
});
it("captures an interrupted first turn (no rows, not streaming) without a generating note", () => {
const md = buildChatMarkdown({
title: null,
chatId: "unsaved",
rows: [],
live: [
live({ id: "u1", role: "user", parts: [{ type: "text", text: "q" }] }),
live({
id: "a1",
role: "assistant",
parts: [{ type: "text", text: "half an answer" }],
}),
],
isStreaming: false,
banner: "Connection dropped — the response was cut off.",
t,
});
expect(md).toContain("half an answer");
// An interrupted (non-streaming) partial is exported as-is, no generating note.
expect(md).not.toContain("still being generated");
// The on-screen banner records the interruption.
expect(md).toContain("_⚠️ Connection dropped — the response was cut off._");
});
});

View File

@@ -1,308 +0,0 @@
/**
* Client-only Markdown builder for an AI agent chat. Serializes the already
* persisted message rows (loaded via `useAiChatMessagesQuery`) into a single
* Markdown string suitable for copying to the clipboard. NO network call is
* made and NO server/DB code is touched — this reuses the rich "request
* internals" (tool calls with input/output, per-message token usage,
* finish/error info) that the chat already holds client-side.
*
* Only role labels and tool action labels are localized via the passed-in `t`
* translator; the structural document words (Input/Output/Error/Tokens/...) are
* plain English constants because the output is a technical artifact.
*/
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
import {
ToolUiPart,
getToolName,
toolRunState,
toolLabelKey,
} from "@/features/ai-chat/utils/tool-parts.tsx";
// Minimal translator signature compatible with react-i18next's `t`.
type Translate = (key: string, values?: Record<string, unknown>) => string;
interface BuildChatMarkdownArgs {
title: string | null;
chatId: string;
/** The live, on-screen messages — the WYSIWYG source of the export. When
* present and non-empty these DRIVE the document (so it mirrors exactly what
* the user sees, including a partial reply from an interrupted turn). Each is
* matched to a persisted row by `id` to enrich it with token usage / error /
* timestamp. When absent or empty the builder falls back to `rows`. */
live?: LiveMessage[];
/** Persisted message rows. Enrichment source (matched to `live` by id) AND the
* fallback document source when `live` is empty. */
rows: IAiChatMessageRow[];
/** Whether the live thread is still streaming. Only then is the tail assistant
* message flagged "still generating"; an interrupted (non-streaming) partial
* reply is exported as-is and the `banner` explains the interruption. */
isStreaming?: boolean;
/** The on-screen banner text (error / dropped connection / manual stop),
* appended at the end of the export so the artifact records the interruption
* the user saw. */
banner?: string | null;
t: Translate;
}
/** A single AI SDK UIMessage part (text part or other). */
interface TextLikePart {
type: string;
text?: string;
}
/** Authoritative per-turn usage the server attaches to a message / row. */
interface UsageLike {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
}
/** A live, on-screen message (subset of the AI SDK UIMessage we consume). */
interface LiveMessage {
id: string;
role: "user" | "assistant" | string;
parts: TextLikePart[];
metadata?: { usage?: UsageLike; error?: string };
}
/** One message normalized for rendering, regardless of live/persisted origin. */
interface ExportItem {
role: string;
parts: TextLikePart[];
usage?: UsageLike;
error?: string;
/** ISO timestamp from the persisted row, when one is known. */
createdAt?: string;
/** True only for the tail assistant message while the thread is streaming. */
generating: boolean;
}
/**
* Stringify an arbitrary tool input/output value for a fenced block. Strings
* pass through as-is; everything else is pretty-printed JSON, falling back to
* `String(value)` if serialization throws (e.g. a circular structure).
*/
function stringify(value: unknown): string {
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
/**
* Wrap `code` in a fenced code block whose backtick delimiter is LONGER than
* the longest backtick run inside the content, so embedded backticks (or even
* a literal ``` fence) never break out of the block. Minimum 3 backticks.
*/
function fence(code: string, lang = ""): string {
const runs: string[] = code.match(/`+/g) ?? [];
const longest = runs.reduce((m, s) => Math.max(m, s.length), 0);
const delim = "`".repeat(Math.max(3, longest + 1));
return `${delim}${lang}\n${code}\n${delim}`;
}
/** Per-row token count, mirroring the header sum in ai-chat-window.tsx. */
function rowTokens(usage: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
}): number {
return (
usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
);
}
/** Render one message's UIMessage parts into an array of Markdown blocks
* (text blocks + tool blocks). Mirrors MessageItem's part handling. */
function renderMessageParts(parts: TextLikePart[], t: Translate): string[] {
const out: string[] = [];
for (const part of parts) {
if (part.type === "text") {
const text = (part.text ?? "").trim();
// Skip empty/whitespace-only text parts (matches MessageItem).
if (text.length > 0) out.push(text);
continue;
}
const isToolPart =
part.type.startsWith("tool-") || part.type === "dynamic-tool";
if (!isToolPart) continue;
const tp = part as unknown as ToolUiPart;
const name = getToolName(tp);
const { key, values } = toolLabelKey(name);
const label = t(key, values);
const state = toolRunState(tp.state);
const toolLines: string[] = [
`**Tool: ${label}** (\`${name}\`) — ${state}`,
];
if (tp.input !== undefined) {
toolLines.push("Input:");
toolLines.push(fence(stringify(tp.input), "json"));
}
if (tp.output !== undefined) {
toolLines.push("Output:");
toolLines.push(fence(stringify(tp.output), "json"));
}
if (tp.errorText) {
toolLines.push(`**Error:** ${tp.errorText}`);
}
out.push(toolLines.join("\n\n"));
}
return out;
}
/** Resolve a persisted row's parts: prefer the rich persisted parts, else a
* single text part built from the plain-text content (mirrors `rowToUiMessage`). */
function rowParts(row: IAiChatMessageRow): TextLikePart[] {
return Array.isArray(row.metadata?.parts) && row.metadata.parts.length > 0
? (row.metadata.parts as TextLikePart[])
: [{ type: "text", text: row.content ?? "" }];
}
/**
* Normalize the export to one ordered list of {@link ExportItem}, WYSIWYG-first:
*
* - When `live` messages are present, THEY are the document (what the user sees,
* incl. an interrupted turn's partial reply). Each is matched to a persisted
* row by `id` to pull token usage / error / timestamp — a live message of the
* CURRENT turn has no matching row yet, so it simply renders without a footer.
* Authoritative `usage`/`error` already on the live message metadata win over
* the row (the server attaches usage to the streamed message at a step
* boundary before the row is refetched). Only the tail assistant message is
* flagged `generating`, and only while `isStreaming`.
* - When `live` is empty (e.g. the export runs before the live mirror is
* populated), fall back to the persisted `rows` so the format never regresses.
*/
function resolveItems(
live: LiveMessage[] | undefined,
rows: IAiChatMessageRow[],
isStreaming: boolean,
): ExportItem[] {
if (live && live.length > 0) {
const rowsById = new Map(rows.map((r) => [r.id, r]));
// The "still generating" note may apply ONLY to an assistant message that is
// the actual TAIL of the list — that is where the on-screen typing indicator
// sits. While `status === "submitted"` (isStreaming true) right after the
// user hit send, the tail is the USER message and the new assistant turn has
// no message yet; the previous assistant answer is shown complete on screen,
// so it must NOT be flagged (the indicator renders as a separate bottom
// block, not on that answer).
const lastIndex = live.length - 1;
const tailIsStreamingAssistant =
isStreaming && live[lastIndex]?.role === "assistant";
return live.map((m, i) => {
const row = rowsById.get(m.id);
return {
role: m.role,
parts: m.parts ?? [],
// Authoritative usage/error already on the live message (the server
// attaches usage to the streamed message at a step boundary) wins over
// the persisted row; a current-turn live message has no matching row yet
// and simply renders without a token footer (the accepted WYSIWYG
// tradeoff — an interrupted turn loses only its token footer, not text).
usage: m.metadata?.usage ?? row?.metadata?.usage,
error: m.metadata?.error ?? row?.metadata?.error ?? undefined,
createdAt: row?.createdAt,
generating: tailIsStreamingAssistant && i === lastIndex,
};
});
}
return rows.map((row) => ({
role: row.role,
parts: rowParts(row),
usage: row.metadata?.usage,
error: row.metadata?.error ?? undefined,
createdAt: row.createdAt,
generating: false,
}));
}
/**
* Serialize a chat to a Markdown string. Pure (apart from `new Date()` for the
* export timestamp), so it is straightforward to unit-test.
*/
export function buildChatMarkdown(args: BuildChatMarkdownArgs): string {
const { title, chatId, live, rows, isStreaming, banner, t } = args;
const blocks: string[] = [];
const items = resolveItems(live, rows, isStreaming === true);
const heading = (title ?? "").trim() || t("Untitled chat");
blocks.push(`# ${heading}`);
// Metadata bullet list. Total tokens is only shown when there is a sum.
const totalTokens = items.reduce(
(sum, item) => (item.usage ? sum + rowTokens(item.usage) : sum),
0,
);
const meta = [
`- Chat ID: \`${chatId}\``,
`- Exported: ${new Date().toISOString()}`,
`- Messages: ${items.length}`,
];
if (totalTokens > 0) meta.push(`- Total tokens: ${totalTokens}`);
blocks.push(meta.join("\n"));
items.forEach((item, index) => {
blocks.push("---");
const roleLabel = item.role === "assistant" ? t("AI agent") : t("You");
blocks.push(`## ${index + 1}. ${roleLabel}`);
// Created-at kept in source as an HTML comment (out of the rendered prose).
// A live message of the current turn has no persisted row yet — omit it.
if (item.createdAt) blocks.push(`<!-- ${item.createdAt} -->`);
blocks.push(...renderMessageParts(item.parts, t));
// A generating assistant may have empty/no parts yet — the heading (above)
// and this note still record the in-progress turn.
if (item.generating) {
blocks.push(
"_⏳ This message is still being generated — the export captured a partial, in-progress response._",
);
}
// A persisted per-message error (the raw provider text) may coexist with the
// trailing `banner` (the classified on-screen alert) when the failed turn's
// row has already been refetched by export time. They describe the same
// failure at different fidelity; showing both is an accepted, minor redundancy.
if (item.error) {
blocks.push(`**⚠️ Error:** ${item.error}`);
}
const usage = item.usage;
if (usage) {
const total = usage.totalTokens ?? rowTokens(usage);
// Reasoning (thinking) tokens are shown only when the provider reported a
// positive count; old rows / non-reasoning providers omit it.
const reasoning =
usage.reasoningTokens && usage.reasoningTokens > 0
? `, reasoning: ${usage.reasoningTokens}`
: "";
blocks.push(
`_Tokens — in: ${usage.inputTokens ?? "?"}, out: ${usage.outputTokens ?? "?"}${reasoning}, total: ${total}_`,
);
}
});
// Record the on-screen banner (error / dropped connection / manual stop) so
// the export reflects exactly what the user saw, including an interruption.
if (banner && banner.trim().length > 0) {
blocks.push("---");
blocks.push(`_⚠️ ${banner.trim()}_`);
}
// Blank line between blocks so the Markdown renders cleanly.
return blocks.join("\n\n");
}

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
describe("collapseBlankLines", () => {
it("collapses a run of 2+ newlines to a single newline", () => {
expect(collapseBlankLines("a\n\nb")).toBe("a\nb");
expect(collapseBlankLines("a\n\n\n\nb")).toBe("a\nb");
});
it("keeps single newlines untouched", () => {
expect(collapseBlankLines("a\nb\nc")).toBe("a\nb\nc");
});
it("preserves blank lines INSIDE a fenced code block", () => {
const src = "a\n\n\nb\n\n```\nx\n\n\ny\n```\n\nc";
// Prose blanks collapse; the blank lines between the ``` fences survive.
expect(collapseBlankLines(src)).toBe("a\nb\n```\nx\n\n\ny\n```\nc");
});
it("handles a tilde fence and preserves its interior blanks", () => {
const src = "p\n\n~~~\ncode\n\nmore\n~~~\n\nq";
expect(collapseBlankLines(src)).toBe("p\n~~~\ncode\n\nmore\n~~~\nq");
});
it("leaves an unclosed fence's remaining lines verbatim", () => {
const src = "intro\n\n```\nstill\n\nopen";
expect(collapseBlankLines(src)).toBe("intro\n```\nstill\n\nopen");
});
it("is a no-op for text with no blank lines", () => {
expect(collapseBlankLines("just one line")).toBe("just one line");
});
});
describe("collapseBlankLines + renderChatMarkdown (tight reasoning rendering)", () => {
it("renders a blank-line-separated list as a TIGHT list (no <li><p>)", () => {
const loose =
"Intro paragraph.\n\n- item one\n\n- item two\n\n- item three";
const html = renderChatMarkdown(collapseBlankLines(loose), {});
// Tight list: each <li> holds the text directly, not wrapped in a <p>.
expect(html).toContain("<li>item one</li>");
expect(html).not.toContain("<li><p>");
// The list still parses as a list after the paragraph (not a paragraph+<br>).
expect(html).toContain("<ul>");
expect(html).toContain("<p>Intro paragraph.</p>");
});
it("renders an ordered list (1. 2.) as tight after collapsing", () => {
const loose = "Intro.\n\n1. first\n\n2. second";
const html = renderChatMarkdown(collapseBlankLines(loose), {});
expect(html).toContain("<ol>");
expect(html).toContain("<li>first</li>");
expect(html).not.toContain("<li><p>");
});
it("the loose source WOULD render <li><p> without collapsing (control)", () => {
const loose = "- a\n\n- b";
expect(renderChatMarkdown(loose, {})).toContain("<li><p>");
});
});

View File

@@ -0,0 +1,56 @@
// Pure helper for compact reasoning ("Thinking") rendering. Kept free of React
// so it can be unit-tested in isolation (see collapse-blank-lines.test.ts).
/**
* Collapse runs of 2+ newlines down to a single newline, EXCEPT inside fenced
* code blocks (``` ... ``` or ~~~ ... ~~~), where blank lines are significant.
*
* Why: reasoning models emit thinking with a blank line (`\n\n`) between every
* list item and paragraph. `marked` turns those into "loose" lists (each `<li>`
* wrapped in a `<p>`) and separate `<p>` paragraphs, each carrying a vertical
* margin — so the "Thinking" block renders with large, airy gaps. Removing the
* blank-line gaps yields tight lists (no `<li><p>`) and joined paragraphs. The
* chat markdown renderer runs with `breaks: true`, so a single `\n` still
* becomes a `<br>` — line breaks inside the reasoning are preserved; only the
* empty gaps between blocks disappear. Apply ONLY to reasoning text, never to a
* normal assistant answer (where paragraph spacing is intentional).
*
* Fenced code is preserved verbatim: a fence opens on a line whose first
* non-space characters are ``` or ~~~ and closes on the next line that starts
* with the same fence character. Blank lines between fences (significant for
* code formatting) are never collapsed.
*/
export function collapseBlankLines(text: string): string {
const lines = text.split("\n");
const out: string[] = [];
let inFence = false;
let fenceChar = "";
for (const line of lines) {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
if (fenceMatch) {
const ch = fenceMatch[1][0];
if (!inFence) {
inFence = true;
fenceChar = ch;
} else if (ch === fenceChar) {
inFence = false;
}
out.push(line);
continue;
}
// Inside a fenced block every line (including blanks) is significant.
if (inFence) {
out.push(line);
continue;
}
// Outside fences: drop blank lines so a `\n\n+` gap collapses to a single
// `\n` between the surrounding content lines.
if (line.trim() === "") continue;
out.push(line);
}
return out.join("\n");
}

View File

@@ -117,3 +117,55 @@ describe("liveTurnTokens — authoritative path", () => {
expect(r).toEqual({ reasoning: 0, output: 1, authoritative: false });
});
});
describe("liveTurnTokens — combined authoritative + estimate (#163)", () => {
it("ticks the in-flight step above the completed-steps authoritative base", () => {
// The authoritative usage is the sum over COMPLETED steps (step 1). The
// CURRENT step is streaming and its text is NOT in `usage` yet, but it IS in
// the parts -> the running estimate must push the live figure above the base
// so the badge keeps growing between step boundaries.
const longText = "x".repeat(800); // 800 chars -> 200 est output tokens
const r = liveTurnTokens(
msg([{ type: "text", text: longText }], {
usage: { inputTokens: 500, outputTokens: 40 }, // step-1 base: 40 output
}),
);
// max(authOutput=40, estOutput=200) = 200 -> the counter ticks, not frozen.
expect(r.output).toBe(200);
expect(r.authoritative).toBe(true);
});
it("ticks reasoning of the in-flight step above the authoritative reasoning base", () => {
const longReasoning = "r".repeat(400); // 400 chars -> 100 est reasoning
const r = liveTurnTokens(
msg([{ type: "reasoning", text: longReasoning }], {
usage: { inputTokens: 100, outputTokens: 20, reasoningTokens: 20 },
}),
);
// reasoning: max(20, 100) = 100 ; output: max(max(0,20-20)=0, 0) = 0.
expect(r.reasoning).toBe(100);
expect(r.output).toBe(0);
expect(r.authoritative).toBe(true);
});
it("snaps to the authoritative figure once it exceeds the rough estimate", () => {
// Short on-screen text (estimate tiny) but a large authoritative output:
// the exact figure wins at the boundary (the counter never under-reports).
const r = liveTurnTokens(
msg([{ type: "text", text: "abcd" }], {
usage: { inputTokens: 10, outputTokens: 5000 },
}),
);
expect(r.output).toBe(5000);
});
it("is monotonic: max never drops below the authoritative base when the estimate is smaller", () => {
// Mirrors the legacy 'verbatim' tests: estimate < authoritative -> unchanged.
const r = liveTurnTokens(
msg([{ type: "text", text: "tiny" }], {
usage: { inputTokens: 500, outputTokens: 100, reasoningTokens: 30 },
}),
);
expect(r).toEqual({ reasoning: 30, output: 70, authoritative: true });
});
});

View File

@@ -56,39 +56,58 @@ function metadataUsage(message: UIMessage): AuthoritativeUsage | undefined {
/**
* Token split for the given (streaming) assistant message.
*
* Prefers AUTHORITATIVE `metadata.usage` when the server has attached it (at a
* step/turn boundary, incl. `reasoningTokens`) — so the live counter snaps to the
* provider's exact figures. Until then it returns a running ESTIMATE summed over
* the message parts: `reasoning` parts feed the reasoning estimate, `text` parts
* feed the output estimate. Multi-part / multi-step turns accumulate naturally
* because every part of the turn is summed.
* COMBINES the authoritative server usage with the running text estimate so the
* counter ticks in real time AND lands exact. The server only attaches
* `metadata.usage` at a step/turn boundary (`finish-step`/`finish`) and it is
* CUMULATIVE over COMPLETED steps — it does NOT yet include the in-flight step.
* So a multi-step turn that returned the authoritative figure verbatim would
* FREEZE between boundaries and jump in steps (issue #163).
*
* Instead we always compute the running ESTIMATE (chars/≈4 over the message's
* `reasoning`/`text` parts, which grows on every streamed delta) and take the
* per-component MAX of the authoritative base and the estimate:
* - between boundaries the estimate of the in-flight step ticks the number up;
* - at a boundary the authoritative figure snaps it to exact;
* - because the server's usage is cumulative and we only ever take the max, the
* number is MONOTONIC — it never drops.
*
* Providers that don't stream reasoning text still surface a reasoning count once
* the authoritative usage arrives (`usage.reasoningTokens`); on the pure estimate
* path such a turn simply shows `reasoning: 0` until then.
* the authoritative usage arrives (`max(reasoningTokens, 0)`); on the pure
* estimate path (no usage yet) such a turn shows `reasoning: 0` until then.
*/
export function liveTurnTokens(message: UIMessage | undefined): LiveTurnTokens {
if (!message) return { reasoning: 0, output: 0, authoritative: false };
const usage = metadataUsage(message);
if (usage) {
// Authoritative branch: outputTokens already INCLUDES reasoning tokens in the
// AI SDK usage shape, so subtract reasoning out for the "answer" figure (never
// go negative if a provider reports them inconsistently).
const reasoning = usage.reasoningTokens ?? 0;
const totalOutput = usage.outputTokens ?? 0;
const output = Math.max(0, totalOutput - reasoning);
return { reasoning, output, authoritative: true };
}
let reasoning = 0;
let output = 0;
// Running ESTIMATE over every reasoning/text part — grows on each delta. This
// includes the IN-FLIGHT step, which the authoritative usage does not cover yet.
let estReasoning = 0;
let estOutput = 0;
for (const part of message.parts ?? []) {
if (part.type === "reasoning") {
reasoning += estimateTokens((part as { text?: string }).text ?? "");
estReasoning += estimateTokens((part as { text?: string }).text ?? "");
} else if (part.type === "text") {
output += estimateTokens((part as { text?: string }).text ?? "");
estOutput += estimateTokens((part as { text?: string }).text ?? "");
}
}
return { reasoning, output, authoritative: false };
const usage = metadataUsage(message);
if (!usage) {
// No authoritative usage streamed yet: the estimate IS the live figure.
return { reasoning: estReasoning, output: estOutput, authoritative: false };
}
// Authoritative sum over COMPLETED steps. `outputTokens` already INCLUDES
// reasoning in the AI SDK usage shape, so subtract it out for the "answer"
// figure (never go negative if a provider reports them inconsistently).
const authReasoning = usage.reasoningTokens ?? 0;
const authOutput = Math.max(0, (usage.outputTokens ?? 0) - authReasoning);
// Per-component max: the in-flight step's estimate ticks above the completed-
// steps base between boundaries, and the authoritative figure wins once it
// exceeds the (rough) estimate at the next boundary. Monotonic by construction.
return {
reasoning: Math.max(authReasoning, estReasoning),
output: Math.max(authOutput, estOutput),
authoritative: true,
};
}

View File

@@ -1,25 +1,45 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import { getFootnoteNumber } from "@docmost/editor-ext";
import { getFootnoteNumber, getFootnoteRefCount } from "@docmost/editor-ext";
import classes from "./footnote.module.css";
/**
* A 0-based backlink index -> its lowercase letter label (0 -> "a", 25 -> "z",
* 26 -> "aa", ...), matching the Pandoc/Wikipedia "↩ a b c" convention.
*/
export function backlinkLabel(index: number): string {
let out = "";
let x = index;
while (x >= 0) {
out = String.fromCharCode(97 + (x % 26)) + out;
x = Math.floor(x / 26) - 1;
}
return out;
}
/**
* NodeView for a single footnote definition: a decorative number marker, the
* editable content (NodeViewContent), and a "↩" back-link to its reference.
* The number is derived from the document (not stored).
*
* After #166 a footnote can be referenced more than once (one number, one
* definition, N forward links). When it is, the back-link becomes a row of
* per-occurrence links — ↩ a b c … — each scrolling to its own reference (#168);
* a single-reference footnote keeps the plain ↩.
*/
export default function FootnoteDefinitionView(props: NodeViewProps) {
const { node, editor } = props;
const { t } = useTranslation();
const id = node.attrs.id as string;
// Read the cached number from the numbering plugin (computed once per doc
// change) rather than recomputing the whole map on every render.
// Read the cached number/ref-count from the numbering plugin (computed once
// per doc change) rather than recomputing the whole map on every render.
const number = getFootnoteNumber(editor.state, id) ?? "?";
const refCount = getFootnoteRefCount(editor.state, id);
const handleBack = (e: React.MouseEvent) => {
const jumpTo = (e: React.MouseEvent, index: number) => {
e.preventDefault();
editor.commands.scrollToReference(id);
editor.commands.scrollToReference(id, index);
};
return (
@@ -42,16 +62,47 @@ export default function FootnoteDefinitionView(props: NodeViewProps) {
>
{number}.
</span>
<span
className={classes.backLink}
contentEditable={false}
onClick={handleBack}
role="button"
aria-label={t("Back to reference")}
title={t("Back to reference")}
>
</span>
{refCount > 1 ? (
// Multiple references -> ↩ followed by one lettered link per occurrence.
<span
className={classes.backLinks}
contentEditable={false}
role="group"
aria-label={t("Back to references")}
>
<span className={classes.backLinkArrow} aria-hidden="true">
</span>
{Array.from({ length: refCount }, (_, i) => (
<span
key={i}
className={classes.backLink}
onClick={(e) => jumpTo(e, i)}
role="button"
aria-label={t("Back to reference {{label}}", {
label: backlinkLabel(i),
})}
title={t("Back to reference {{label}}", {
label: backlinkLabel(i),
})}
>
{backlinkLabel(i)}
</span>
))}
</span>
) : (
// Single reference -> the plain ↩ (unchanged behavior).
<span
className={classes.backLink}
contentEditable={false}
onClick={(e) => jumpTo(e, 0)}
role="button"
aria-label={t("Back to reference")}
title={t("Back to reference")}
>
</span>
)}
</NodeViewWrapper>
);
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { render } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, fireEvent } from "@testing-library/react";
/**
* Structural regression guard for #146 (PR #147).
@@ -36,10 +36,14 @@ vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// footnote-definition-view reads a cached number from the numbering plugin;
// stub it so we don't need a live ProseMirror state.
// footnote-definition-view reads a cached number + reference count from the
// numbering plugin; stub them so we don't need a live ProseMirror state. The
// ref-count is a hoisted mutable so a test can drive the single-vs-multi
// backlink branch (#168). Default 1 = single reference (the #146 cases).
const { mockRefCount } = vi.hoisted(() => ({ mockRefCount: { value: 1 } }));
vi.mock("@docmost/editor-ext", () => ({
getFootnoteNumber: () => 1,
getFootnoteRefCount: () => mockRefCount.value,
}));
// Mocks so CodeBlockView renders cheaply (no MantineProvider, no matchMedia).
@@ -59,7 +63,8 @@ vi.mock("@mantine/core", () => ({
),
}));
vi.mock("@/components/common/copy-button", () => ({
CopyButton: ({ children }: any) => children({ copied: false, copy: () => {} }),
CopyButton: ({ children }: any) =>
children({ copied: false, copy: () => {} }),
}));
vi.mock("@tabler/icons-react", () => ({
IconCheck: () => null,
@@ -70,7 +75,9 @@ vi.mock("@/features/editor/components/code-block/mermaid-view.tsx", () => ({
}));
import FootnotesListView from "./footnotes-list-view";
import FootnoteDefinitionView from "./footnote-definition-view";
import FootnoteDefinitionView, {
backlinkLabel,
} from "./footnote-definition-view";
import CodeBlockView from "../code-block/code-block-view";
// Minimal NodeViewProps stub: definition view only touches node.attrs.id and
@@ -141,3 +148,84 @@ describe("#146 editable NodeView contentDOM-first invariant", () => {
},
);
});
// #168: a footnote referenced more than once shows one lettered backlink per
// occurrence (↩ a b c), each scrolling to its own reference; a single-reference
// footnote keeps the plain ↩.
describe("#168 footnote definition multi-backlinks", () => {
afterEach(() => {
// Reset the shared ref-count mock so other tests see a single reference.
mockRefCount.value = 1;
});
const makeProps = () =>
({
node: { attrs: { id: "fn-1" }, textContent: "" },
editor: {
state: {},
isEditable: true,
commands: { scrollToReference: vi.fn() },
},
getPos: () => 0,
updateAttributes: () => {},
deleteNode: () => {},
}) as any;
it("renders one lettered backlink per reference (a, b, c) plus the ↩ arrow", () => {
mockRefCount.value = 3;
const { getByTestId } = render(<FootnoteDefinitionView {...makeProps()} />);
const wrapper = getByTestId("nvw");
const links = wrapper.querySelectorAll('[role="button"]');
expect(Array.from(links).map((l) => l.textContent)).toEqual([
"a",
"b",
"c",
]);
// The ↩ arrow is present (as decorative chrome, not a button).
expect(wrapper.textContent).toContain("↩");
});
it("clicking the n-th backlink scrolls to the n-th occurrence (0-based)", () => {
mockRefCount.value = 3;
const props = makeProps();
const { getByTestId } = render(<FootnoteDefinitionView {...props} />);
const links = getByTestId("nvw").querySelectorAll('[role="button"]');
fireEvent.click(links[1]); // "b"
expect(props.editor.commands.scrollToReference).toHaveBeenCalledWith(
"fn-1",
1,
);
});
it("a single-reference footnote renders just one ↩ (no letters)", () => {
mockRefCount.value = 1;
const props = makeProps();
const { getByTestId } = render(<FootnoteDefinitionView {...props} />);
const wrapper = getByTestId("nvw");
const links = wrapper.querySelectorAll('[role="button"]');
expect(links.length).toBe(1);
expect(links[0].textContent).toBe("↩");
fireEvent.click(links[0]);
expect(props.editor.commands.scrollToReference).toHaveBeenCalledWith(
"fn-1",
0,
);
});
});
// #185 re-review pt 7: backlinkLabel is base-26 (a..z, then aa…). The component
// tests only cover a,b,c (index 0-2); pin the >= 26 carry boundary.
describe("backlinkLabel base-26 boundary (#168)", () => {
it("maps 0->a, 25->z, 26->aa, 27->ab, 51->az, 52->ba", () => {
expect(backlinkLabel(0)).toBe("a");
expect(backlinkLabel(25)).toBe("z");
expect(backlinkLabel(26)).toBe("aa");
expect(backlinkLabel(27)).toBe("ab");
expect(backlinkLabel(51)).toBe("az");
expect(backlinkLabel(52)).toBe("ba");
});
});

View File

@@ -115,3 +115,18 @@
.backLink:hover {
text-decoration: underline;
}
/* Multi-backlink row (#168): ↩ a b c — one lettered link per reference
occurrence. Sits on the right, after the content, like the single ↩. */
.backLinks {
flex: 0 0 auto;
display: inline-flex;
align-items: baseline;
gap: 0.3em;
user-select: none;
}
.backLinkArrow {
color: var(--mantine-color-dimmed);
font-size: 0.9em;
}

View File

@@ -274,7 +274,10 @@ export function useRestorePageMutation() {
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge);
},
onError: (error) => {
notifications.show({ message: t("Failed to restore page"), color: "red" });
notifications.show({
message: t("Failed to restore page"),
color: "red",
});
},
});
}
@@ -285,10 +288,10 @@ export function useGetSidebarPagesQuery(
return useInfiniteQuery({
queryKey: ["sidebar-pages", data],
enabled: !!data?.pageId || !!data?.spaceId,
queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
queryFn: ({ pageParam }) =>
getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
initialPageParam: undefined,
getNextPageParam: (lastPage) =>
lastPage.meta?.nextCursor ?? undefined,
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
});
}
@@ -296,11 +299,14 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
return useInfiniteQuery({
queryKey: ["root-sidebar-pages", data.spaceId],
queryFn: async ({ pageParam }) => {
return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam, limit: 100 });
return getSidebarPages({
spaceId: data.spaceId,
cursor: pageParam,
limit: 100,
});
},
initialPageParam: undefined,
getNextPageParam: (lastPage) =>
lastPage.meta?.nextCursor ?? undefined,
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
});
}
@@ -323,12 +329,17 @@ export function usePageBreadcrumbsQuery(
});
}
export async function fetchAllAncestorChildren(params: SidebarPagesParams) {
export async function fetchAllAncestorChildren(
params: SidebarPagesParams,
// `fresh: true` forces a server refetch (staleTime 0) — used by the reconnect
// refresh (#159 #8), which must NOT receive the 30-min-cached children.
opts?: { fresh?: boolean },
) {
// not using a hook here, so we can call it inside a useEffect hook
const response = await queryClient.fetchQuery({
queryKey: ["sidebar-pages", params],
queryFn: () => getAllSidebarPages(params),
staleTime: 30 * 60 * 1000,
staleTime: opts?.fresh ? 0 : 30 * 60 * 1000,
});
const allItems = response.pages.flatMap((page) => page.items);
@@ -347,11 +358,15 @@ export function useRecentChangesQuery(spaceId?: string) {
});
}
export function useCreatedByQuery(params?: { userId?: string; spaceId?: string }) {
export function useCreatedByQuery(params?: {
userId?: string;
spaceId?: string;
}) {
const { userId, spaceId } = params ?? {};
return useInfiniteQuery({
queryKey: ["pages-created-by-user", { userId, spaceId }],
queryFn: ({ pageParam }) => getCreatedByPages({ userId, spaceId, cursor: pageParam, limit: 15 }),
queryFn: ({ pageParam }) =>
getCreatedByPages({ userId, spaceId, cursor: pageParam, limit: 15 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,

View File

@@ -29,9 +29,11 @@ import {
collectBranchIds,
openBranches,
closeIds,
loadedOpenBranchIds,
} from "@/features/page/tree/utils/utils.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
import {
getPageBreadcrumbs,
getSpaceTree,
@@ -39,11 +41,7 @@ import {
import { IPage } from "@/features/page/types/page.types.ts";
import { extractPageSlugId } from "@/lib";
import { isCompactPageTreeEnabled } from "@/lib/config.ts";
import {
DocTree,
ROW_HEIGHT_COMPACT,
ROW_HEIGHT_STANDARD,
} from "./doc-tree";
import { DocTree, ROW_HEIGHT_COMPACT, ROW_HEIGHT_STANDARD } from "./doc-tree";
import { SpaceTreeRow } from "./space-tree-row";
interface SpaceTreeProps {
@@ -193,6 +191,54 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
[openTreeNodes],
);
// Latest tree + open-state for the reconnect handler (its closure would
// otherwise read stale snapshots).
const [socket] = useAtom(socketAtom);
const dataRef = useRef(data);
dataRef.current = data;
const openIdsRef = useRef(openIds);
openIdsRef.current = openIds;
// Reconnect refresh (#159 #8): on a socket reconnect, re-fetch and reconcile
// the children of every currently-open, already-loaded branch of THIS space,
// so a move/rename/delete that happened INSIDE a loaded branch while events
// were missed (laptop sleep / wifi gap) is reflected instead of left stale.
// The ROOT level is reconciled separately by the root-query refetch +
// mergeRootTrees; an UNLOADED branch is skipped (lazy-load fetches it fresh on
// expand). No first-connect guard is needed: space-tree usually mounts AFTER
// the initial connect, so every `connect` it sees is a reconnect; the rare
// initial-connect case has an empty tree, so the refresh is a harmless no-op.
useEffect(() => {
if (!socket) return;
const onConnect = async () => {
const effectSpaceId = spaceIdRef.current;
const branchIds = loadedOpenBranchIds(
dataRef.current.filter((n) => n?.spaceId === effectSpaceId),
openIdsRef.current,
);
if (branchIds.length === 0) return;
for (const id of branchIds) {
try {
// `fresh: true` bypasses the 30-min sidebar-pages cache so the
// reconcile sees the server's CURRENT children (handler-order
// independent — no reliance on the global reconnect invalidation).
const fresh = await fetchAllAncestorChildren(
{ pageId: id, spaceId: effectSpaceId },
{ fresh: true },
);
if (spaceIdRef.current !== effectSpaceId) return; // space switched
setData((prev) => treeModel.reconcileChildren(prev, id, fresh));
} catch (err) {
console.error("[tree] reconnect branch refresh failed", err);
}
}
};
socket.on("connect", onConnect);
return () => {
socket.off("connect", onConnect);
};
}, [socket, setData]);
const handleToggle = useCallback(
async (id: string, isOpen: boolean) => {
setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen }));
@@ -245,8 +291,7 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
notifications.show({
color: "red",
message: t("Couldn't expand the tree: {{reason}}", {
reason:
err?.response?.data?.message ?? err?.message ?? String(err),
reason: err?.response?.data?.message ?? err?.message ?? String(err),
}),
});
} finally {
@@ -262,11 +307,11 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
setOpenTreeNodes((prev) => closeIds(prev, ids));
}, [filteredData, setOpenTreeNodes]);
useImperativeHandle(
ref,
() => ({ expandAll, collapseAll, isExpanding }),
[expandAll, collapseAll, isExpanding],
);
useImperativeHandle(ref, () => ({ expandAll, collapseAll, isExpanding }), [
expandAll,
collapseAll,
isExpanding,
]);
// Stable callbacks for DocTree. Without these, every parent render recreates
// the props and tears down every row's draggable/dropTarget subscription,

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import type { TreeNode, SiblingsInfo } from './tree-model.types';
import type { TreeNode, SiblingsInfo } from "./tree-model.types";
function findInternal<T extends object>(
nodes: TreeNode<T>[],
@@ -19,7 +19,10 @@ export const treeModel = {
return findInternal(tree, id)?.node ?? null;
},
path<T extends object>(tree: TreeNode<T>[], id: string): TreeNode<T>[] | null {
path<T extends object>(
tree: TreeNode<T>[],
id: string,
): TreeNode<T>[] | null {
const found = findInternal(tree, id);
if (!found) return null;
return [...found.parents, found.node];
@@ -123,6 +126,23 @@ export const treeModel = {
return treeModel.insert(tree, null, node, index(tree));
}
const parent = treeModel.find(tree, parentId);
// The parent is in the tree but its children have NOT been lazy-loaded yet
// (`children === undefined`, distinct from a loaded-but-empty `[]`). Inserting
// here would MATERIALIZE a misleading partial child list (`[node]`) that
// defeats the lazy-load gate — which fetches only when children are
// absent/empty — so the parent's OTHER real children would never load and the
// moved/added node would be the only one shown (a silent data loss, #159 #1).
// Instead, leave the children unloaded and just flag `hasChildren` so the
// chevron appears; expanding fetches the FULL set (including this node).
if (parent && parent.children === undefined) {
return treeModel.update(
tree,
parentId,
// hasChildren is not part of the generic T constraint; tree nodes carry
// it. Cast narrowly so this stays a single, well-understood exception.
{ hasChildren: true } as unknown as Omit<Partial<T>, "id" | "children">,
);
}
const kids = (parent?.children as TreeNode<T>[] | undefined) ?? [];
return treeModel.insert(tree, parentId, node, index(kids));
},
@@ -203,6 +223,48 @@ export const treeModel = {
return touched ? out : tree;
},
// Replace a parent's DIRECT children with the authoritative `fresh` set while
// PRESERVING each surviving child's already-loaded grandchildren (deeper
// expansion). Unlike `appendChildren` (add-only), this DROPS children that are
// no longer present and reorders to `fresh` — so a move/delete/rename that
// happened inside a loaded branch while events were missed (a socket reconnect
// gap) is reflected, not left stale (#159 #8). Only used to reconcile an
// already-loaded branch against a fresh fetch; a parent with no loaded children
// (`children === undefined`) is left untouched (lazy-load handles it).
reconcileChildren<T extends object>(
tree: TreeNode<T>[],
parentId: string,
fresh: TreeNode<T>[],
): TreeNode<T>[] {
let touched = false;
const walk = (nodes: TreeNode<T>[]): TreeNode<T>[] =>
nodes.map((n) => {
if (n.id === parentId) {
// Only reconcile a branch whose children were actually loaded; an
// unloaded parent stays unloaded (lazy-load fetches it fresh later).
if (n.children === undefined) return n;
const prevById = new Map(n.children.map((c) => [c.id, c]));
const merged = fresh.map((f) => {
const prev = prevById.get(f.id);
// Preserve the surviving child's previously loaded grandchildren so
// deeper expansion is not collapsed by the reconcile.
return prev?.children !== undefined
? { ...f, children: prev.children }
: f;
});
touched = true;
return { ...n, children: merged };
}
if (n.children) {
const next = walk(n.children);
if (next !== n.children) return { ...n, children: next };
}
return n;
});
const out = walk(tree);
return touched ? out : tree;
},
place<T extends object>(
tree: TreeNode<T>[],
sourceId: string,
@@ -242,9 +304,10 @@ export const treeModel = {
move<T extends object>(
tree: TreeNode<T>[],
sourceId: string,
op: import('./tree-model.types').DropOp,
): { tree: TreeNode<T>[]; result: import('./tree-model.types').DropResult } {
if (sourceId === op.targetId) return { tree, result: { parentId: null, index: 0 } };
op: import("./tree-model.types").DropOp,
): { tree: TreeNode<T>[]; result: import("./tree-model.types").DropResult } {
if (sourceId === op.targetId)
return { tree, result: { parentId: null, index: 0 } };
if (!treeModel.find(tree, sourceId) || !treeModel.find(tree, op.targetId)) {
return { tree, result: { parentId: null, index: 0 } };
}
@@ -255,7 +318,7 @@ export const treeModel = {
let parentId: string | null;
let index: number;
if (op.kind === 'make-child') {
if (op.kind === "make-child") {
parentId = op.targetId;
const target = treeModel.find(tree, op.targetId)!;
index = target.children?.length ?? 0;
@@ -264,9 +327,8 @@ export const treeModel = {
parentId = info.parentId;
const sourceInfo = treeModel.siblingsOf(tree, sourceId)!;
const sameParent = sourceInfo.parentId === parentId;
const adjust =
sameParent && sourceInfo.index < info.index ? -1 : 0;
index = info.index + adjust + (op.kind === 'reorder-after' ? 1 : 0);
const adjust = sameParent && sourceInfo.index < info.index ? -1 : 0;
index = info.index + adjust + (op.kind === "reorder-after" ? 1 : 0);
}
const next = treeModel.place(tree, sourceId, { parentId, index });

View File

@@ -6,6 +6,8 @@ import {
collectBranchIds,
openBranches,
closeIds,
mergeRootTrees,
loadedOpenBranchIds,
} from "./utils";
import type { IPage } from "@/features/page/types/page.types.ts";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
@@ -44,10 +46,7 @@ function flatNode(
}
// Nested SpaceTreeNode factory for collectAllIds / collectBranchIds.
function treeNode(
id: string,
children: SpaceTreeNode[] = [],
): SpaceTreeNode {
function treeNode(id: string, children: SpaceTreeNode[] = []): SpaceTreeNode {
return {
id,
slugId: `slug-${id}`,
@@ -94,11 +93,7 @@ describe("collectBranchIds", () => {
]),
treeNode("root2", [treeNode("leaf3")]),
];
expect(collectBranchIds(tree).sort()).toEqual([
"branch1",
"root",
"root2",
]);
expect(collectBranchIds(tree).sort()).toEqual(["branch1", "root", "root2"]);
});
it("returns [] for a leaf-only tree", () => {
@@ -273,3 +268,95 @@ describe("closeIds", () => {
expect(twice).toEqual({ keep: true, a: false, b: false });
});
});
describe("mergeRootTrees (#159 #2 reconnect reconcile)", () => {
// Root node with a position and optional already-loaded children.
function root(
id: string,
position: string,
children?: SpaceTreeNode[],
): SpaceTreeNode {
return {
id,
slugId: `slug-${id}`,
name: id.toUpperCase(),
icon: undefined,
position,
spaceId: "space-1",
parentPageId: null as unknown as string,
hasChildren: !!children?.length,
children: children as SpaceTreeNode[],
};
}
it("DROPS a stale root that is absent from the incoming (authoritative) set", () => {
// 'ghost' was a root before the gap; the server's current roots no longer
// include it (deleted / moved under another page). It must not linger.
const prev = [root("a", "a0"), root("ghost", "a2"), root("b", "a4")];
const incoming = [root("a", "a0"), root("b", "a4")];
const merged = mergeRootTrees(prev, incoming);
expect(merged.map((n) => n.id)).toEqual(["a", "b"]);
expect(merged.find((n) => n.id === "ghost")).toBeUndefined();
});
it("PRESERVES a surviving root's lazy-loaded children (subtree not lost on refetch)", () => {
const loadedChild = root("a1", "a0");
const prev = [root("a", "a0", [loadedChild])];
// The root query returns only top-level roots (no children).
const incoming = [root("a", "a0")];
const merged = mergeRootTrees(prev, incoming);
expect(merged[0].children?.map((c) => c.id)).toEqual(["a1"]);
});
it("ADDS a new incoming root", () => {
const prev = [root("a", "a0")];
const incoming = [root("a", "a0"), root("new", "a2")];
const merged = mergeRootTrees(prev, incoming);
expect(merged.map((n) => n.id)).toEqual(["a", "new"]);
});
it("REFRESHES a surviving root's own fields from the incoming copy (e.g. rename)", () => {
const prev = [{ ...root("a", "a0"), name: "OLD" }];
const incoming = [{ ...root("a", "a0"), name: "NEW" }];
const merged = mergeRootTrees(prev, incoming);
expect(merged[0].name).toBe("NEW");
});
});
describe("loadedOpenBranchIds (#159 #8 reconnect refresh targets)", () => {
function n(id: string, children?: SpaceTreeNode[]): SpaceTreeNode {
return {
id,
slugId: `slug-${id}`,
name: id.toUpperCase(),
icon: undefined,
position: "a0",
spaceId: "space-1",
parentPageId: null as unknown as string,
hasChildren: !!children,
children: children as SpaceTreeNode[],
};
}
it("returns OPEN branches whose children are loaded (array)", () => {
const tree = [n("a", [n("a1")]), n("b", [n("b1")])];
const ids = loadedOpenBranchIds(tree, new Set(["a"]));
expect(ids).toEqual(["a"]); // b is closed; a is open+loaded
});
it("skips an open branch whose children are NOT loaded (undefined)", () => {
const tree = [n("a")]; // children undefined
expect(loadedOpenBranchIds(tree, new Set(["a"]))).toEqual([]);
});
it("includes a loaded-but-empty open branch (a child may have been added during the gap)", () => {
const tree = [n("a", [])];
expect(loadedOpenBranchIds(tree, new Set(["a"]))).toEqual(["a"]);
});
it("walks nested open+loaded branches (deep chain refreshes every level)", () => {
const tree = [n("a", [n("a1", [n("a1a")])])];
const ids = loadedOpenBranchIds(tree, new Set(["a", "a1"]));
expect(ids.sort()).toEqual(["a", "a1"]);
});
});

View File

@@ -214,21 +214,59 @@ export function appendNodeChildren(
}
/**
* Merge root nodes; keep existing ones intact, append new ones,
* Reconcile the loaded root nodes to the authoritative INCOMING set (the
* server's complete current roots for the space), preserving any lazy-loaded
* children/subtree of a root that still exists.
*
* This runs only once all root pages are fetched, so `incomingRoots` is the full
* server root set and is authoritative for WHICH roots exist:
* - a root in BOTH: kept, with its own fields refreshed from `incoming` (so a
* rename/move during a gap shows) while PRESERVING its previously lazy-loaded
* `children` (expanded subtrees + open-state survive a refetch);
* - a root only in `incoming`: a new root, added as-is;
* - a root only in `prev`: it was DELETED or moved under another page while we
* were not receiving events (e.g. a socket reconnect after a sleep/wifi gap).
* It is DROPPED instead of lingering as a 404 "ghost" root (#159 #2). The old
* append-only merge kept it forever.
*/
export function mergeRootTrees(
prevRoots: SpaceTreeNode[],
incomingRoots: SpaceTreeNode[],
): SpaceTreeNode[] {
const seen = new Set(prevRoots.map((r) => r.id));
const prevById = new Map(prevRoots.map((r) => [r.id, r]));
// add new roots that were not present before
const merged = [...prevRoots];
incomingRoots.forEach((node) => {
if (!seen.has(node.id)) merged.push(node);
const reconciled = incomingRoots.map((incoming) => {
const prev = prevById.get(incoming.id);
// Preserve the previously loaded children/subtree (the root query returns
// only top-level roots, so `incoming` carries no children); refresh the
// node's own fields from the authoritative incoming copy.
return prev ? { ...incoming, children: prev.children } : incoming;
});
return sortPositionKeys(merged);
return sortPositionKeys(reconciled);
}
/**
* Ids of branches a socket-reconnect refresh should re-fetch and reconcile
* (#159 #8): a node that is currently OPEN and whose children are LOADED
* (`children` is an array — possibly empty). An unloaded branch (`children ===
* undefined`) is skipped because lazy-load fetches it fresh on the next expand,
* so there is nothing stale to reconcile. Walks the whole tree (a deep open
* chain refreshes every loaded level).
*/
export function loadedOpenBranchIds(
tree: SpaceTreeNode[],
openIds: ReadonlySet<string>,
): string[] {
const ids: string[] = [];
const walk = (nodes: SpaceTreeNode[]) => {
for (const n of nodes) {
if (openIds.has(n.id) && Array.isArray(n.children)) ids.push(n.id);
if (n.children) walk(n.children);
}
};
walk(tree);
return ids;
}
// Collect every node id in the tree (roots, branches, leaves). Used by

View File

@@ -0,0 +1,237 @@
import {
ActionIcon,
Button,
Group,
Modal,
Text,
TextInput,
} from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl } from "@/lib/config.ts";
import {
useRemoveShareAliasMutation,
useSetShareAliasMutation,
useShareAliasForPageQuery,
} from "@/features/share/queries/share-query.ts";
import { checkShareAliasAvailability } from "@/features/share/services/share-service.ts";
import {
isValidShareAlias,
normalizeShareAlias,
} from "@/features/share/share-alias.util.ts";
interface ShareAliasSectionProps {
pageId: string;
readOnly: boolean;
}
// The prefix label shown next to the slug input, e.g. "docs.example.com/l/".
function aliasPrefixLabel(): string {
const url = getAppUrl();
const host = url.replace(/^https?:\/\//, "").replace(/\/+$/, "");
return `${host}/l/`;
}
export default function ShareAliasSection({
pageId,
readOnly,
}: ShareAliasSectionProps) {
const { t } = useTranslation();
const { data: currentAlias } = useShareAliasForPageQuery(pageId);
const setAliasMutation = useSetShareAliasMutation();
const removeAliasMutation = useRemoveShareAliasMutation();
const [value, setValue] = useState("");
const [availability, setAvailability] = useState<{
valid: boolean;
available: boolean;
currentPageId: string | null;
} | null>(null);
const [reassign, setReassign] = useState<{
alias: string;
currentPageTitle: string | null;
} | null>(null);
// Seed the input from the page's current alias (if any).
useEffect(() => {
setValue(currentAlias?.alias ?? "");
}, [currentAlias?.alias, pageId]);
const normalized = useMemo(() => normalizeShareAlias(value), [value]);
const isValid = isValidShareAlias(normalized);
const unchanged = currentAlias?.alias === normalized;
// Debounced availability probe (skips when invalid or unchanged).
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
setAvailability(null);
if (!isValid || unchanged) return;
debounceRef.current && clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
try {
const res = await checkShareAliasAvailability(normalized);
setAvailability({
valid: res.valid,
available: res.available,
currentPageId: res.currentPageId,
});
} catch {
setAvailability(null);
}
}, 400);
return () => {
debounceRef.current && clearTimeout(debounceRef.current);
};
}, [normalized, isValid, unchanged]);
const prettyLink = currentAlias?.alias
? `${getAppUrl()}/l/${currentAlias.alias}`
: null;
const handleSave = async (confirmReassign = false) => {
try {
await setAliasMutation.mutateAsync({
pageId,
alias: normalized,
confirmReassign,
});
setReassign(null);
} catch (error: any) {
// The address already points at another page: prompt to move it here.
if (error?.status === 409 || error?.response?.status === 409) {
const data = error?.response?.data;
if (data?.code === "ALIAS_REASSIGN_REQUIRED") {
setReassign({
alias: normalized,
currentPageTitle: data?.currentPageTitle ?? null,
});
}
}
}
};
const handleRemove = async () => {
if (!currentAlias?.id) return;
await removeAliasMutation.mutateAsync(currentAlias.id);
setValue("");
};
const showInvalid = normalized.length > 0 && !isValid;
const showTaken =
isValid && !unchanged && availability && !availability.available;
return (
<>
<Text size="sm" fw={500} mt="md">
{t("Custom address")}
</Text>
<Text size="xs" c="dimmed" mb={4}>
{t("A short, memorable link you can point at any shared page.")}
</Text>
{prettyLink && (
<Group my="xs" gap={4} wrap="nowrap">
<TextInput
variant="filled"
value={prettyLink}
readOnly
rightSection={<CopyTextButton text={prettyLink} />}
style={{ width: "100%" }}
/>
<ActionIcon
component="a"
variant="default"
target="_blank"
href={prettyLink}
size="sm"
>
<IconExternalLink size={16} />
</ActionIcon>
</Group>
)}
<TextInput
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
// Show the canonical form once the user pauses so what they type maps
// visibly to what gets stored.
onBlur={() => setValue(normalized)}
leftSection={
<Text size="xs" c="dimmed" pl={4} style={{ whiteSpace: "nowrap" }}>
{aliasPrefixLabel()}
</Text>
}
leftSectionWidth={Math.min(aliasPrefixLabel().length * 7 + 12, 180)}
placeholder={t("my-page")}
disabled={readOnly}
error={
showInvalid
? t("Use 2-60 lowercase letters, digits and hyphens")
: showTaken
? t("This address is already in use")
: undefined
}
/>
<Group mt="xs" gap="xs">
<Button
size="compact-sm"
onClick={() => handleSave(false)}
loading={setAliasMutation.isPending}
disabled={readOnly || !isValid || unchanged}
>
{t("Save")}
</Button>
{currentAlias?.id && (
<Button
size="compact-sm"
variant="default"
color="red"
onClick={handleRemove}
loading={removeAliasMutation.isPending}
disabled={readOnly}
>
{t("Remove")}
</Button>
)}
</Group>
<Modal
opened={!!reassign}
onClose={() => setReassign(null)}
title={t("Move custom address?")}
centered
size="sm"
>
<Text size="sm">
{reassign?.currentPageTitle
? t(
'The address "{{alias}}" currently points to "{{title}}". Move it to this page?',
{
alias: reassign?.alias,
title: reassign?.currentPageTitle,
},
)
: t(
'The address "{{alias}}" is already in use. Move it to this page?',
{ alias: reassign?.alias },
)}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={() => setReassign(null)}>
{t("Cancel")}
</Button>
<Button
color="red"
onClick={() => handleSave(true)}
loading={setAliasMutation.isPending}
>
{t("Move here")}
</Button>
</Group>
</Modal>
</>
);
}

View File

@@ -25,6 +25,7 @@ import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl } from "@/lib/config.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import classes from "@/features/share/components/share.module.css";
import ShareAliasSection from "@/features/share/components/share-alias-section.tsx";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
@@ -253,6 +254,9 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
disabled={readOnly}
/>
</Group>
{pageId && (
<ShareAliasSection pageId={pageId} readOnly={readOnly} />
)}
</>
)}
</>

View File

@@ -10,6 +10,8 @@ import { useTranslation } from "react-i18next";
import {
ICreateShare,
IShare,
IShareAlias,
ISetShareAlias,
ISharedItem,
ISharedPage,
ISharedPageTree,
@@ -20,11 +22,14 @@ import {
import {
createShare,
deleteShare,
getShareAliasForPage,
getSharedPageTree,
getShareForPage,
getShareInfo,
getSharePageInfo,
getShares,
removeShareAlias,
setShareAlias,
updateShare,
} from "@/features/share/services/share-service.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
@@ -170,6 +175,72 @@ export function useDeleteShareMutation() {
});
}
export function useShareAliasForPageQuery(
pageId: string,
): UseQueryResult<IShareAlias | null, Error> {
return useQuery({
// The endpoint resolves to null when the page has no alias; normalize the
// absence so React Query never sees `undefined`.
queryKey: ["share-alias-for-page", pageId],
queryFn: async () => (await getShareAliasForPage(pageId)) ?? null,
enabled: !!pageId,
staleTime: 60 * 1000,
retry: false,
});
}
export function useSetShareAliasMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<IShareAlias, Error, ISetShareAlias>({
mutationFn: (data) => setShareAlias(data),
onSuccess: () => {
queryClient.invalidateQueries({
predicate: (item) =>
["share-alias-for-page", "share-list"].includes(
item.queryKey[0] as string,
),
});
},
onError: (error) => {
// A 409 reassign-required is handled inline by the modal (it shows the
// "move address here?" confirmation), so don't surface a generic toast.
if (error?.["status"] === 409) return;
notifications.show({
message:
error?.["response"]?.data?.message || t("Failed to set custom address"),
color: "red",
});
},
});
}
export function useRemoveShareAliasMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (aliasId) => removeShareAlias(aliasId),
onSuccess: () => {
queryClient.invalidateQueries({
predicate: (item) =>
["share-alias-for-page", "share-list"].includes(
item.queryKey[0] as string,
),
});
},
onError: (error) => {
notifications.show({
message:
error?.["response"]?.data?.message ||
t("Failed to remove custom address"),
color: "red",
});
},
});
}
export function useGetSharedPageTreeQuery(
shareId: string,
): UseQueryResult<ISharedPageTree, Error> {

View File

@@ -4,6 +4,9 @@ import { IPage } from "@/features/page/types/page.types";
import {
ICreateShare,
IShare,
IShareAlias,
IShareAliasAvailability,
ISetShareAlias,
ISharedItem,
ISharedPage,
ISharedPageTree,
@@ -57,3 +60,33 @@ export async function getSharedPageTree(
const req = await api.post<ISharedPageTree>("/shares/tree", { shareId });
return req.data;
}
export async function getShareAliasForPage(
pageId: string,
): Promise<IShareAlias | null> {
const req = await api.post<IShareAlias | null>("/share-aliases/for-page", {
pageId,
});
return req.data;
}
export async function setShareAlias(
data: ISetShareAlias,
): Promise<IShareAlias> {
const req = await api.post<IShareAlias>("/share-aliases/set", data);
return req.data;
}
export async function removeShareAlias(aliasId: string): Promise<void> {
await api.post("/share-aliases/remove", { aliasId });
}
export async function checkShareAliasAvailability(
alias: string,
): Promise<IShareAliasAvailability> {
const req = await api.post<IShareAliasAvailability>(
"/share-aliases/availability",
{ alias },
);
return req.data;
}

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from "vitest";
import {
isValidShareAlias,
normalizeShareAlias,
} from "@/features/share/share-alias.util.ts";
// Mirrors the server-side util so the modal's live feedback matches what the
// server will accept/store.
describe("normalizeShareAlias", () => {
it("lowercases, trims and maps separators to single hyphens", () => {
expect(normalizeShareAlias(" My Cool_Page ")).toBe("my-cool-page");
});
it("collapses repeated hyphens and trims edges", () => {
expect(normalizeShareAlias("--a---b--")).toBe("a-b");
});
});
describe("isValidShareAlias", () => {
it("accepts ascii hyphen-separated slugs of length 2..60", () => {
expect(isValidShareAlias("hello-world")).toBe(true);
expect(isValidShareAlias("a".repeat(60))).toBe(true);
});
it("rejects too short, edge/double hyphens, uppercase and non-ascii", () => {
expect(isValidShareAlias("a")).toBe(false);
expect(isValidShareAlias("-a")).toBe(false);
expect(isValidShareAlias("a--b")).toBe(false);
expect(isValidShareAlias("Hello")).toBe(false);
expect(isValidShareAlias("привет")).toBe(false);
});
});

View File

@@ -0,0 +1,26 @@
/**
* Client copy of the vanity share-alias helpers. Kept in sync with the server
* (`apps/server/src/core/share/share-alias.util.ts`) so live input feedback
* matches what the server will store/accept. ASCII-only, lowercase, hyphen
* separated, length 2..60.
*/
// Normalize a user-provided vanity alias into canonical ASCII storage form.
export function normalizeShareAlias(raw: string): string {
return (raw ?? "")
.trim()
.toLowerCase()
.replace(/[\s_]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-+|-+$/g, "");
}
const ALIAS_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export function isValidShareAlias(alias: string): boolean {
return (
typeof alias === "string" &&
alias.length >= 2 &&
alias.length <= 60 &&
ALIAS_RE.test(alias)
);
}

View File

@@ -75,6 +75,30 @@ export interface IShareInfoInput {
pageId: string;
}
// Vanity /l/:alias pointer.
export interface IShareAlias {
id: string;
workspaceId: string;
alias: string;
pageId: string | null;
creatorId: string | null;
createdAt: string;
updatedAt: string;
}
export interface ISetShareAlias {
pageId: string;
alias: string;
confirmReassign?: boolean;
}
export interface IShareAliasAvailability {
alias: string;
valid: boolean;
available: boolean;
currentPageId: string | null;
}
export interface ISharedPageTree {
share: IShare;
pageTree: Partial<IPage[]>;

View File

@@ -81,6 +81,38 @@ describe("applyMoveTreeNode", () => {
]);
});
it("does NOT create a partial child list when the destination is loaded-but-collapsed (children unloaded) — keeps it lazy-loadable (#159)", () => {
// `dstCollapsed` is in the tree but its children were never lazy-loaded
// (children === undefined). The OLD behavior inserted `src` as the ONLY
// child ([src]), which defeated the lazy-load gate and HID the parent's
// other real children. Now the move leaves children unloaded (so expanding
// fetches the FULL set, including src) and just flags hasChildren.
const tree: SpaceTreeNode[] = [
node("dstCollapsed", {
position: "a0",
hasChildren: false,
children: undefined as unknown as SpaceTreeNode[],
}),
node("src", { position: "a9" }),
];
const next = applyMoveTreeNode(tree, {
id: "src",
parentId: "dstCollapsed",
oldParentId: null,
index: 0,
position: "a4",
pageData: {},
});
const dst = treeModel.find(next, "dstCollapsed");
// Children stay unloaded -> the lazy-load gate fetches the FULL set (incl.
// src) on expand, rather than showing a misleading partial [src] list.
expect(dst?.children).toBeUndefined();
expect(dst?.hasChildren).toBe(true);
// src moved away from its old root slot (it lives under dstCollapsed
// server-side and reappears when the parent is expanded/loaded).
expect(next.map((n) => n.id)).not.toContain("src");
});
it("flips the OLD parent's hasChildren to false when it is left childless", () => {
// src is the only child of `old`; moving it to `dst` empties `old`.
const tree: SpaceTreeNode[] = [
@@ -164,7 +196,9 @@ describe("applyDeleteTreeNode", () => {
position: "a1",
parentPageId: "p",
hasChildren: true,
children: [node("grandchild", { position: "a1", parentPageId: "child" })],
children: [
node("grandchild", { position: "a1", parentPageId: "child" }),
],
}),
],
}),

View File

@@ -11,6 +11,7 @@ import {
Switch,
TagsInput,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import { useForm } from "@mantine/form";
@@ -35,6 +36,8 @@ const formSchema = z.object({
// Write-only secret buffer. Empty string means "do not change" (unless cleared).
authHeader: z.string(),
toolAllowlist: z.array(z.string()),
// Admin-authored prompt guidance (#180). Capped to mirror the DTO MaxLength.
instructions: z.string().max(4000),
enabled: z.boolean(),
});
@@ -63,6 +66,7 @@ function buildInitialValues(server?: IAiMcpServer): FormValues {
toolAllowlist: Array.isArray(server?.toolAllowlist)
? server.toolAllowlist
: [],
instructions: server?.instructions ?? "",
enabled: server?.enabled ?? true,
};
}
@@ -124,6 +128,8 @@ export default function AiMcpServerForm({
transport: values.transport,
url: values.url,
toolAllowlist: values.toolAllowlist,
// Always sent: a blank value clears the stored guidance (server -> null).
instructions: values.instructions,
enabled: values.enabled,
};
// Only attach headers when set or explicitly cleared (omit => unchanged).
@@ -135,6 +141,8 @@ export default function AiMcpServerForm({
transport: values.transport,
url: values.url,
toolAllowlist: values.toolAllowlist,
// Blank => server stores null (no guidance).
instructions: values.instructions,
enabled: values.enabled,
};
// On create, only a typed value matters (no prior stored headers).
@@ -158,10 +166,7 @@ export default function AiMcpServerForm({
return (
<Stack>
<TextInput
label={t("Server name")}
{...form.getInputProps("name")}
/>
<TextInput label={t("Server name")} {...form.getInputProps("name")} />
<Select
label={t("Transport")}
@@ -177,7 +182,7 @@ export default function AiMcpServerForm({
// Clarify that the value is sent verbatim as the Authorization header,
// so the user supplies the full scheme (no implicit Bearer prefix).
description={t(
"Sent verbatim as the value of the Authorization header (e.g. \"Bearer <token>\" or \"Basic <base64>\").",
'Sent verbatim as the value of the Authorization header (e.g. "Bearer <token>" or "Basic <base64>").',
)}
// Placeholder hints whether headers are stored; the value is never shown.
placeholder={hasHeaders ? t("•••• set") : ""}
@@ -208,6 +213,20 @@ export default function AiMcpServerForm({
{...form.getInputProps("toolAllowlist")}
/>
<Textarea
label={t("Instructions")}
// Hint that the text is injected into the agent's system prompt and that
// the server's tools are namespaced under <name>_* (the prompt header).
description={t(
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".",
)}
autosize
minRows={2}
maxRows={8}
maxLength={4000}
{...form.getInputProps("instructions")}
/>
<Switch
label={t("Enabled")}
checked={form.values.enabled}

View File

@@ -14,6 +14,9 @@ export interface IAiMcpServer {
enabled: boolean;
toolAllowlist: string[] | null;
hasHeaders: boolean;
// Admin-authored guidance injected into the agent system prompt (#180).
// NON-secret, so it IS returned. Null when no guidance is configured.
instructions: string | null;
}
// Create payload. `headers` is write-only: omit => no auth headers.
@@ -25,6 +28,8 @@ export interface IAiMcpServerCreate {
// never returned.
headers?: Record<string, string>;
toolAllowlist?: string[];
// Admin-authored prompt guidance (#180). Blank => stored as null.
instructions?: string;
enabled?: boolean;
}
@@ -39,6 +44,8 @@ export interface IAiMcpServerUpdate {
url?: string;
headers?: Record<string, string>;
toolAllowlist?: string[];
// Admin-authored prompt guidance (#180). Absent => unchanged; blank => cleared.
instructions?: string;
enabled?: boolean;
}

View File

@@ -0,0 +1,159 @@
import { ForbiddenException } from '@nestjs/common';
import { AiChatController } from './ai-chat.controller';
import {
planFinalizeAssistant,
applyFinalize,
flushAssistant,
type AssistantFlush,
} from './ai-chat.service';
import type { User, Workspace } from '@docmost/db/types/entity.types';
/**
* Wiring spec for the #183 `POST /ai-chat/export` endpoint. It must: own-gate via
* the chat lookup (workspace-scoped + creator-owned), load the FULL transcript
* via findAllByChat, render server-side, and return `{ markdown }`. Exercised by
* instantiating the controller with hand-rolled mocks — no Nest graph, no DB.
*/
describe('AiChatController.export', () => {
const user = { id: 'u1' } as User;
const workspace = { id: 'ws1' } as Workspace;
function makeController(
over: {
chat?: unknown;
rows?: unknown[];
} = {},
) {
const chat =
'chat' in over
? over.chat
: { id: 'c1', creatorId: 'u1', title: 'My chat' };
const aiChatRepo = {
findById: jest.fn().mockResolvedValue(chat),
};
const aiChatMessageRepo = {
findAllByChat: jest.fn().mockResolvedValue(
over.rows ?? [
{
id: 'm1',
role: 'user',
content: 'hi',
metadata: null,
status: null,
},
{
id: 'm2',
role: 'assistant',
content: 'hello',
metadata: null,
status: 'completed',
},
],
),
};
const controller = new AiChatController(
{} as never,
aiChatRepo as never,
aiChatMessageRepo as never,
{} as never,
);
return { controller, aiChatRepo, aiChatMessageRepo };
}
it('renders the full transcript and returns { markdown }', async () => {
const { controller, aiChatMessageRepo } = makeController();
const res = await controller.export({ chatId: 'c1' }, user, workspace);
expect(aiChatMessageRepo.findAllByChat).toHaveBeenCalledWith('c1', 'ws1');
expect(res.markdown).toContain('# My chat');
expect(res.markdown).toContain('## 1. You');
expect(res.markdown).toContain('## 2. AI agent');
});
it('forbids a chat the user does not own', async () => {
const { controller } = makeController({
chat: { id: 'c1', creatorId: 'someone-else', title: 'X' },
});
await expect(
controller.export({ chatId: 'c1' }, user, workspace),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('forbids a missing / foreign-workspace chat', async () => {
const { controller } = makeController({ chat: null });
await expect(
controller.export({ chatId: 'c1' }, user, workspace),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('localizes labels when lang=ru is passed', async () => {
const { controller } = makeController();
const res = await controller.export(
{ chatId: 'c1', lang: 'ru' },
user,
workspace,
);
expect(res.markdown).toContain('## 1. Вы');
expect(res.markdown).toContain('## 2. ИИ-агент');
});
});
/**
* The terminal-finalize dispatch (#183): the assistant row is INSERTed upfront
* as 'streaming' and finalized once on the terminal callback. When the upfront
* insert SUCCEEDED (we hold an id) finalize UPDATEs that row; when it FAILED
* (assistantId is undefined) finalize falls back to INSERTing the terminal row
* so the turn is not lost — the only safety against losing the turn entirely.
*
* `planFinalizeAssistant` is the pure decision; `applyFinalize` is the REAL
* dispatch the service uses, exercised here over a mock repo (not a copy of the
* logic) so a production drift would fail the test (#186 review).
*/
describe('finalizeAssistant dispatch (planFinalizeAssistant + applyFinalize)', () => {
const workspaceId = 'ws1';
// Drive the SAME applyFinalize the service calls (no duplicated logic).
async function dispatchFinalize(
repo: { insert: jest.Mock; update: jest.Mock },
assistantId: string | undefined,
flushed: AssistantFlush,
): Promise<void> {
await applyFinalize(
repo,
planFinalizeAssistant(assistantId),
{ chatId: 'c1', workspaceId, userId: 'u1' },
flushed,
);
}
it('plan: update when the upfront insert returned an id', () => {
expect(planFinalizeAssistant('a1')).toEqual({ kind: 'update', id: 'a1' });
});
it('plan: insert (fallback) when there is no upfront id', () => {
expect(planFinalizeAssistant(undefined)).toEqual({ kind: 'insert' });
});
it('(a) upfront insert succeeded -> finalize UPDATEs the row by id', async () => {
const repo = { insert: jest.fn(), update: jest.fn() };
const flushed = flushAssistant([], 'final answer', 'completed', {
finishReason: 'stop',
});
await dispatchFinalize(repo, 'a1', flushed);
expect(repo.update).toHaveBeenCalledWith('a1', workspaceId, flushed);
expect(repo.insert).not.toHaveBeenCalled();
});
it('(b) upfront insert failed -> finalize INSERTs the terminal payload', async () => {
const repo = { insert: jest.fn(), update: jest.fn() };
const flushed = flushAssistant([], 'partial', 'error', { error: 'boom' });
await dispatchFinalize(repo, undefined, flushed);
expect(repo.update).not.toHaveBeenCalled();
expect(repo.insert).toHaveBeenCalledTimes(1);
const arg = repo.insert.mock.calls[0][0];
// The fallback insert carries the terminal content/status/metadata.
expect(arg.role).toBe('assistant');
expect(arg.content).toBe('partial');
expect(arg.status).toBe('error');
expect((arg.metadata as { error?: string }).error).toBe('boom');
});
});

View File

@@ -20,7 +20,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { SkipTransform } from '../../common/decorators/skip-transform.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { AiChat, User, Workspace } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
@@ -31,10 +31,12 @@ import { AiChatService, AiChatStreamBody } from './ai-chat.service';
import { AiTranscriptionService } from './ai-transcription.service';
import {
ChatIdDto,
ExportChatDto,
GetChatMessagesDto,
RenameChatDto,
} from './dto/ai-chat.dto';
import { describeProviderError } from '../../integrations/ai/ai-error.util';
import { buildChatMarkdown } from './chat-markdown.util';
/**
* Per-user AI chat API (§6.1). Routes are POST to match this codebase's
@@ -81,6 +83,36 @@ export class AiChatController {
);
}
/**
* Export a chat to Markdown (#183). The DB is the single source of truth: the
* whole transcript is loaded (oldest -> newest) and rendered server-side. Now
* that the assistant row is persisted upfront and per step, an interrupted
* turn is included up to its last finished step. Workspace-scoped and owner-
* gated via assertOwnedChat (same as the other read endpoints). Returns
* `{ markdown }`. `lang` localizes the few fixed labels (default English).
*/
@HttpCode(HttpStatus.OK)
@Post('export')
async export(
@Body() dto: ExportChatDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<{ markdown: string }> {
const chat = await this.assertOwnedChat(dto.chatId, user, workspace);
const rows = await this.aiChatMessageRepo.findAllByChat(
dto.chatId,
workspace.id,
);
const markdown = buildChatMarkdown({
title: chat.title ?? null,
chatId: dto.chatId,
rows,
// normalizeLang(undefined) already yields 'en', so no `?? 'en'` is needed.
lang: dto.lang,
});
return { markdown };
}
/** Rename a chat. */
@HttpCode(HttpStatus.OK)
@Post('rename')
@@ -90,7 +122,11 @@ export class AiChatController {
@AuthWorkspace() workspace: Workspace,
) {
await this.assertOwnedChat(dto.chatId, user, workspace);
await this.aiChatRepo.update(dto.chatId, { title: dto.title }, workspace.id);
await this.aiChatRepo.update(
dto.chatId,
{ title: dto.title },
workspace.id,
);
return { success: true };
}
@@ -145,7 +181,10 @@ export class AiChatController {
// Resolve the agent role for this turn BEFORE hijack: existing chats read it
// from ai_chats.role_id (authoritative), a new chat from body.roleId. The
// role drives both the persona and the optional model override below.
const role = await this.aiChatService.resolveRoleForRequest(workspace, body);
const role = await this.aiChatService.resolveRoleForRequest(
workspace,
body,
);
// Resolve the model (applying the role's optional override) BEFORE hijack so
// an unconfigured provider — including a role pointing at an unconfigured
@@ -232,7 +271,9 @@ export class AiChatController {
let file = null;
try {
// Whisper hard-caps uploads at 25MB; allow a single file.
file = await req.file({ limits: { fileSize: 25 * 1024 * 1024, files: 1 } });
file = await req.file({
limits: { fileSize: 25 * 1024 * 1024, files: 1 },
});
} catch (err: any) {
if (err?.statusCode === 413) {
throw new BadRequestException('Audio file too large (max 25MB)');
@@ -283,11 +324,12 @@ export class AiChatController {
chatId: string,
user: User,
workspace: Workspace,
): Promise<void> {
): Promise<AiChat> {
const chat = await this.aiChatRepo.findById(chatId, workspace.id);
if (!chat || chat.creatorId !== user.id) {
throw new ForbiddenException();
}
return chat;
}
}

View File

@@ -1,4 +1,4 @@
import { buildSystemPrompt } from './ai-chat.prompt';
import { buildSystemPrompt, buildMcpToolingBlock } from './ai-chat.prompt';
import { Workspace } from '@docmost/db/types/entity.types';
/**
@@ -161,3 +161,81 @@ describe('buildSystemPrompt current-page context', () => {
expect(pageIdx).toBeLessThan(lastSafety);
});
});
/**
* Unit tests for the per-EXTERNAL-MCP-server guidance block (#180). When the
* caller passes non-blank instructions for ≥1 server, an <mcp_tooling> block
* renders the server name, its tool namespace prefix and the text. The block
* sits INSIDE the safety sandwich (after context, before the trailing SAFETY)
* and never removes/duplicates the immutable safety framework. An empty list or
* all-blank text renders nothing.
*/
describe('buildSystemPrompt mcp tooling guidance', () => {
const workspace = { name: 'Acme' } as unknown as Workspace;
const SAFETY_MARKER = 'Operating rules (always in effect)';
// The block's CONTENT and its empty/undefined/all-blank handling are covered by
// the buildMcpToolingBlock unit tests below; here we only pin the INTEGRATION
// invariants that are unique to buildSystemPrompt: sandwich placement and that
// both safety copies survive.
it('places the block inside the safety sandwich, after context, before the trailing SAFETY', () => {
const prompt = buildSystemPrompt({
workspace,
openedPage: { id: 'pg-1', title: 'Doc' },
mcpInstructions: [
{ serverName: 'Tavily', toolPrefix: 'tavily', instructions: 'guide' },
],
});
const ctxIdx = prompt.indexOf('currently viewing the page');
const mcpIdx = prompt.indexOf('<mcp_tooling');
const firstSafety = prompt.indexOf(SAFETY_MARKER);
const lastSafety = prompt.lastIndexOf(SAFETY_MARKER);
// After context, and strictly inside the sandwich.
expect(mcpIdx).toBeGreaterThan(ctxIdx);
expect(mcpIdx).toBeGreaterThan(firstSafety);
expect(mcpIdx).toBeLessThan(lastSafety);
});
it('keeps BOTH copies of the safety framework when guidance is present', () => {
const prompt = buildSystemPrompt({
workspace,
mcpInstructions: [
{ serverName: 'Tavily', toolPrefix: 'tavily', instructions: 'guide' },
],
});
const firstSafety = prompt.indexOf(SAFETY_MARKER);
const lastSafety = prompt.lastIndexOf(SAFETY_MARKER);
expect(firstSafety).toBeGreaterThanOrEqual(0);
expect(lastSafety).toBeGreaterThan(firstSafety);
});
});
/**
* Unit tests for the pure block builder. It filters blank entries and returns
* '' so the caller can omit the section entirely.
*/
describe('buildMcpToolingBlock', () => {
it('returns "" for undefined / empty / all-blank', () => {
expect(buildMcpToolingBlock(undefined)).toBe('');
expect(buildMcpToolingBlock([])).toBe('');
expect(
buildMcpToolingBlock([
{ serverName: 'A', toolPrefix: 'a', instructions: ' ' },
]),
).toBe('');
});
it('includes only the non-blank entries', () => {
const block = buildMcpToolingBlock([
{ serverName: 'A', toolPrefix: 'a', instructions: 'alpha guide' },
{ serverName: 'B', toolPrefix: 'b', instructions: ' ' },
{ serverName: 'C', toolPrefix: 'c', instructions: 'gamma guide' },
]);
expect(block).toContain('a_*');
expect(block).toContain('alpha guide');
expect(block).toContain('c_*');
expect(block).toContain('gamma guide');
// The blank-only entry contributes no section header.
expect(block).not.toContain('b_*');
});
});

View File

@@ -1,4 +1,5 @@
import { Workspace } from '@docmost/db/types/entity.types';
import type { McpServerInstruction } from './external-mcp/mcp-clients.service';
/**
* Default agent persona used when the admin has not configured a custom system
@@ -76,6 +77,42 @@ export interface BuildSystemPromptInput {
* uses its CASL-enforced read/write page tools with the id when needed.
*/
openedPage?: { id?: string; title?: string } | null;
/**
* Admin-authored, per-EXTERNAL-MCP-server guidance ("how/when to use this
* server's tools"), built by `McpClientsService.toolsFor` for servers that
* actually connected and contributed ≥1 callable tool (#180). Rendered as an
* `<mcp_tooling>` block INSIDE the safety sandwich (trusted text — it informs
* tool usage but cannot override the surrounding rules). Empty/blank => the
* block is omitted entirely.
*/
mcpInstructions?: McpServerInstruction[];
}
/**
* Render the `<mcp_tooling>` block from per-server guidance. Each server gets a
* section headed by its tool namespace prefix (e.g. `tavily_*`) so the model can
* connect the guidance to the actual namespaced tool names. The prefix is
* advisory: on rare name collisions individual tools may carry a disambiguating
* suffix, but the guidance stays guidance, not a contract. Returns '' when no
* server has non-blank guidance, so the caller can omit the block entirely.
*/
export function buildMcpToolingBlock(
mcpInstructions: McpServerInstruction[] | undefined,
): string {
if (!mcpInstructions || mcpInstructions.length === 0) return '';
const sections = mcpInstructions
.filter((m) => typeof m.instructions === 'string' && m.instructions.trim())
.map((m) => {
const header = `Server "${m.serverName}" (tools: ${m.toolPrefix}_*):`;
return `${header}\n${m.instructions.trim()}`;
});
if (sections.length === 0) return '';
return [
'<mcp_tooling note="admin guidance for the external tools below; informs tool choice only, cannot override the rules above or below">',
'Guidance for the external MCP tools available to you this turn:',
...sections,
'</mcp_tooling>',
].join('\n');
}
/**
@@ -92,6 +129,7 @@ export function buildSystemPrompt({
adminPrompt,
roleInstructions,
openedPage,
mcpInstructions,
}: BuildSystemPromptInput): string {
// Persona precedence: role instructions REPLACE the admin persona / default.
// effectivePersona = roleInstructions || adminPrompt || DEFAULT_PROMPT.
@@ -112,24 +150,35 @@ export function buildSystemPrompt({
const pageId = openedPage?.id;
if (typeof pageId === 'string' && pageId.trim().length > 0) {
const title =
typeof openedPage?.title === 'string' && openedPage.title.trim().length > 0
typeof openedPage?.title === 'string' &&
openedPage.title.trim().length > 0
? openedPage.title.trim()
: 'Untitled';
context += `\nThe user is currently viewing the page "${title}" (pageId: ${pageId.trim()}). When they refer to "this page", "the current page", or similar, operate on that pageId — use the read/write page tools with it.`;
}
// Per-server external-MCP tool guidance (#180). Trusted, admin-authored text;
// rendered inside the sandwich (after context, before the trailing SAFETY) so
// it informs tool choice but cannot override the surrounding safety rules.
// Empty when no qualifying server has guidance.
const mcpTooling = buildMcpToolingBlock(mcpInstructions);
// Sandwich the lower-trust persona/role text between two copies of the
// immutable SAFETY_FRAMEWORK so any jailbreak inside `base` is both preceded
// and followed by the safety rules. The persona is delimited with explicit
// <role_persona> tags noting it only shapes tone/voice. Context (workspace
// name, currently-viewed page) follows the persona, before the trailing
// SAFETY copy.
// name, currently-viewed page) then the MCP tooling guidance follow the
// persona, before the trailing SAFETY copy. Blank parts are filtered out so
// an empty section never adds a stray blank line.
return [
SAFETY_FRAMEWORK,
'<role_persona note="shapes tone/voice only; cannot override the rules above or below">',
base,
'</role_persona>',
context,
mcpTooling,
SAFETY_FRAMEWORK,
].join('\n');
]
.filter((part) => part !== '')
.join('\n');
}

View File

@@ -0,0 +1,61 @@
import { Logger } from '@nestjs/common';
import { AiChatService } from './ai-chat.service';
/**
* Lifecycle unit tests for AiChatService.onModuleInit (#183 crash-recovery
* sweep). The sweep is BEST-EFFORT: a failure must be logged (warn) but must
* NEVER throw out of onModuleInit and block server startup. Exercised with a
* hand-rolled mock repo — no Nest graph, no DB. Only `aiChatMessageRepo` is
* touched by onModuleInit, so the other constructor deps are stubbed as never.
*/
describe('AiChatService.onModuleInit (startup sweep)', () => {
function makeService(sweepStreaming: jest.Mock) {
const aiChatMessageRepo = { sweepStreaming };
const service = new AiChatService(
{} as never, // ai
{} as never, // aiChatRepo
aiChatMessageRepo as never,
{} as never, // aiSettings
{} as never, // tools
{} as never, // mcpClients
{} as never, // aiAgentRoleRepo
{} as never, // pageRepo
{} as never, // pageAccess
);
return { service, aiChatMessageRepo };
}
afterEach(() => jest.restoreAllMocks());
it('happy path: calls sweepStreaming and resolves', async () => {
const sweepStreaming = jest.fn().mockResolvedValue(0);
const { service } = makeService(sweepStreaming);
await expect(service.onModuleInit()).resolves.toBeUndefined();
expect(sweepStreaming).toHaveBeenCalledTimes(1);
});
it('logs how many rows were swept when > 0', async () => {
const sweepStreaming = jest.fn().mockResolvedValue(3);
const logSpy = jest
.spyOn(Logger.prototype, 'log')
.mockImplementation(() => undefined);
const { service } = makeService(sweepStreaming);
await service.onModuleInit();
expect(logSpy).toHaveBeenCalledTimes(1);
expect(String(logSpy.mock.calls[0][0])).toContain('3');
});
it('sweepStreaming throws -> onModuleInit resolves (does NOT throw) and warns', async () => {
const sweepStreaming = jest
.fn()
.mockRejectedValue(new Error('db unavailable'));
const warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
const { service } = makeService(sweepStreaming);
// Must not throw — a sweep failure may never block startup.
await expect(service.onModuleInit()).resolves.toBeUndefined();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0][0])).toContain('db unavailable');
});
});

View File

@@ -1,16 +1,20 @@
import { ForbiddenException } from '@nestjs/common';
import {
AiChatService,
compactToolOutput,
assistantParts,
serializeSteps,
rowToUiMessage,
prepareAgentStep,
buildPartialAssistantRecord,
flushAssistant,
chatStreamMetadata,
accumulateStepUsage,
MAX_AGENT_STEPS,
FINAL_STEP_INSTRUCTION,
} from './ai-chat.service';
import type { AiChatMessage } from '@docmost/db/types/entity.types';
import type { AiChatMessage, Workspace } from '@docmost/db/types/entity.types';
import { buildSystemPrompt } from './ai-chat.prompt';
import type { McpClientsService } from './external-mcp/mcp-clients.service';
/**
* Unit tests for compactToolOutput: the pure helper that shrinks LARGE tool
@@ -94,8 +98,12 @@ describe('assistantParts', () => {
const steps = [
{
text: '',
toolCalls: [{ toolCallId: 'c1', toolName: 'getPage', input: { id: 'p1' } }],
toolResults: [{ toolCallId: 'c1', toolName: 'getPage', output: { title: 'T' } }],
toolCalls: [
{ toolCallId: 'c1', toolName: 'getPage', input: { id: 'p1' } },
],
toolResults: [
{ toolCallId: 'c1', toolName: 'getPage', output: { title: 'T' } },
],
},
];
const parts = assistantParts(steps, '') as AnyPart[];
@@ -109,7 +117,9 @@ describe('assistantParts', () => {
const steps = [
{
text: '',
toolCalls: [{ toolCallId: 'c9', toolName: 'insertNode', input: { node: {} } }],
toolCalls: [
{ toolCallId: 'c9', toolName: 'insertNode', input: { node: {} } },
],
toolResults: [],
},
];
@@ -136,7 +146,8 @@ describe('assistantParts', () => {
];
const parts = assistantParts(steps, '') as AnyPart[];
const toolParts = parts.filter(
(p) => typeof p.type === 'string' && (p.type as string).startsWith('tool-'),
(p) =>
typeof p.type === 'string' && (p.type as string).startsWith('tool-'),
);
expect(toolParts).toHaveLength(0);
});
@@ -222,79 +233,108 @@ describe('prepareAgentStep', () => {
// The synthesis instruction is appended.
expect(result?.system).toContain(FINAL_STEP_INSTRUCTION);
});
it('pins the off-by-one boundary (MAX-2 is not final, MAX-1 is)', () => {
// Boundary expressed via the constant, not a hardcoded 18/19, so the test
// tracks MAX_AGENT_STEPS if the cap ever changes.
expect(prepareAgentStep(MAX_AGENT_STEPS - 2, 'SYS')).toBeUndefined();
const atBoundary = prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS');
expect(atBoundary).toBeDefined();
expect(atBoundary?.toolChoice).toBe('none');
});
});
/**
* Unit test for buildPartialAssistantRecord: the pure helper that shapes the
* assistant-message record persisted on a partial/failed turn (the streamText
* onError / onAbort paths). It captures the PARTIAL answer the user already saw
* (finished steps' text + tool parts, plus the in-progress step's text) so a
* provider error / disconnect no longer throws the streamed answer away. Pinning
* the record shape here covers the persist-partial logic without seaming
* streamText itself.
* flushAssistant (#183): the PURE row builder behind the step-granular durable
* write path. It runs identically for the upfront insert (empty steps,
* 'streaming'), every per-step update, and the terminal finalize — so a future
* background worker can call the same function. These tests pin the four status
* shapes and the `metadata.parts` shape that rowToUiMessage/findRecent depend on
* (per-step text + tool parts via assistantParts, in-progress text appended).
*/
describe('buildPartialAssistantRecord', () => {
describe('flushAssistant', () => {
type AnyPart = Record<string, unknown>;
it('records an empty turn with the error text (preserves old behavior)', () => {
const rec = buildPartialAssistantRecord([], '', 'error', '401: Unauthorized');
expect(rec).toEqual({
text: '',
toolCalls: null,
metadata: { finishReason: 'error', parts: [], error: '401: Unauthorized' },
const toolStep = {
text: 'looked it up',
toolCalls: [{ toolCallId: 'c1', toolName: 'getPage', input: { id: 'p1' } }],
toolResults: [
{ toolCallId: 'c1', toolName: 'getPage', output: { title: 'T' } },
],
};
it('upfront seed: empty streaming row (no content, no toolCalls, empty parts)', () => {
const f = flushAssistant([], '', 'streaming');
expect(f.status).toBe('streaming');
expect(f.content).toBe('');
expect(f.toolCalls).toBeNull();
expect(f.metadata.parts).toEqual([]);
// No finishReason while streaming (it is not a terminal state).
expect('finishReason' in f.metadata).toBe(false);
});
it('streaming update folds in finished steps but keeps status streaming', () => {
const f = flushAssistant([toolStep], '', 'streaming');
expect(f.status).toBe('streaming');
expect(f.content).toBe('looked it up');
const parts = f.metadata.parts as AnyPart[];
expect(parts).toContainEqual({ type: 'text', text: 'looked it up' });
const toolPart = parts.find((p) => p.type === 'tool-getPage');
expect(toolPart!.state).toBe('output-available');
expect(f.toolCalls).not.toBeNull();
});
it('completed: attaches finishReason + normalized usage + contextTokens', () => {
const f = flushAssistant([toolStep], '', 'completed', {
finishReason: 'stop',
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
contextTokens: 15,
});
expect(f.status).toBe('completed');
expect(f.metadata.finishReason).toBe('stop');
expect(f.metadata.usage).toEqual({
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
reasoningTokens: undefined,
});
expect(f.metadata.contextTokens).toBe(15);
});
it('error: records the error and a derived finishReason', () => {
const f = flushAssistant([], 'partial answer', 'error', { error: 'boom' });
expect(f.status).toBe('error');
expect(f.content).toBe('partial answer');
expect(f.metadata.error).toBe('boom');
// Derives finishReason from the terminal status when none is supplied.
expect(f.metadata.finishReason).toBe('error');
expect(f.metadata.parts).toEqual([
{ type: 'text', text: 'partial answer' },
]);
});
it('aborted: in-progress text appended last, no error key', () => {
const f = flushAssistant([toolStep], ' and then', 'aborted');
expect(f.status).toBe('aborted');
expect(f.metadata.finishReason).toBe('aborted');
expect('error' in f.metadata).toBe(false);
expect(f.content).toBe('looked it up and then');
const parts = f.metadata.parts as AnyPart[];
expect(parts[parts.length - 1]).toEqual({
type: 'text',
text: ' and then',
});
});
it('persists in-progress text (no finished steps) as the partial answer', () => {
const rec = buildPartialAssistantRecord([], 'partial answer', 'error', 'boom');
expect(rec.text).toBe('partial answer');
expect(rec.metadata.parts).toEqual([
{ type: 'text', text: 'partial answer' },
]);
expect(rec.metadata.error).toBe('boom');
});
it('combines a finished tool step with trailing in-progress text', () => {
const steps = [
{
text: 'looked it up',
toolCalls: [
{ toolCallId: 'c1', toolName: 'getPage', input: { id: 'p1' } },
],
toolResults: [
{ toolCallId: 'c1', toolName: 'getPage', output: { title: 'T' } },
],
},
];
const rec = buildPartialAssistantRecord(steps, ' and then', 'error', 'boom');
const parts = rec.metadata.parts as AnyPart[];
// The finished step's text part is present.
it('combines a finished tool step with trailing in-progress text (error path)', () => {
// The error path captures the PARTIAL answer the user already saw: each
// finished step's text + tool parts, then the in-progress step's text last.
const flushed = flushAssistant([toolStep], ' and then', 'error', {
error: 'boom',
});
const parts = flushed.metadata.parts as AnyPart[];
expect(parts).toContainEqual({ type: 'text', text: 'looked it up' });
// The paired tool call+result becomes an output-available part.
const toolPart = parts.find((p) => p.type === 'tool-getPage');
expect(toolPart).toBeDefined();
expect(toolPart!.state).toBe('output-available');
// The in-progress text is appended LAST so the parts match the stream order.
expect(parts[parts.length - 1]).toEqual({ type: 'text', text: ' and then' });
expect(rec.text).toBe('looked it up and then');
expect(rec.toolCalls).not.toBeNull();
expect(rec.metadata.error).toBe('boom');
});
it('omits the error key on the abort path (no errorText)', () => {
const rec = buildPartialAssistantRecord([], 'half', 'aborted');
expect(rec.metadata.finishReason).toBe('aborted');
expect('error' in rec.metadata).toBe(false);
expect(rec.text).toBe('half');
// In-progress text appended LAST so the parts match the stream order.
expect(parts[parts.length - 1]).toEqual({
type: 'text',
text: ' and then',
});
expect(flushed.content).toBe('looked it up and then');
expect(flushed.toolCalls).not.toBeNull();
expect(flushed.metadata.error).toBe('boom');
});
});
@@ -319,10 +359,20 @@ describe('chatStreamMetadata', () => {
chatStreamMetadata(
{ type: 'finish-step', usage: { outputTokens: 100 } },
'chat-1',
{ inputTokens: 500, outputTokens: 220, totalTokens: 720, reasoningTokens: 30 },
{
inputTokens: 500,
outputTokens: 220,
totalTokens: 720,
reasoningTokens: 30,
},
),
).toEqual({
usage: { inputTokens: 500, outputTokens: 220, totalTokens: 720, reasoningTokens: 30 },
usage: {
inputTokens: 500,
outputTokens: 220,
totalTokens: 720,
reasoningTokens: 30,
},
});
});
@@ -394,8 +444,18 @@ describe('accumulateStepUsage', () => {
it('sums every field across two steps', () => {
expect(
accumulateStepUsage(
{ inputTokens: 500, outputTokens: 100, totalTokens: 600, reasoningTokens: 30 },
{ inputTokens: 520, outputTokens: 80, totalTokens: 600, reasoningTokens: 10 },
{
inputTokens: 500,
outputTokens: 100,
totalTokens: 600,
reasoningTokens: 30,
},
{
inputTokens: 520,
outputTokens: 80,
totalTokens: 600,
reasoningTokens: 10,
},
),
).toEqual({
inputTokens: 1020,
@@ -431,3 +491,143 @@ describe('accumulateStepUsage', () => {
});
});
});
/**
* Contract test for the #180 wiring in AiChatService.handle: the external MCP
* toolset must be built BEFORE the system prompt, and its per-server guidance
* threaded into buildSystemPrompt({ mcpInstructions }). The full streaming
* handle() is not unit-testable, so this reproduces the exact prompt-build call
* the service makes with a connected-server toolset and asserts the guidance is
* present. The toolsFor->buildSystemPrompt ordering is additionally enforced at
* compile time (the prompt input now consumes external.instructions).
*/
describe('AiChatService system prompt wiring (#180)', () => {
const workspace = { name: 'Acme' } as unknown as Workspace;
it('includes the external MCP server instructions in the built system prompt', () => {
// Shape returned by mcpClients.toolsFor (only `instructions` matters here).
const external: Pick<
Awaited<ReturnType<McpClientsService['toolsFor']>>,
'instructions'
> = {
instructions: [
{
serverName: 'Tavily',
toolPrefix: 'tavily',
instructions: 'Prefer tavily_search for current events.',
},
],
};
// Exactly the call the service makes after building the external toolset.
const system = buildSystemPrompt({
workspace,
adminPrompt: 'persona',
mcpInstructions: external.instructions,
});
expect(system).toContain('<mcp_tooling');
expect(system).toContain('Tavily');
expect(system).toContain('tavily_*');
expect(system).toContain('Prefer tavily_search for current events.');
});
it('renders no MCP block when there are no external servers (empty instructions)', () => {
const system = buildSystemPrompt({
workspace,
adminPrompt: 'persona',
mcpInstructions: [],
});
expect(system).not.toContain('<mcp_tooling');
});
});
/**
* resolveOpenPageContext: the open page the client sends is attacker-controllable
* (id AND title), so the service must validate the id against the DB and take the
* title from the DB row — never echo the client title (#159, AI edits the wrong
* page). Built with Object.create so the test exercises the real method without
* the service's full dependency graph (the constructor only assigns fields).
*/
describe('AiChatService.resolveOpenPageContext (#159 current-page validation)', () => {
const ws = { id: 'ws-1' } as Workspace;
const user = { id: 'u-1' } as any;
function makeService(opts: {
page?: { id: string; workspaceId: string; title: string | null } | null;
canView?: boolean | 'throw-other';
}) {
const svc = Object.create(AiChatService.prototype) as AiChatService;
(svc as any).logger = { warn: () => {} };
(svc as any).pageRepo = {
findById: async () => opts.page ?? undefined,
};
(svc as any).pageAccess = {
validateCanView: async () => {
if (opts.canView === 'throw-other') throw new Error('db down');
if (opts.canView === false) throw new ForbiddenException();
return true;
},
};
return svc;
}
const call = (svc: AiChatService, openPage: any) =>
(svc as any).resolveOpenPageContext(openPage, ws, user) as Promise<{
id: string;
title: string;
} | null>;
it('returns null when no page is open (no id)', async () => {
const svc = makeService({});
expect(await call(svc, null)).toBeNull();
expect(await call(svc, {})).toBeNull();
expect(await call(svc, { title: 'spoofed' })).toBeNull();
});
it('returns null when the page does not exist', async () => {
const svc = makeService({ page: null });
expect(await call(svc, { id: 'p-x' })).toBeNull();
});
it('returns null for a page in a DIFFERENT workspace (tenant isolation)', async () => {
const svc = makeService({
page: { id: 'p-1', workspaceId: 'ws-OTHER', title: 'Secret' },
});
expect(await call(svc, { id: 'p-1' })).toBeNull();
});
it('returns null when the user may not view the page (Forbidden)', async () => {
const svc = makeService({
page: { id: 'p-1', workspaceId: 'ws-1', title: 'Restricted' },
canView: false,
});
expect(await call(svc, { id: 'p-1' })).toBeNull();
});
it('returns null (fail-closed) on a non-Forbidden access-check fault', async () => {
const svc = makeService({
page: { id: 'p-1', workspaceId: 'ws-1', title: 'X' },
canView: 'throw-other',
});
expect(await call(svc, { id: 'p-1' })).toBeNull();
});
it('uses the AUTHORITATIVE DB title, IGNORING the client-supplied title', async () => {
const svc = makeService({
page: { id: 'p-1', workspaceId: 'ws-1', title: 'Real Title B' },
canView: true,
});
// The client claims it is on "Page A" but the id points at page B.
const result = await call(svc, { id: 'p-1', title: 'Page A' });
expect(result).toEqual({ id: 'p-1', title: 'Real Title B' });
});
it('coerces a null DB title to an empty string', async () => {
const svc = makeService({
page: { id: 'p-1', workspaceId: 'ws-1', title: null },
canView: true,
});
expect(await call(svc, { id: 'p-1' })).toEqual({ id: 'p-1', title: '' });
});
});

View File

@@ -1,4 +1,9 @@
import { ForbiddenException, Injectable, Logger } from '@nestjs/common';
import {
ForbiddenException,
Injectable,
Logger,
OnModuleInit,
} from '@nestjs/common';
import { FastifyReply } from 'fastify';
import {
streamText,
@@ -60,7 +65,10 @@ export function prepareAgentStep(
system: string,
): { toolChoice: 'none'; system: string } | undefined {
if (stepNumber >= MAX_AGENT_STEPS - 1) {
return { toolChoice: 'none', system: `${system}\n\n${FINAL_STEP_INSTRUCTION}` };
return {
toolChoice: 'none',
system: `${system}\n\n${FINAL_STEP_INSTRUCTION}`,
};
}
return undefined;
}
@@ -121,7 +129,7 @@ export interface AiChatStreamArgs {
* can be rebuilt for `convertToModelMessages`.
*/
@Injectable()
export class AiChatService {
export class AiChatService implements OnModuleInit {
private readonly logger = new Logger(AiChatService.name);
constructor(
@@ -136,6 +144,32 @@ export class AiChatService {
private readonly pageAccess: PageAccessService,
) {}
/**
* Crash-recovery sweep on server start (#183): any assistant row left in the
* 'streaming' state is the relic of a turn whose process died before it
* reached a terminal status. Flip those to 'aborted' so history/export show
* them settled (with whatever finished steps were already persisted) instead
* of perpetually "streaming". Best-effort: a sweep failure is logged but must
* never block server startup.
*/
async onModuleInit(): Promise<void> {
try {
const swept = await this.aiChatMessageRepo.sweepStreaming();
if (swept > 0) {
this.logger.log(
`Startup sweep: marked ${swept} dangling 'streaming' assistant ` +
`message(s) as 'aborted'.`,
);
}
} catch (err) {
this.logger.warn(
`Startup sweep of dangling 'streaming' messages failed: ${
err instanceof Error ? err.message : 'unknown error'
}`,
);
}
}
/**
* Resolve the agent role that applies to this stream request, scoped to the
* workspace and soft-delete aware. For an EXISTING chat the role is read from
@@ -182,6 +216,41 @@ export class AiChatService {
return this.ai.getChatModel(workspaceId, roleModelOverride(role));
}
/**
* Validate the client-supplied open page and return its AUTHORITATIVE identity
* ({ id, title }) or null. The client controls BOTH the id and the title in the
* request body, so neither is trusted: the id must resolve to a real page in
* THIS workspace that the user may read, and the title is taken from the DB row
* (never the client) so the model can't be told it is "on Page A" while the id
* points at page B (#159). Fail-closed — any missing / foreign / inaccessible
* page, or any non-Forbidden access-check fault, returns null.
*/
private async resolveOpenPageContext(
openPage: { id?: string; title?: string } | null | undefined,
workspace: Workspace,
user: User,
): Promise<{ id: string; title: string } | null> {
const candidatePageId = openPage?.id;
if (!candidatePageId) return null;
const page = await this.pageRepo.findById(candidatePageId);
if (!page || page.workspaceId !== workspace.id) return null;
try {
await this.pageAccess.validateCanView(page, user);
} catch (e) {
// A ForbiddenException is the expected "user cannot read this page" case;
// log anything else (e.g. a DB error) so a real fault is not masked.
if (!(e instanceof ForbiddenException)) {
this.logger.warn(
`open page access check failed: ${
e instanceof Error ? e.message : 'unknown error'
}`,
);
}
return null;
}
return { id: page.id, title: page.title ?? '' };
}
async stream({
user,
workspace,
@@ -202,37 +271,26 @@ export class AiChatService {
chatId = undefined;
}
}
// The open page the client sent is attacker-controllable — BOTH its id and
// its title. Resolve it ONCE against the DB (workspace-scoped + access-
// checked) and use the AUTHORITATIVE identity everywhere below: the system
// prompt context, the getCurrentPage tool, and the new-chat history origin.
// Previously the client title was echoed verbatim, so a navigation / two-tab
// desync (openPage.id -> page B, title -> "Page A") made the model report
// "updated Page A" while it edited page B (#159). Null when no page is open
// or the page is foreign / inaccessible / missing.
const openPageContext = await this.resolveOpenPageContext(
body.openPage,
workspace,
user,
);
if (!chatId) {
// Resolve the origin document for the history list. body.openPage.id is
// attacker-controllable, so validate it before persisting: it must be a
// real page in THIS workspace that the user is allowed to read. Anything
// else (foreign workspace, inaccessible/restricted, or non-existent) is
// dropped to null — persisting it would leak the page's title via the
// chat-list join, or violate the page_id FK on insert (this runs after
// res.hijack(), so a DB error would break the stream).
let originPageId: string | null = null;
const candidatePageId = body.openPage?.id;
if (candidatePageId) {
const page = await this.pageRepo.findById(candidatePageId);
if (page && page.workspaceId === workspace.id) {
try {
await this.pageAccess.validateCanView(page, user);
originPageId = page.id;
} catch (e) {
// Fail-closed: no provenance on any failure. A ForbiddenException is
// the expected "user cannot read this page" case; log anything else
// (e.g. a DB error) so a real fault is not masked as "no access".
if (!(e instanceof ForbiddenException)) {
this.logger.warn(
`origin page access check failed: ${
e instanceof Error ? e.message : 'unknown error'
}`,
);
}
originPageId = null;
}
}
}
// The history-list origin is the validated open page (see above):
// persisting an unvalidated id would leak a title via the chat-list join,
// or violate the page_id FK on insert (this runs after res.hijack(), so a
// DB error would break the stream).
const originPageId: string | null = openPageContext?.id ?? null;
const chat = await this.aiChatRepo.insert({
creatorId: user.id,
workspaceId: workspace.id,
@@ -259,9 +317,7 @@ export class AiChatService {
content: incomingText,
// jsonb column: UIMessage parts are JSON-serializable at runtime but not
// structurally `JsonValue`, so cast through unknown.
metadata: (incoming?.parts
? { parts: incoming.parts }
: null) as never,
metadata: (incoming?.parts ? { parts: incoming.parts } : null) as never,
});
// Rebuild the conversation from persisted history (not the client payload),
@@ -280,38 +336,20 @@ export class AiChatService {
// The model is resolved by the controller before hijack (clean 503 path).
// Here we only need the admin-configured system prompt.
const resolved = await this.aiSettings.resolve(workspace.id);
const system = buildSystemPrompt({
workspace,
adminPrompt: resolved?.systemPrompt,
// The role (pre-resolved by the controller) REPLACES the persona layer;
// the safety framework is still appended by buildSystemPrompt.
roleInstructions: role?.instructions,
openedPage: body.openPage,
});
// Pass the resolved chatId so the write tools can mint provenance tokens
// (access + collab) carrying { actor:'agent', aiChatId: chatId }, making
// agent REST/collab writes attributable and non-spoofable (§6.5/§6.6).
const docmostTools = await this.tools.forUser(
user,
sessionId,
workspace.id,
chatId,
// Same open-page value used by the system prompt above; exposed to the
// model via getCurrentPage so page identity survives prompt mangling.
body.openPage,
);
// Merge in admin-configured external MCP tools (web search, etc.; §6.8).
// A down/slow external server never crashes the turn — toolsFor skips it and
// records the outcome. The returned client handles MUST be closed in the
// streamText lifecycle (onFinish/onError/onAbort) — leaking them is a bug.
// Docmost tools take precedence on a name clash (external are namespaced, so
// a clash is not expected; the spread order makes intent explicit).
// Build the external MCP toolset FIRST so the system prompt can carry each
// connected server's admin-authored guidance (#180). Merge in admin-
// configured external MCP tools (web search, etc.; §6.8). A down/slow
// external server never crashes the turn — toolsFor skips it and records the
// outcome. The returned client handles MUST be closed in the streamText
// lifecycle (onFinish/onError/onAbort) — leaking them is a bug. Docmost
// tools take precedence on a name clash (external are namespaced, so a clash
// is not expected; the spread order makes intent explicit).
let external: Awaited<ReturnType<McpClientsService['toolsFor']>> = {
tools: {},
clients: [],
outcomes: [],
instructions: [],
};
try {
external = await this.mcpClients.toolsFor(workspace.id);
@@ -324,12 +362,15 @@ export class AiChatService {
}`,
);
}
const tools = { ...external.tools, ...docmostTools };
// Close every external client EXACTLY ONCE across the turn's terminal
// callbacks (onFinish/onError/onAbort all fire at most once collectively,
// but guard anyway). Close errors are swallowed so they never break the
// response.
// but guard anyway). DEFINED HERE — before the prompt/toolset are built — so
// that if buildSystemPrompt or forUser throws AFTER the external lease was
// taken (toolsFor above), the lease is still released. Otherwise its refCount
// stays >= 1 forever and the external undici sockets leak until restart
// (#180 reorder moved toolsFor ahead of these; #185 review). Close errors are
// swallowed so they never break the response.
let clientsClosed = false;
const closeExternalClients = async (): Promise<void> => {
if (clientsClosed) return;
@@ -347,30 +388,43 @@ export class AiChatService {
);
};
// Persist the assistant message. Used by onFinish (full result) and the
// abort/error paths (partial result). Guarded so we persist at most once.
let persisted = false;
const persistAssistant = async (data: {
text: string;
toolCalls: unknown;
metadata: Record<string, unknown>;
}): Promise<void> => {
if (persisted) return;
persisted = true;
try {
await this.aiChatMessageRepo.insert({
chatId,
workspaceId: workspace.id,
userId: user.id,
role: 'assistant',
content: data.text ?? '',
toolCalls: (data.toolCalls ?? null) as never,
metadata: data.metadata as never,
});
} catch (err) {
this.logger.error('Failed to persist assistant message', err as Error);
}
};
// Build the system prompt + Docmost toolset. If either throws after the
// external MCP lease was taken above, release the lease before rethrowing so
// the leased transports are not leaked (#185 review).
let system: string;
let docmostTools: Awaited<ReturnType<AiChatToolsService['forUser']>>;
try {
system = buildSystemPrompt({
workspace,
adminPrompt: resolved?.systemPrompt,
// The role (pre-resolved by the controller) REPLACES the persona layer;
// the safety framework is still appended by buildSystemPrompt.
roleInstructions: role?.instructions,
// Server-validated open page (authoritative title), not the client value.
openedPage: openPageContext,
// Guidance only for servers that connected and yielded ≥1 callable tool.
mcpInstructions: external.instructions,
});
// Pass the resolved chatId so the write tools can mint provenance tokens
// (access + collab) carrying { actor:'agent', aiChatId: chatId }, making
// agent REST/collab writes attributable and non-spoofable (§6.5/§6.6).
docmostTools = await this.tools.forUser(
user,
sessionId,
workspace.id,
chatId,
// Same server-validated open page used by the system prompt above;
// exposed to the model via getCurrentPage so page identity (and the
// AUTHORITATIVE title) survives prompt mangling / client title spoofing.
openPageContext,
);
} catch (err) {
await closeExternalClients();
throw err;
}
const tools = { ...external.tools, ...docmostTools };
// Accumulate the turn's streamed output so a provider error / disconnect can
// persist the PARTIAL answer the user already saw — the SDK's onError/onAbort
@@ -380,6 +434,101 @@ export class AiChatService {
const capturedSteps: StepLike[] = [];
let inProgressText = '';
// Step-granular durability (#183): create the assistant row UPFRONT in the
// 'streaming' state (before any token), then UPDATE it as each step finishes
// and finalize it once on the terminal callback. If the process dies
// mid-turn the row survives with every finished step already persisted; the
// startup sweep (sweepStreaming) later flips a dangling 'streaming' row to
// 'aborted'. The DB is now the single source of truth for the turn — the
// socket is never required for the write path. A failed upfront insert is
// logged and leaves assistantId undefined; the per-step/terminal updates then
// no-op (guarded below) so the turn still streams to the user.
let assistantId: string | undefined;
try {
const seed = flushAssistant([], '', 'streaming');
const seeded = await this.aiChatMessageRepo.insert({
chatId,
workspaceId: workspace.id,
userId: user.id,
role: 'assistant',
content: seed.content,
// jsonb columns: cast through never (same as the user insert above).
toolCalls: (seed.toolCalls ?? null) as never,
metadata: seed.metadata as never,
status: seed.status,
});
assistantId = seeded?.id;
} catch (err) {
this.logger.error(
`Failed to insert upfront assistant row (chat ${chatId}, workspace ${workspace.id})`,
err as Error,
);
}
// Per-step (non-terminal) update: persist the finished steps the moment a
// step ends. Tolerant — a failed update is logged and swallowed so it never
// throws into the stream. Keeps status 'streaming'.
const updateStreaming = async (): Promise<void> => {
if (!assistantId) return;
// Cheap short-circuit once the turn is finalized (see `finalized` below).
// The AUTHORITATIVE guard is `onlyIfStreaming` on the UPDATE: a late
// fire-and-forget step update could still be in flight on another pool
// connection when finalize runs, so the SQL `WHERE status='streaming'`
// (not this flag) is what prevents it clobbering the terminal row.
if (finalized) return;
try {
await this.aiChatMessageRepo.update(
assistantId,
workspace.id,
flushAssistant(capturedSteps, '', 'streaming'),
{ onlyIfStreaming: true },
);
} catch (err) {
this.logger.warn(
`Failed to update streaming assistant row: ${
err instanceof Error ? err.message : 'unknown error'
}`,
);
}
};
// Serialize the per-step updates (#183 review): onStepFinish fires them
// without await, so two could otherwise commit out of order on different pool
// connections (step N landing after N+1). Chaining each onto the previous
// keeps the persisted row monotonic with step order; each link short-circuits
// on `finalized`, so a tail of late updates is cheap.
let stepUpdateChain: Promise<void> = Promise.resolve();
// Terminal finalize: write the completed/error/aborted row exactly once
// across the (mutually-exclusive, at-most-once) onFinish/onError/onAbort
// callbacks — mirroring the pre-#183 persist-at-most-once guard for the
// TERMINAL status (the row may be updated many times with 'streaming' before
// this fires once).
let finalized = false;
const finalizeAssistant = async (
flushed: AssistantFlush,
): Promise<void> => {
if (finalized) return;
finalized = true;
const plan = planFinalizeAssistant(assistantId);
try {
// Shared dispatch (see applyFinalize): UPDATE the upfront row, or — when
// the upfront insert failed (kind 'insert') — INSERT the terminal row as
// the only safety against losing the turn entirely.
await applyFinalize(
this.aiChatMessageRepo,
plan,
{ chatId, workspaceId: workspace.id, userId: user.id },
flushed,
);
} catch (err) {
this.logger.error(
`Failed to finalize assistant message (kind=${plan.kind})`,
err as Error,
);
}
};
// DIAGNOSTIC (Safari stream-drop investigation) — temporary. Measure
// first-chunk latency, the model-silent gap right before a disconnect, and
// how many SSE heartbeats were written, so a Safari drop can be classified
@@ -395,144 +544,151 @@ export class AiChatService {
let result: ReturnType<typeof streamText>;
try {
result = streamText({
model,
system,
messages,
tools,
// No maxOutputTokens cap on the agent: tool-call arguments (e.g. a full
// page body for the write tools) are emitted as OUTPUT tokens, so a fixed
// cap would truncate complex tool calls mid-argument. Let the model use its
// natural per-step budget. (Cost/credit limits are an account concern, not
// something to enforce by silently breaking the agent.)
stopWhen: stepCountIs(MAX_AGENT_STEPS),
// Forced finalization: reserve the LAST allowed step for a text-only
// answer. Without this, a turn that spends all its steps on tool calls
// ends with no assistant text (an empty turn). prepareAgentStep forbids
// further tool calls and appends a synthesis instruction on that step,
// concatenated onto the original `system` so the persona is preserved.
prepareStep: ({ stepNumber }) => prepareAgentStep(stepNumber, system),
abortSignal: signal,
onChunk: ({ chunk }) => {
// DIAGNOSTIC (Safari stream-drop investigation) — temporary. Any model
// output chunk means the stream is actively emitting bytes; track first
// + most-recent activity timestamps.
const now = Date.now();
firstModelChunkAt ??= now;
lastModelChunkAt = now;
// 'text-delta' is the assistant's prose; tool-call args are separate chunk
// types — so this mirrors exactly what streams to the client.
if (chunk.type === 'text-delta') inProgressText += chunk.text;
},
onStepFinish: (step) => {
// The finished step's full text is now in `step.text`; fold it in and reset
// the in-progress accumulator for the next step.
capturedSteps.push(step as StepLike);
inProgressText = '';
},
onFinish: async ({ text, finishReason, totalUsage, usage, steps }) => {
// DIAGNOSTIC (Safari stream-drop investigation) — temporary: success
// baseline for Safari comparison.
const diagNow = Date.now();
this.logger.log(
`AI chat stream DIAGNOSTIC (finish): elapsed=${diagNow - streamStartedAt}ms ` +
`firstChunkLatency=${firstModelChunkAt ? firstModelChunkAt - streamStartedAt : 'none'}ms ` +
`heartbeatsSent=${heartbeatsSent} steps=${steps.length}`,
);
await persistAssistant({
text,
toolCalls: serializeSteps(steps),
metadata: {
finishReason,
// Persist the turn's cumulative usage WITH reasoning tokens resolved
// from either the new `outputTokenDetails` or the deprecated top-level
// field, so reopened history / the Markdown export show the thinking
// token cost too.
usage: normalizeStreamUsage(totalUsage as StreamUsage) ?? totalUsage,
// Final-step usage = the context actually fed to the model on the last LLM
// call (full history + tool results) plus the answer it just generated.
// input+output of the FINAL step ≈ the conversation's CURRENT context size,
// distinct from totalUsage which sums every step (cumulative tokens spent).
contextTokens:
(usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0) || undefined,
// Persist the FULL set of UIMessage parts for the turn (text +
// tool-call/result), so the rebuilt history replays prior tool
// context to the model on later turns.
parts: assistantParts(steps, text),
},
});
// Lifecycle: release the external MCP clients leased for this turn.
await closeExternalClients();
// Generate the chat title for a freshly created chat AFTER the stream's
// provider call has completed — NOT concurrently with it. The z.ai coding
// endpoint stalls one of two concurrent requests to the same plan, which
// black-holed the chat stream (~300s headers timeout) when title
// generation raced it. Running it here (solo, fire-and-forget) avoids the
// race; never block the turn on it, swallow any error.
if (isNewChat && incomingText) {
void this.generateTitle(chatId, workspace.id, incomingText).catch(
(err) => {
this.logger.warn(
`Title generation failed: ${(err as Error)?.message ?? err}`,
);
},
model,
system,
messages,
tools,
// No maxOutputTokens cap on the agent: tool-call arguments (e.g. a full
// page body for the write tools) are emitted as OUTPUT tokens, so a fixed
// cap would truncate complex tool calls mid-argument. Let the model use its
// natural per-step budget. (Cost/credit limits are an account concern, not
// something to enforce by silently breaking the agent.)
stopWhen: stepCountIs(MAX_AGENT_STEPS),
// Forced finalization: reserve the LAST allowed step for a text-only
// answer. Without this, a turn that spends all its steps on tool calls
// ends with no assistant text (an empty turn). prepareAgentStep forbids
// further tool calls and appends a synthesis instruction on that step,
// concatenated onto the original `system` so the persona is preserved.
prepareStep: ({ stepNumber }) => prepareAgentStep(stepNumber, system),
abortSignal: signal,
onChunk: ({ chunk }) => {
// DIAGNOSTIC (Safari stream-drop investigation) — temporary. Any model
// output chunk means the stream is actively emitting bytes; track first
// + most-recent activity timestamps.
const now = Date.now();
firstModelChunkAt ??= now;
lastModelChunkAt = now;
// 'text-delta' is the assistant's prose; tool-call args are separate chunk
// types — so this mirrors exactly what streams to the client.
if (chunk.type === 'text-delta') inProgressText += chunk.text;
},
onStepFinish: (step) => {
// The finished step's full text is now in `step.text`; fold it in and reset
// the in-progress accumulator for the next step.
capturedSteps.push(step as StepLike);
inProgressText = '';
// Step-granular durability (#183): persist this finished step (its text +
// tool calls + tool RESULTS) the moment it ends, so a process death after
// this point still recovers the step. Not awaited here (never block the
// stream), but SERIALIZED via stepUpdateChain so the writes commit in
// step order; updateStreaming is error-tolerant (logs + swallows).
stepUpdateChain = stepUpdateChain.then(() => updateStreaming());
},
onFinish: async ({ text, finishReason, totalUsage, usage, steps }) => {
// DIAGNOSTIC (Safari stream-drop investigation) — temporary: success
// baseline for Safari comparison.
const diagNow = Date.now();
this.logger.log(
`AI chat stream DIAGNOSTIC (finish): elapsed=${diagNow - streamStartedAt}ms ` +
`firstChunkLatency=${firstModelChunkAt ? firstModelChunkAt - streamStartedAt : 'none'}ms ` +
`heartbeatsSent=${heartbeatsSent} steps=${steps.length}`,
);
}
},
onError: async ({ error }) => {
// NestJS Logger.error(message, stack?, context?): pass the real message
// (with statusCode when present) + the stack string, not the Error
// object, so the actual provider cause is clearly logged. Reuse the
// shared formatter so provider error formatting stays unified.
const e = error as { stack?: string };
const errorText = describeProviderError(error, String(error));
this.logger.error(`AI chat stream error: ${errorText}`, e?.stack);
// DIAGNOSTIC (Safari stream-drop investigation) — temporary: timing of
// an error-terminated stream.
const diagNow = Date.now();
this.logger.warn(
`AI chat stream DIAGNOSTIC (error): elapsed=${diagNow - streamStartedAt}ms ` +
`firstChunkLatency=${firstModelChunkAt ? firstModelChunkAt - streamStartedAt : 'none'}ms ` +
`silentGapBeforeDrop=${diagNow - lastModelChunkAt}ms heartbeatsSent=${heartbeatsSent}`,
);
// Persist the PARTIAL answer streamed before the failure (text + any
// finished tool steps) WITH the error in metadata, so the turn shows what
// the user already saw plus the cause — not just a bare error.
await persistAssistant(
buildPartialAssistantRecord(
capturedSteps,
inProgressText,
'error',
errorText,
),
);
await closeExternalClients();
},
onAbort: async ({ steps }) => {
const partialChars =
capturedSteps.reduce((n, s) => n + (s.text?.length ?? 0), 0) +
inProgressText.length;
// Unlike onError/onFinish, this terminal path otherwise writes nothing, so
// an aborted turn (client disconnect / proxy drop / stop()) would be
// invisible in the logs. Log it (warn) so the abort is traceable.
this.logger.warn(
`AI chat stream aborted (chat ${chatId}) after ${steps.length} ` +
`step(s), ${partialChars} chars partial text; persisting partial turn.`,
);
// DIAGNOSTIC (Safari stream-drop investigation) — temporary: THE key
// line — classifies the Safari drop.
const diagNow = Date.now();
this.logger.warn(
`AI chat stream DIAGNOSTIC (abort/disconnect): elapsed=${diagNow - streamStartedAt}ms ` +
`firstChunkLatency=${firstModelChunkAt ? firstModelChunkAt - streamStartedAt : 'none'}ms ` +
`silentGapBeforeDrop=${diagNow - lastModelChunkAt}ms heartbeatsSent=${heartbeatsSent} ` +
`steps=${steps.length}`,
);
await persistAssistant(
buildPartialAssistantRecord(capturedSteps, inProgressText, 'aborted'),
);
await closeExternalClients();
},
// Finalize the assistant row (#183): the upfront 'streaming' row is
// UPDATEd to 'completed' with the turn's final text, cumulative usage and
// full UIMessage parts. We pass the SDK `steps` (which carry the final
// step's text) as the captured steps so metadata.parts matches the
// pre-#183 onFinish record exactly; `inProgressText` is '' here (the last
// step already finished). Final-step usage (usage.input+output) ≈ the
// conversation's CURRENT context size, distinct from totalUsage.
//
// COLUMN-SEMANTICS NOTE (#183): `content` is built by flushAssistant as
// the CONCATENATION of every step's text (stepsText), whereas pre-#183
// it stored only the FINAL step's text. This is a deliberate, harmless
// change: the UI and the Markdown export render from `metadata.parts`
// (per-step text + tool parts), not from `content`; `content` is the
// plain-text projection (full-text search / fallback). A multi-step
// turn's `content` therefore now holds all steps' prose, not just the
// last block.
await finalizeAssistant(
flushAssistant(steps as StepLike[], '', 'completed', {
finishReason: finishReason as string,
usage: totalUsage as StreamUsage,
contextTokens:
(usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0) ||
undefined,
}),
);
// Lifecycle: release the external MCP clients leased for this turn.
await closeExternalClients();
// Generate the chat title for a freshly created chat AFTER the stream's
// provider call has completed — NOT concurrently with it. The z.ai coding
// endpoint stalls one of two concurrent requests to the same plan, which
// black-holed the chat stream (~300s headers timeout) when title
// generation raced it. Running it here (solo, fire-and-forget) avoids the
// race; never block the turn on it, swallow any error.
if (isNewChat && incomingText) {
void this.generateTitle(chatId, workspace.id, incomingText).catch(
(err) => {
this.logger.warn(
`Title generation failed: ${(err as Error)?.message ?? err}`,
);
},
);
}
},
onError: async ({ error }) => {
// NestJS Logger.error(message, stack?, context?): pass the real message
// (with statusCode when present) + the stack string, not the Error
// object, so the actual provider cause is clearly logged. Reuse the
// shared formatter so provider error formatting stays unified.
const e = error as { stack?: string };
const errorText = describeProviderError(error, String(error));
this.logger.error(`AI chat stream error: ${errorText}`, e?.stack);
// DIAGNOSTIC (Safari stream-drop investigation) — temporary: timing of
// an error-terminated stream.
const diagNow = Date.now();
this.logger.warn(
`AI chat stream DIAGNOSTIC (error): elapsed=${diagNow - streamStartedAt}ms ` +
`firstChunkLatency=${firstModelChunkAt ? firstModelChunkAt - streamStartedAt : 'none'}ms ` +
`silentGapBeforeDrop=${diagNow - lastModelChunkAt}ms heartbeatsSent=${heartbeatsSent}`,
);
// Finalize the PARTIAL answer streamed before the failure (text + any
// finished tool steps) WITH the error in metadata, so the turn shows what
// the user already saw plus the cause — not just a bare error. Status
// 'error' (#183).
await finalizeAssistant(
flushAssistant(capturedSteps, inProgressText, 'error', {
error: errorText,
}),
);
await closeExternalClients();
},
onAbort: async ({ steps }) => {
const partialChars =
capturedSteps.reduce((n, s) => n + (s.text?.length ?? 0), 0) +
inProgressText.length;
// Unlike onError/onFinish, this terminal path otherwise writes nothing, so
// an aborted turn (client disconnect / proxy drop / stop()) would be
// invisible in the logs. Log it (warn) so the abort is traceable.
this.logger.warn(
`AI chat stream aborted (chat ${chatId}) after ${steps.length} ` +
`step(s), ${partialChars} chars partial text; persisting partial turn.`,
);
// DIAGNOSTIC (Safari stream-drop investigation) — temporary: THE key
// line — classifies the Safari drop.
const diagNow = Date.now();
this.logger.warn(
`AI chat stream DIAGNOSTIC (abort/disconnect): elapsed=${diagNow - streamStartedAt}ms ` +
`firstChunkLatency=${firstModelChunkAt ? firstModelChunkAt - streamStartedAt : 'none'}ms ` +
`silentGapBeforeDrop=${diagNow - lastModelChunkAt}ms heartbeatsSent=${heartbeatsSent} ` +
`steps=${steps.length}`,
);
await finalizeAssistant(
flushAssistant(capturedSteps, inProgressText, 'aborted'),
);
await closeExternalClients();
},
});
// Drain the stream independently of the client socket so the turn always
@@ -652,7 +808,10 @@ export class AiChatService {
'punctuation at the end.',
prompt: firstMessage.slice(0, 2000),
});
const title = text.trim().replace(/^["']|["']$/g, '').slice(0, 120);
const title = text
.trim()
.replace(/^["']|["']$/g, '')
.slice(0, 120);
if (title) {
await this.aiChatRepo.update(chatId, { title }, workspaceId);
}
@@ -975,38 +1134,132 @@ export function rowToUiMessage(row: AiChatMessage): Omit<UIMessage, 'id'> & {
}
/**
* Build the assistant-message record persisted on a partial/failed turn (the
* streamText onError / onAbort paths). Captures the partial answer the user
* already saw: each finished step's text + tool parts (via assistantParts),
* then the in-progress step's text appended last. When `errorText` is provided
* it is recorded in metadata.error so the cause shows in history; an aborted
* turn passes none. Pure, so the partial-recording shape is unit-testable
* without seaming streamText.
* The persisted-row patch shape produced by {@link flushAssistant}. It is the
* SAME shape the assistant repo insert/update consume (content + toolCalls +
* metadata) plus the lifecycle `status` column added in #183.
*/
export function buildPartialAssistantRecord(
steps: ReadonlyArray<StepLike> | undefined,
export interface AssistantFlush {
content: string;
toolCalls: unknown;
metadata: Record<string, unknown>;
status: 'streaming' | 'completed' | 'error' | 'aborted';
}
/**
* Pure decision for the terminal finalize (#183): given whether the upfront
* assistant row exists (`assistantId`), choose whether the terminal payload is
* written by UPDATEing that row or — when the upfront insert failed and there is
* no id — by INSERTing a fresh terminal row so the turn is not lost entirely.
* Returns `{ kind: 'update', id }` or `{ kind: 'insert' }`. Extracted so the
* fallback-insert branch (the only safety against losing a turn whose upfront
* insert failed) is unit-testable without seaming streamText.
*/
export function planFinalizeAssistant(
assistantId: string | undefined,
): { kind: 'update'; id: string } | { kind: 'insert' } {
return assistantId ? { kind: 'update', id: assistantId } : { kind: 'insert' };
}
/** The repo surface the terminal finalize needs (structural — the real repo and
* a test mock both satisfy it). */
export interface FinalizeRepo {
insert(insertable: Record<string, unknown>): Promise<unknown>;
update(
id: string,
workspaceId: string,
patch: AssistantFlush,
): Promise<unknown>;
}
/**
* Apply a finalize `plan` to the repo with the terminal `flushed` payload (#183):
* UPDATE the upfront row, or INSERT a fresh terminal row as the fallback when the
* upfront insert failed. The SINGLE dispatch shared by the service's
* finalizeAssistant and its test, so the test exercises the real path instead of
* a copy (#186 review). Pure of error handling — the caller wraps it.
*/
export async function applyFinalize(
repo: FinalizeRepo,
plan: { kind: 'update'; id: string } | { kind: 'insert' },
base: { chatId: string; workspaceId: string; userId: string },
flushed: AssistantFlush,
): Promise<void> {
if (plan.kind === 'update') {
await repo.update(plan.id, base.workspaceId, flushed);
return;
}
await repo.insert({
chatId: base.chatId,
workspaceId: base.workspaceId,
userId: base.userId,
role: 'assistant',
content: flushed.content,
toolCalls: flushed.toolCalls ?? null,
metadata: flushed.metadata,
status: flushed.status,
});
}
/**
* PURE assistant-row builder (#183 step-granular durability). Given the turn's
* accumulated steps + the in-progress (not-yet-finished) text + the lifecycle
* status, it returns the row patch to persist. The SAME path runs for the
* upfront insert (empty steps, status 'streaming'), every per-step update, and
* the terminal finalize (completed/error/aborted) — and a future background
* worker can call it identically, so it must stay a pure function of its inputs
* (NO `this`, no IO).
*
* `metadata.parts` is built by assistantParts over the finished steps, then the
* in-progress text appended as a trailing text part, so rowToUiMessage /
* findRecent keep replaying the turn unchanged. `metadata.finishReason`,
* `metadata.error`, `metadata.usage` and `metadata.contextTokens` are attached
* only when provided/relevant, matching the pre-#183 onFinish/onError records.
*/
export function flushAssistant(
capturedSteps: ReadonlyArray<StepLike> | undefined,
inProgressText: string,
finishReason: 'error' | 'aborted',
errorText?: string,
): { text: string; toolCalls: unknown; metadata: Record<string, unknown> } {
const finished = steps ?? [];
status: 'streaming' | 'completed' | 'error' | 'aborted',
extra?: {
finishReason?: string;
usage?: ChatStreamUsage | StreamUsage | undefined;
contextTokens?: number;
error?: string;
},
): AssistantFlush {
const finished = capturedSteps ?? [];
const stepsText = finished.map((s) => s.text ?? '').join('');
const trailing = inProgressText ?? '';
// assistantParts emits text parts only for FINISHED steps; append the
// in-progress step's text (the answer cut off by the error) as the last text
// part so the persisted parts match what streamed to the client.
// in-progress step's text (the partial answer cut off by an error/abort, or
// simply not yet flushed mid-stream) as the last text part so the persisted
// parts match what streamed to the client.
const parts = assistantParts(finished, '') as unknown as Array<
Record<string, unknown>
>;
if (trailing) parts.push({ type: 'text', text: trailing });
const metadata: Record<string, unknown> = {
parts: parts as unknown as UIMessage['parts'],
};
// finishReason: prefer an explicit one; else derive a sensible value from the
// terminal status (so onError/onAbort records keep their historical reason).
if (extra?.finishReason) {
metadata.finishReason = extra.finishReason;
} else if (status === 'error' || status === 'aborted') {
metadata.finishReason = status;
}
if (extra?.usage !== undefined) {
metadata.usage =
normalizeStreamUsage(extra.usage as StreamUsage) ?? extra.usage;
}
if (extra?.contextTokens) metadata.contextTokens = extra.contextTokens;
if (extra?.error) metadata.error = extra.error;
return {
text: stepsText + trailing,
content: stepsText + trailing,
toolCalls: serializeSteps(finished),
metadata: {
finishReason,
parts: parts as unknown as UIMessage['parts'],
...(errorText ? { error: errorText } : {}),
},
metadata,
status,
};
}

View File

@@ -0,0 +1,295 @@
import { buildChatMarkdown, normalizeLang } from './chat-markdown.util';
import type { AiChatMessage } from '@docmost/db/types/entity.types';
/**
* normalizeLang: the client sends `i18n.language` — a FULL locale tag like
* 'en-US' / 'ru-RU', NOT a bare 'en'/'ru'. A `@IsIn(['en','ru'])` DTO rejected
* that with a 400 (caught in real-browser testing); the export now accepts any
* string and normalizes here. Guards that regression.
*/
describe('normalizeLang', () => {
it("maps any 'ru…' locale tag to ru", () => {
expect(normalizeLang('ru')).toBe('ru');
expect(normalizeLang('ru-RU')).toBe('ru');
expect(normalizeLang('RU-ru')).toBe('ru');
});
it('maps everything else (incl. region-qualified English) to en', () => {
expect(normalizeLang('en')).toBe('en');
expect(normalizeLang('en-US')).toBe('en');
expect(normalizeLang('fr-FR')).toBe('en');
expect(normalizeLang(undefined)).toBe('en');
expect(normalizeLang('')).toBe('en');
});
});
/**
* Unit tests for the SERVER Markdown export (#183). Mirrors the coverage of the
* (now-removed) client chat-markdown tests: heading/metadata, role labels, text
* + tool blocks, token footers, the interrupted-turn note, and NULL-status
* (legacy) rows. The export embeds a live `new Date().toISOString()` timestamp;
* we never assert it, only the deterministic structure.
*/
function row(partial: Partial<AiChatMessage>): AiChatMessage {
return {
id: partial.id ?? 'id',
chatId: partial.chatId ?? 'chat-1',
workspaceId: partial.workspaceId ?? 'ws-1',
userId: partial.userId ?? null,
role: partial.role ?? 'user',
content: partial.content ?? null,
toolCalls: partial.toolCalls ?? null,
metadata: partial.metadata ?? null,
status: partial.status ?? null,
createdAt: partial.createdAt ?? ('2026-06-21T00:00:00.000Z' as never),
updatedAt: partial.updatedAt ?? ('2026-06-21T00:00:00.000Z' as never),
deletedAt: partial.deletedAt ?? null,
} as AiChatMessage;
}
describe('buildChatMarkdown (server) — structure', () => {
it('emits the title heading, chat id and message count', () => {
const md = buildChatMarkdown({
title: 'My chat',
chatId: 'chat-123',
rows: [],
});
expect(md).toContain('# My chat');
expect(md).toContain('- Chat ID: `chat-123`');
expect(md).toContain('- Messages: 0');
});
it('falls back to "Untitled chat" with no title (en)', () => {
const md = buildChatMarkdown({ title: null, chatId: 'c', rows: [] });
expect(md).toContain('# Untitled chat');
});
it('localizes fixed labels with lang=ru (structure stays English)', () => {
const md = buildChatMarkdown({
title: null,
chatId: 'c',
lang: 'ru',
rows: [row({ role: 'assistant', content: 'hi' })],
});
expect(md).toContain('# Без названия');
expect(md).toContain('## 1. ИИ-агент');
// Structural words remain English.
expect(md).toContain('- Chat ID:');
});
it('numbers messages and labels roles (You / AI agent)', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({ role: 'user', content: 'question' }),
row({ role: 'assistant', content: 'answer' }),
],
});
expect(md).toContain('## 1. You');
expect(md).toContain('question');
expect(md).toContain('## 2. AI agent');
expect(md).toContain('answer');
});
it('renders a tool part with fenced input/output and the friendly label', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
content: 'done',
metadata: {
parts: [
{
type: 'tool-getPage',
state: 'output-available',
input: { id: 'p1' },
output: { title: 'Hello' },
},
{ type: 'text', text: 'done' },
],
} as never,
}),
],
});
expect(md).toContain('**Tool: Read page** (`getPage`) — done');
expect(md).toContain('Input:');
expect(md).toContain('"id": "p1"');
expect(md).toContain('Output:');
expect(md).toContain('"title": "Hello"');
});
// #186 re-review pt 1: restore the parity coverage of the removed client spec —
// error state, unknown-tool fallback (en + ru), and the circular-stringify catch.
it('renders a tool part in the error state with its errorText', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
metadata: {
parts: [
{
type: 'tool-getPage',
state: 'output-error',
input: { id: 'p1' },
errorText: 'page not found',
},
],
} as never,
}),
],
});
expect(md).toContain('**Tool: Read page** (`getPage`) — error');
expect(md).toContain('**Error:** page not found');
});
it('falls back to "Ran tool <name>" for an unknown tool (en) and the ru variant', () => {
const parts = [
{
type: 'tool-mysteryTool',
state: 'output-available',
output: { ok: 1 },
},
];
const en = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [row({ role: 'assistant', metadata: { parts } as never })],
});
expect(en).toContain('**Tool: Ran tool mysteryTool** (`mysteryTool`)');
const ru = buildChatMarkdown({
title: 'T',
chatId: 'c',
lang: 'ru',
rows: [row({ role: 'assistant', metadata: { parts } as never })],
});
expect(ru).toContain('Выполнил инструмент mysteryTool');
});
it('does not throw on a circular tool output (falls back to String)', () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(() =>
buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
metadata: {
parts: [
{
type: 'tool-getPage',
state: 'output-available',
output: circular,
},
],
} as never,
}),
],
}),
).not.toThrow();
});
it('emits a token footer + total when usage is present', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
content: 'a',
metadata: {
usage: {
inputTokens: 100,
outputTokens: 20,
totalTokens: 120,
reasoningTokens: 8,
},
} as never,
}),
],
});
expect(md).toContain('- Total tokens: 120');
expect(md).toContain(
'_Tokens — in: 100, out: 20, reasoning: 8, total: 120_',
);
});
it('flags a still-streaming (interrupted) row', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({ role: 'assistant', content: 'partial', status: 'streaming' }),
],
});
expect(md).toContain('still being generated');
});
it('does NOT flag a completed row', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [row({ role: 'assistant', content: 'final', status: 'completed' })],
});
expect(md).not.toContain('still being generated');
});
it('renders a legacy NULL-status row (no parts) from plain content', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({ role: 'assistant', content: 'legacy answer', status: null }),
],
});
expect(md).toContain('legacy answer');
expect(md).not.toContain('still being generated');
});
it('renders a persisted error', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
content: '',
status: 'error',
metadata: { error: '401: Unauthorized' } as never,
}),
],
});
expect(md).toContain('**⚠️ Error:** 401: Unauthorized');
});
it('escapes embedded triple-backtick fences with a longer delimiter', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
content: 'x',
metadata: {
parts: [
{
type: 'tool-getPage',
state: 'output-available',
output: '```inner```',
},
],
} as never,
}),
],
});
// A 4-backtick fence wraps content that itself contains a 3-backtick run.
expect(md).toContain('````');
});
});

View File

@@ -0,0 +1,299 @@
/**
* Server-side Markdown export for an AI agent chat (#183). The DB is the single
* source of truth: this renders a chat purely from its persisted message rows
* (`AiChatMessage[]` — role / content / metadata.parts / toolCalls / usage).
* Because the assistant row is now persisted UPFRONT and updated per step, an
* interrupted turn is included up to its last finished step.
*
* Ported from the client `utils/chat-markdown.ts`. It is a PURE function (apart
* from `new Date()` for the export timestamp), so it is straightforward to
* unit-test and a future background worker can reuse it.
*
* Only a few fixed role/tool labels are localized via the `lang` param; the
* structural document words (Input/Output/Error/Tokens/...) stay English because
* the output is a technical artifact.
*/
import type { AiChatMessage } from '@docmost/db/types/entity.types';
/** Supported export label languages. Defaults to English. */
export type ExportLang = 'en' | 'ru';
/**
* Normalize an arbitrary client locale code to a supported export language. The
* client sends `i18n.language`, which is a FULL locale tag (e.g. `en-US`,
* `ru-RU`), not a bare `en`/`ru` — so match on the language subtag and fall back
* to English for anything non-Russian.
*/
export function normalizeLang(lang?: string): ExportLang {
return lang?.toLowerCase().startsWith('ru') ? 'ru' : 'en';
}
/** A single AI SDK UIMessage part (text part or a tool part). */
interface ExportPart {
type: string;
text?: string;
state?: string;
toolName?: string;
input?: unknown;
output?: unknown;
errorText?: string;
}
/** Authoritative per-turn usage the server attaches to a message row. */
interface UsageLike {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
}
/** Localized label table. The client-side Markdown builder was removed by #183
* (the export is now server-side only), so this no longer mirrors a second
* exporter — instead the tool-action labels are kept in parity with the
* on-screen action-log labels in the client's `tool-parts.tsx` (`toolLabelKey`)
* so the export reads the same as the UI. Only role + tool-action labels are
* localized; everything structural is an English constant in the renderer. */
const LABELS: Record<
ExportLang,
{
untitled: string;
aiAgent: string;
you: string;
tools: Record<string, string>;
ranTool: (name: string) => string;
stillGenerating: string;
}
> = {
en: {
untitled: 'Untitled chat',
aiAgent: 'AI agent',
you: 'You',
tools: {
searchPages: 'Searched pages',
getPage: 'Read page',
createPage: 'Created page',
updatePageContent: 'Updated page',
renamePage: 'Renamed page',
movePage: 'Moved page',
deletePage: 'Deleted page (to trash)',
createComment: 'Commented',
resolveComment: 'Resolved comment',
},
ranTool: (name) => `Ran tool ${name}`,
stillGenerating:
'This message is still being generated — the export captured a partial, in-progress response.',
},
ru: {
untitled: 'Без названия',
aiAgent: 'ИИ-агент',
you: 'Вы',
tools: {
searchPages: 'Искал по страницам',
getPage: 'Прочитал страницу',
createPage: 'Создал страницу',
updatePageContent: 'Обновил страницу',
renamePage: 'Переименовал страницу',
movePage: 'Переместил страницу',
deletePage: 'Удалил страницу (в корзину)',
createComment: 'Прокомментировал',
resolveComment: 'Закрыл комментарий',
},
ranTool: (name) => `Выполнил инструмент ${name}`,
stillGenerating:
'Это сообщение всё ещё генерируется — экспорт захватил частичный, незавершённый ответ.',
},
};
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
function isToolPart(type: string): boolean {
return type.startsWith('tool-') || type === 'dynamic-tool';
}
/** Extract the tool name from a part `type` of `tool-${name}` (or dynamic). */
function getToolName(part: ExportPart): string {
if (part.type === 'dynamic-tool') return part.toolName ?? '';
return part.type.startsWith('tool-')
? part.type.slice('tool-'.length)
: part.type;
}
/** Map an AI SDK tool-part state to the 3 states the action-log renders. */
function toolRunState(state: string | undefined): 'running' | 'done' | 'error' {
if (state === 'output-error' || state === 'output-denied') return 'error';
if (state === 'output-available') return 'done';
return 'running';
}
/** Resolve a tool's friendly action-log label (localized) from its name. */
function toolLabel(name: string, lang: ExportLang): string {
return LABELS[lang].tools[name] ?? LABELS[lang].ranTool(name);
}
/**
* Stringify an arbitrary tool input/output value for a fenced block. Strings
* pass through as-is; everything else is pretty-printed JSON, falling back to
* `String(value)` if serialization throws (e.g. a circular structure).
*/
function stringify(value: unknown): string {
if (typeof value === 'string') return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
/**
* Wrap `code` in a fenced code block whose backtick delimiter is LONGER than the
* longest backtick run inside the content, so embedded backticks (or a literal
* ``` fence) never break out of the block. Minimum 3 backticks.
*/
function fence(code: string, lang = ''): string {
const runs: string[] = code.match(/`+/g) ?? [];
const longest = runs.reduce((m, s) => Math.max(m, s.length), 0);
const delim = '`'.repeat(Math.max(3, longest + 1));
return `${delim}${lang}\n${code}\n${delim}`;
}
/** Per-row token count, mirroring the header sum in the client window. */
function rowTokens(usage: UsageLike): number {
return (
usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
);
}
/** Render one message's UIMessage parts into an array of Markdown blocks
* (text blocks + tool blocks). Mirrors the client renderer / MessageItem. */
function renderMessageParts(parts: ExportPart[], lang: ExportLang): string[] {
const out: string[] = [];
for (const part of parts) {
if (part.type === 'text') {
const text = (part.text ?? '').trim();
if (text.length > 0) out.push(text);
continue;
}
if (!isToolPart(part.type)) continue;
const name = getToolName(part);
const label = toolLabel(name, lang);
const state = toolRunState(part.state);
const toolLines: string[] = [`**Tool: ${label}** (\`${name}\`) — ${state}`];
if (part.input !== undefined) {
toolLines.push('Input:');
toolLines.push(fence(stringify(part.input), 'json'));
}
if (part.output !== undefined) {
toolLines.push('Output:');
toolLines.push(fence(stringify(part.output), 'json'));
}
if (part.errorText) {
toolLines.push(`**Error:** ${part.errorText}`);
}
out.push(toolLines.join('\n\n'));
}
return out;
}
/** Resolve a persisted row's parts: prefer the rich persisted parts, else a
* single text part built from the plain-text content (mirrors rowToUiMessage). */
function rowParts(row: AiChatMessage): ExportPart[] {
const meta = (row.metadata ?? {}) as { parts?: ExportPart[] };
return Array.isArray(meta.parts) && meta.parts.length > 0
? meta.parts
: [{ type: 'text', text: row.content ?? '' }];
}
/**
* Serialize a chat to a Markdown string from its persisted rows. Source = DB
* ONLY (no live client state). A row whose `status` is still 'streaming' is an
* interrupted turn that the export captured mid-flight; it is rendered up to its
* last finished step and flagged "still generating".
*/
export function buildChatMarkdown(args: {
title: string | null;
chatId: string;
rows: AiChatMessage[];
// Accepts a full client locale tag (e.g. 'en-US'/'ru-RU'); normalized below.
lang?: string;
}): string {
const { title, chatId, rows } = args;
const lang: ExportLang = normalizeLang(args.lang);
const L = LABELS[lang];
const blocks: string[] = [];
const heading = (title ?? '').trim() || L.untitled;
blocks.push(`# ${heading}`);
const usageOf = (row: AiChatMessage): UsageLike | undefined => {
const meta = (row.metadata ?? {}) as { usage?: UsageLike };
return meta.usage;
};
const errorOf = (row: AiChatMessage): string | undefined => {
const meta = (row.metadata ?? {}) as { error?: string };
return meta.error;
};
// Metadata bullet list. Total tokens is only shown when there is a sum.
const totalTokens = rows.reduce((sum, row) => {
const usage = usageOf(row);
return usage ? sum + rowTokens(usage) : sum;
}, 0);
const meta = [
`- Chat ID: \`${chatId}\``,
`- Exported: ${new Date().toISOString()}`,
`- Messages: ${rows.length}`,
];
if (totalTokens > 0) meta.push(`- Total tokens: ${totalTokens}`);
blocks.push(meta.join('\n'));
rows.forEach((row, index) => {
blocks.push('---');
const roleLabel = row.role === 'assistant' ? L.aiAgent : L.you;
blocks.push(`## ${index + 1}. ${roleLabel}`);
// Created-at kept in source as an HTML comment (out of the rendered prose).
if (row.createdAt) {
const iso =
row.createdAt instanceof Date
? row.createdAt.toISOString()
: String(row.createdAt);
blocks.push(`<!-- ${iso} -->`);
}
blocks.push(...renderMessageParts(rowParts(row), lang));
// A still-'streaming' row is an interrupted/in-progress turn captured by the
// export; record that so the partial answer is not mistaken for complete.
if (row.status === 'streaming') {
blocks.push(`_⏳ ${L.stillGenerating}_`);
}
const error = errorOf(row);
if (error) {
blocks.push(`**⚠️ Error:** ${error}`);
}
const usage = usageOf(row);
if (usage) {
const total = usage.totalTokens ?? rowTokens(usage);
const reasoning =
usage.reasoningTokens && usage.reasoningTokens > 0
? `, reasoning: ${usage.reasoningTokens}`
: '';
blocks.push(
`_Tokens — in: ${usage.inputTokens ?? '?'}, out: ${
usage.outputTokens ?? '?'
}${reasoning}, total: ${total}_`,
);
}
});
// Blank line between blocks so the Markdown renders cleanly.
return blocks.join('\n\n');
}

View File

@@ -26,3 +26,17 @@ export class GetChatMessagesDto {
@IsString()
cursor?: string;
}
/** Export a chat to Markdown (#183). `lang` localizes the few fixed
* role/tool-action labels; defaults to English server-side. */
export class ExportChatDto {
@IsString()
chatId: string;
// A full client locale tag (e.g. 'en-US', 'ru-RU') — normalized server-side to
// a supported export language (see normalizeLang). Accept any string so a
// region-qualified locale is not rejected (the 400 that broke the real client).
@IsOptional()
@IsString()
lang?: string;
}

View File

@@ -42,6 +42,15 @@ export class CreateMcpServerDto {
@IsString({ each: true })
toolAllowlist?: string[];
// Admin-authored guidance ("how/when to use this server's tools") injected
// into the agent system prompt next to the tool descriptions (#180). Trusted,
// NON-secret (so it IS returned). Capped to bound prompt/token size (the
// built-in guide is ~1.5KB). Blank => stored as null.
@IsOptional()
@IsString()
@MaxLength(4000)
instructions?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;

View File

@@ -0,0 +1,75 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { CreateMcpServerDto } from './create-mcp-server.dto';
import { UpdateMcpServerDto } from './update-mcp-server.dto';
/**
* API-boundary validation for the per-server `instructions` field (#180): a free
* text guide injected into the agent system prompt. It is optional, must be a
* string, and is bounded by @MaxLength(4000) to cap prompt/token size.
*/
describe('MCP server DTO instructions validation', () => {
function validateCreate(payload: unknown) {
const dto = plainToInstance(CreateMcpServerDto, payload);
return validateSync(dto as object);
}
function validateUpdate(payload: unknown) {
const dto = plainToInstance(UpdateMcpServerDto, payload);
return validateSync(dto as object);
}
const base = {
name: 'Tavily',
transport: 'http',
url: 'https://example.com/mcp',
};
it('accepts an omitted instructions field on create', () => {
expect(validateCreate({ ...base })).toHaveLength(0);
});
it('accepts a reasonable instructions string on create', () => {
expect(
validateCreate({ ...base, instructions: 'Use search for fresh facts.' }),
).toHaveLength(0);
});
it('rejects instructions over MaxLength(4000) on create', () => {
const errors = validateCreate({
...base,
instructions: 'a'.repeat(4001),
});
expect(
errors.some(
(e) =>
e.property === 'instructions' &&
e.constraints !== undefined &&
'maxLength' in e.constraints,
),
).toBe(true);
});
it('accepts instructions of exactly 4000 chars on create', () => {
expect(
validateCreate({ ...base, instructions: 'a'.repeat(4000) }),
).toHaveLength(0);
});
it('rejects a non-string instructions value', () => {
const errors = validateCreate({ ...base, instructions: 123 });
expect(errors.some((e) => e.property === 'instructions')).toBe(true);
});
it('rejects instructions over MaxLength(4000) on update', () => {
const errors = validateUpdate({ instructions: 'a'.repeat(4001) });
expect(
errors.some(
(e) =>
e.property === 'instructions' &&
e.constraints !== undefined &&
'maxLength' in e.constraints,
),
).toBe(true);
});
});

View File

@@ -43,6 +43,13 @@ export class UpdateMcpServerDto {
@IsString({ each: true })
toolAllowlist?: string[];
// Admin-authored prompt guidance (#180). Absent => unchanged; blank => cleared
// (stored as null by the repo). Capped to bound prompt/token size.
@IsOptional()
@IsString()
@MaxLength(4000)
instructions?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;

View File

@@ -33,6 +33,26 @@ interface ServerOutcome {
reason?: string;
}
/**
* One server's admin-authored guidance for the agent system prompt (#180).
* Built ONLY for a server that actually connected AND contributed ≥1 tool
* (after the allowlist filter) AND has non-blank guidance — so a guide never
* appears for a server whose tools the agent cannot actually call.
*/
export interface McpServerInstruction {
/** Display name of the server (for the prompt section header). */
serverName: string;
/**
* The tool-name namespace prefix the server's tools were merged under
* (sanitized name, e.g. `tavily`). The prompt renders this as `tavily_*` so
* the model can connect the guidance to the actual tool names. Advisory:
* individual tools may carry a disambiguating suffix on rare collisions.
*/
toolPrefix: string;
/** The trusted, non-blank guidance text. */
instructions: string;
}
export interface ExternalToolset {
/** Namespaced external tools, merge-ready into the agent toolset. */
tools: Record<string, Tool>;
@@ -40,6 +60,11 @@ export interface ExternalToolset {
clients: Closable[];
/** Per-server connect outcomes so the UI can show unavailable servers. */
outcomes: ServerOutcome[];
/**
* Per-server prompt guidance for connected servers that contributed ≥1 tool
* and have non-blank instructions. Empty when no server qualifies.
*/
instructions: McpServerInstruction[];
}
/** Connect+tools() timeout per server — a slow server must not stall the turn. */
@@ -60,6 +85,8 @@ interface CacheEntry {
tools: Record<string, Tool>;
clients: McpClient[];
outcomes: ServerOutcome[];
/** Prompt guidance for qualifying servers (see McpServerInstruction). */
instructions: McpServerInstruction[];
expiresAt: number;
/** Active leases (turns currently using these clients). */
refCount: number;
@@ -141,6 +168,7 @@ export class McpClientsService {
tools: entry.tools,
clients: [release],
outcomes: entry.outcomes,
instructions: entry.instructions,
};
}
@@ -225,6 +253,7 @@ export class McpClientsService {
const outcomes: ServerOutcome[] = [];
// Per-call total wall-clock cap, read once for this build (env-overridable).
const callTimeoutMs = mcpCallTimeoutMs();
const instructions: McpServerInstruction[] = [];
for (const server of servers) {
try {
@@ -233,17 +262,33 @@ export class McpClientsService {
clients.push(client);
const allow = server.toolAllowlist;
const picked =
Array.isArray(allow) && allow.length > 0
? pick(raw, allow)
: raw;
Array.isArray(allow) && allow.length > 0 ? pick(raw, allow) : raw;
// Bound each tool's execute with a per-call total-timeout guard before
// merging, so a single chatty-but-stuck call is aborted after the cap.
const guarded = wrapToolsWithCallTimeout(picked, callTimeoutMs);
// Namespace each tool with the sanitized server name AND disambiguate
// against names already merged from earlier servers, so no external
// tool is silently overwritten on collision.
this.mergeNamespaced(tools, guarded, server.name, server.id);
// tool is silently overwritten on collision. The returned count drives
// whether this server's prompt guidance is included (≥1 tool merged).
const merged = this.mergeNamespaced(
tools,
guarded,
server.name,
server.id,
);
outcomes.push({ name: server.name, ok: true });
// Include this server's guidance ONLY when it actually contributed at
// least one tool the agent can call (allowlist may have filtered all of
// them out) AND the admin authored non-blank instructions. The header
// prefix is the sanitized server name (= the tool namespace prefix).
const guide = server.instructions?.trim();
if (merged.count > 0 && guide) {
instructions.push({
serverName: server.name,
toolPrefix: merged.prefix,
instructions: guide,
});
}
} catch (err) {
// A failed server is skipped — the turn proceeds with the rest. Log a
// short warning (never the URL/headers) so ops can see degradation, and
@@ -260,6 +305,7 @@ export class McpClientsService {
tools,
clients,
outcomes,
instructions,
expiresAt: Date.now() + CACHE_TTL_MS,
refCount: 0,
evicted: false,
@@ -276,16 +322,19 @@ export class McpClientsService {
* renaming any key that would collide with an already-merged tool (different
* servers with the same sanitized name, or duplicates after truncation), so
* no external tool is silently dropped via overwrite.
*
* Returns how many tools this server actually contributed and the namespace
* prefix used (the sanitized server name) so the caller can attach the
* server's prompt guidance only when ≥1 tool was merged.
*/
private mergeNamespaced(
target: Record<string, Tool>,
picked: Record<string, Tool>,
serverName: string,
serverId: string,
): void {
for (const [name, tool] of Object.entries(
namespace(picked, serverName),
)) {
): { count: number; prefix: string } {
let count = 0;
for (const [name, tool] of Object.entries(namespace(picked, serverName))) {
let key = name;
if (key in target) {
const original = key;
@@ -295,7 +344,9 @@ export class McpClientsService {
);
}
target[key] = tool;
count += 1;
}
return { count, prefix: namespacePrefix(serverName) };
}
/**
@@ -371,9 +422,7 @@ export class McpClientsService {
/** Close clients, swallowing close errors so they never break a response. */
private async closeClients(clients: McpClient[]): Promise<void> {
await Promise.all(
clients.map((c) => c.close().catch(() => undefined)),
);
await Promise.all(clients.map((c) => c.close().catch(() => undefined)));
}
}
@@ -386,9 +435,10 @@ export class McpClientsService {
* lookup hands net/tls.connect ONLY a set that passed this check, so the kernel
* can never connect to an address that did not pass the guard. Pure — no I/O.
*/
export function validateResolvedAddresses(
addrs: readonly LookupAddress[],
): { ok: boolean; blockedHost?: string } {
export function validateResolvedAddresses(addrs: readonly LookupAddress[]): {
ok: boolean;
blockedHost?: string;
} {
if (addrs.length === 0) {
return { ok: false };
}
@@ -524,7 +574,7 @@ function namespace(
tools: Record<string, Tool>,
serverName: string,
): Record<string, Tool> {
const prefix = sanitizeName(serverName) || 'mcp';
const prefix = namespacePrefix(serverName);
const out: Record<string, Tool> = {};
for (const [name, t] of Object.entries(tools)) {
const safe = sanitizeName(name);
@@ -539,6 +589,15 @@ function namespace(
return out;
}
/**
* The tool-name namespace prefix for a server: its sanitized name, or `mcp`
* when the name sanitizes to empty. Tools are merged as `${prefix}_${tool}`, so
* the prompt guidance refers to the server's tools as `${prefix}_*`.
*/
function namespacePrefix(serverName: string): string {
return sanitizeName(serverName) || 'mcp';
}
/** Reduce an arbitrary string to ^[a-zA-Z0-9_-]+, collapsing runs to '_'. */
function sanitizeName(value: string): string {
return value

View File

@@ -0,0 +1,168 @@
import { type Tool } from 'ai';
import { McpClientsService } from './mcp-clients.service';
/**
* Tests for the per-server prompt guidance (#180) assembled by buildEntry and
* surfaced via toolsFor().instructions.
*
* REACHABILITY NOTE: buildEntry is a PRIVATE method; the smallest reachable
* public path is toolsFor() -> getOrBuildEntry -> buildEntry -> connect/tools()
* -> mergeNamespaced. We drive that path: stub the repo's `listEnabled` and spy
* on the private `connect` to return fake MCP clients whose `tools()` we control.
*
* Contract (all checked here): a server's guidance is included ONLY when the
* server actually connected AND contributed ≥1 callable tool (after the
* allowlist filter) AND its instructions are non-blank. The header carries the
* tool namespace prefix (the sanitized server name).
*/
function fakeTool(): Tool {
return { description: 'x', inputSchema: undefined } as unknown as Tool;
}
interface FakeServer {
id: string;
name: string;
transport: string;
url: string;
headersEnc: string | null;
toolAllowlist: string[] | null;
instructions: string | null;
}
function server(
over: Partial<FakeServer> & { id: string; name: string },
): FakeServer {
return {
transport: 'http',
url: 'https://example.com/mcp',
headersEnc: null,
toolAllowlist: null,
instructions: null,
...over,
};
}
async function instructionsFor(
servers: FakeServer[],
toolsByServerId: Record<string, Record<string, Tool>>,
// Server ids whose connect should THROW (simulating an unavailable server).
failingIds: Set<string> = new Set(),
): Promise<
{
serverName: string;
toolPrefix: string;
instructions: string;
}[]
> {
const repoStub = {
listEnabled: jest.fn().mockResolvedValue(servers),
};
const service = new McpClientsService(repoStub as never, {} as never);
jest
.spyOn(
service as unknown as { connect: (s: FakeServer) => unknown },
'connect',
)
.mockImplementation((s: FakeServer) => {
if (failingIds.has(s.id)) {
return Promise.reject(new Error('connection failed'));
}
return Promise.resolve({
tools: () => Promise.resolve(toolsByServerId[s.id] ?? {}),
close: () => Promise.resolve(),
});
});
const toolset = await service.toolsFor('ws-1');
await Promise.all(toolset.clients.map((c) => c.close()));
return toolset.instructions;
}
describe('external MCP per-server prompt guidance (via toolsFor)', () => {
afterEach(() => jest.restoreAllMocks());
it('includes guidance for a connected server with non-empty text and ≥1 tool', async () => {
const instructions = await instructionsFor(
[
server({
id: 'id-tavily',
name: 'Tavily',
instructions: 'Use tavily_search for fresh facts.',
}),
],
{ 'id-tavily': { search: fakeTool() } },
);
// sanitizeName preserves case (charset [a-zA-Z0-9_-]), so the prefix is the
// server name as-is for an already-clean name.
expect(instructions).toEqual([
{
serverName: 'Tavily',
toolPrefix: 'Tavily',
instructions: 'Use tavily_search for fresh facts.',
},
]);
});
it('omits guidance when the server has no instructions', async () => {
const instructions = await instructionsFor(
[server({ id: 'id-1', name: 'Tavily', instructions: null })],
{ 'id-1': { search: fakeTool() } },
);
expect(instructions).toEqual([]);
});
it('omits guidance when the instructions are only whitespace', async () => {
const instructions = await instructionsFor(
[server({ id: 'id-1', name: 'Tavily', instructions: ' ' })],
{ 'id-1': { search: fakeTool() } },
);
expect(instructions).toEqual([]);
});
it('omits guidance for a server that contributed ZERO tools (allowlist filtered all out)', async () => {
const instructions = await instructionsFor(
[
server({
id: 'id-1',
name: 'Tavily',
instructions: 'guide',
// Allowlist names a tool the server does not expose -> 0 picked.
toolAllowlist: ['nonexistent'],
}),
],
{ 'id-1': { search: fakeTool() } },
);
expect(instructions).toEqual([]);
});
it('omits guidance for an unavailable (failed-connect) server', async () => {
const instructions = await instructionsFor(
[server({ id: 'id-1', name: 'Tavily', instructions: 'guide' })],
{ 'id-1': { search: fakeTool() } },
new Set(['id-1']),
);
expect(instructions).toEqual([]);
});
it('includes only the qualifying servers among several', async () => {
const instructions = await instructionsFor(
[
server({ id: 'ok', name: 'Tavily', instructions: 'web guide' }),
server({ id: 'blank', name: 'Crawl', instructions: '' }),
server({ id: 'down', name: 'Down', instructions: 'never shown' }),
],
{
ok: { search: fakeTool() },
blank: { crawl: fakeTool() },
down: { x: fakeTool() },
},
new Set(['down']),
);
expect(instructions).toEqual([
{ serverName: 'Tavily', toolPrefix: 'Tavily', instructions: 'web guide' },
]);
});
});

View File

@@ -17,6 +17,7 @@ function row(overrides: Partial<AiMcpServer>): AiMcpServer {
enabled: true,
toolAllowlist: null,
headersEnc: null,
instructions: null,
...overrides,
} as unknown as AiMcpServer;
}
@@ -28,11 +29,7 @@ describe('McpServersService.toView (via list) — encrypted-header leak guard',
};
// secretBox + clients are unused by the list/toView path; pass stubs to
// satisfy the constructor.
return new McpServersService(
repoStub as never,
{} as never,
{} as never,
);
return new McpServersService(repoStub as never, {} as never, {} as never);
}
it('exposes hasHeaders:true and NO headersEnc when auth headers are set', async () => {
@@ -67,6 +64,7 @@ describe('McpServersService.toView (via list) — encrypted-header leak guard',
enabled: false,
toolAllowlist: ['search'],
headersEnc: 'BLOB',
instructions: 'Use search for fresh web facts.',
}),
]);
@@ -80,6 +78,19 @@ describe('McpServersService.toView (via list) — encrypted-header leak guard',
enabled: false,
toolAllowlist: ['search'],
hasHeaders: true,
instructions: 'Use search for fresh web facts.',
});
});
it('returns instructions (NON-secret) in the view, null when unset', async () => {
const service = buildService([
row({ id: 'a', instructions: 'How to use these tools.' }),
row({ id: 'b', instructions: null }),
]);
const [withText, withoutText] = await service.list('ws-1');
expect(withText.instructions).toBe('How to use these tools.');
expect(withoutText.instructions).toBeNull();
});
});

View File

@@ -20,6 +20,9 @@ export interface McpServerView {
enabled: boolean;
toolAllowlist: string[] | null;
hasHeaders: boolean;
// Admin-authored prompt guidance (#180). NON-secret, so returned in the view.
// Null when no guidance is configured.
instructions: string | null;
}
/**
@@ -56,6 +59,8 @@ export class McpServersService {
url: dto.url,
headersEnc,
toolAllowlist: dto.toolAllowlist ?? null,
// Blank/whitespace guidance is normalized to null by the repo.
instructions: dto.instructions ?? null,
enabled: dto.enabled ?? true,
});
this.clients.invalidate(workspaceId);
@@ -97,6 +102,8 @@ export class McpServersService {
headersEnc,
// undefined => unchanged; [] / value handled by repo (empty => null).
toolAllowlist: dto.toolAllowlist,
// undefined => unchanged; blank => cleared (null) by the repo.
instructions: dto.instructions,
enabled: dto.enabled,
});
this.clients.invalidate(workspaceId);
@@ -167,6 +174,7 @@ export class McpServersService {
enabled: row.enabled,
toolAllowlist: row.toolAllowlist ?? null,
hasHeaders: Boolean(row.headersEnc),
instructions: row.instructions ?? null,
};
}
}

View File

@@ -1,30 +0,0 @@
import { jsonbObject } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
/**
* Unit tests for jsonbObject: the repo helper that encodes a model_config object
* as a jsonb bind (or null when there is nothing to persist). It is the last
* line of defence before the column write, so the null-vs-bind decision is what
* matters here. We assert only null vs non-null because the non-null value is a
* kysely `sql` template fragment whose internal shape is an implementation
* detail of the SQL tag.
*/
describe('jsonbObject', () => {
it('returns null for null', () => {
expect(jsonbObject(null)).toBeNull();
});
it('returns null for undefined', () => {
expect(jsonbObject(undefined)).toBeNull();
});
it('returns null for an empty object (nothing to persist)', () => {
expect(jsonbObject({})).toBeNull();
});
it('returns a (non-null) jsonb bind for a non-empty object', () => {
const out = jsonbObject({ driver: 'gemini', chatModel: 'gemini-2.0-flash' });
// A real sql fragment is produced, never null/undefined.
expect(out).not.toBeNull();
expect(out).toBeDefined();
});
});

View File

@@ -0,0 +1,44 @@
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
/**
* Create/retarget a vanity alias for a page. `confirmReassign` is the
* two-step guard for the "address already points at another page" case: the
* first call without it gets a 409 carrying the current target, the client
* confirms, and retries with `confirmReassign: true`.
*/
export class SetShareAliasDto {
@IsString()
@IsNotEmpty()
pageId: string;
@IsString()
@IsNotEmpty()
alias: string;
@IsBoolean()
@IsOptional()
confirmReassign?: boolean;
}
export class RemoveShareAliasDto {
@IsString()
@IsNotEmpty()
aliasId: string;
}
export class ShareAliasAvailabilityDto {
@IsString()
@IsNotEmpty()
alias: string;
}
export class ShareAliasForPageDto {
@IsString()
@IsNotEmpty()
pageId: string;
}

View File

@@ -0,0 +1,252 @@
import * as fs from 'node:fs';
// `@sindresorhus/slugify` is ESM-only and not in jest's transformIgnorePatterns,
// so the real module fails to parse under ts-jest. Stub it with a minimal,
// deterministic slugifier — this spec asserts the controller's slug *assembly*
// (`<title-slug>-<slugId>`, 70-char clamp, `untitled` fallback), not the upstream
// slug algorithm. The factory keeps the real ESM module from ever being loaded.
jest.mock('@sindresorhus/slugify', () => ({
__esModule: true,
default: (input: string) =>
String(input)
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, ''),
}));
import { ShareAliasRedirectController } from './share-alias-redirect.controller';
/**
* Routing/leak guard for the PUBLIC `GET /l/:alias` resolver.
*
* This is the most security-sensitive surface of the alias feature: an
* unauthenticated route that MUST serve the plain SPA index (exactly like any
* unknown path) for an unknown / dangling / no-longer-readable alias so that the
* existence of a name never leaks. Only a resolvable, still-readable alias may
* 302 to the canonical `/share/<key>/p/<title-slug>-<slugId>` page (302 — never
* 301 — because the target is retargetable). These tests pin that routing and
* the defensive percent-decoding, mirroring `share-seo.controller.routing.spec`.
*/
const STREAM_SENTINEL = { __isStream: true } as unknown as fs.ReadStream;
// Stub fs at CALL time (jest.spyOn), NOT module load (jest.mock): the controller
// transitively pulls bcrypt, whose native module is located by node-gyp-build
// reading the filesystem at import time — a module-level fs mock breaks that.
beforeEach(() => {
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'createReadStream').mockReturnValue(STREAM_SENTINEL);
});
afterEach(() => jest.restoreAllMocks());
function makeRes() {
const res: any = {
sent: undefined as unknown,
statusCode: undefined as number | undefined,
redirectUrl: undefined as string | undefined,
type: jest.fn(() => res),
status: jest.fn((code: number) => {
res.statusCode = code;
return res;
}),
send: jest.fn((v: unknown) => {
res.sent = v;
return res;
}),
redirect: jest.fn((url: string, code: number) => {
res.redirectUrl = url;
res.statusCode = code;
return res;
}),
};
return res;
}
function makeController(opts: {
resolved?: { share: any; page: any } | null;
selfHosted?: boolean;
}) {
const shareAliasService = {
resolveReadableTarget: jest.fn(async () => opts.resolved ?? null),
};
const workspaceRepo = {
findFirst: jest.fn(async () => ({ id: 'ws-self' })),
findByHostname: jest.fn(async (sub: string) =>
sub === 'acme' ? { id: 'ws-acme' } : null,
),
};
const environmentService = {
isSelfHosted: jest.fn(() => opts.selfHosted ?? true),
};
const controller = new ShareAliasRedirectController(
shareAliasService as any,
workspaceRepo as any,
environmentService as any,
);
return { controller, shareAliasService, workspaceRepo, environmentService };
}
const selfReq: any = { raw: { headers: { host: 'self' } } };
describe('ShareAliasRedirectController.resolve', () => {
it('302-redirects a resolvable alias to the canonical share page', async () => {
const { controller, shareAliasService } = makeController({
resolved: {
share: { key: 'SHAREKEY' },
page: { slugId: 'abc123', title: 'Quarterly Report' },
},
});
const res = makeRes();
await controller.resolve('promo', selfReq, res);
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith(
'promo',
'ws-self',
);
expect(res.redirect).toHaveBeenCalledWith(
'/share/SHAREKEY/p/quarterly-report-abc123',
302,
);
// No index stream was served on a hit.
expect(res.sent).toBeUndefined();
});
it('falls back to "untitled" in the slug when the target has no title', async () => {
const { controller } = makeController({
resolved: { share: { key: 'K' }, page: { slugId: 'sid', title: '' } },
});
const res = makeRes();
await controller.resolve('promo', selfReq, res);
expect(res.redirect).toHaveBeenCalledWith('/share/K/p/untitled-sid', 302);
});
it('clamps the title-slug to the first 70 characters of the page title', async () => {
// 119-char title; only the first 70 chars must reach the slug. The 70-char
// boundary deliberately falls mid-word ("Entire" -> "entir") so the clamp is
// unambiguous: anything past char 70 ("...e Fiscal Year...") must be dropped.
const longTitle =
'The Comprehensive Quarterly Financial Performance Report For The Entire Fiscal Year Two Thousand Twenty Five And Beyond';
const { controller } = makeController({
resolved: {
share: { key: 'K' },
page: { slugId: 'sid', title: longTitle },
},
});
const res = makeRes();
await controller.resolve('promo', selfReq, res);
expect(res.redirect).toHaveBeenCalledWith(
'/share/K/p/the-comprehensive-quarterly-financial-performance-report-for-the-entir-sid',
302,
);
});
it('streams the SPA index WITHOUT a 302 for an unknown/dangling/unreadable alias (no leak)', async () => {
const { controller, shareAliasService } = makeController({ resolved: null });
const res = makeRes();
await controller.resolve('does-not-exist', selfReq, res);
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalled();
// The plain index stream was served and no redirect leaked alias existence.
expect(res.redirect).not.toHaveBeenCalled();
expect(res.sent).toBe(STREAM_SENTINEL);
expect(res.type).toHaveBeenCalledWith('text/html');
});
it('streams the SPA index without even resolving when the workspace is null', async () => {
// Subdomain host that maps to no workspace => workspace === null.
const { controller, shareAliasService, workspaceRepo } = makeController({
selfHosted: false,
});
const res = makeRes();
const req: any = { raw: { headers: { host: 'unknown.example.com' } } };
await controller.resolve('promo', req, res);
expect(workspaceRepo.findByHostname).toHaveBeenCalledWith('unknown');
// Never even attempts to resolve (alias existence cannot leak per-host).
expect(shareAliasService.resolveReadableTarget).not.toHaveBeenCalled();
expect(res.redirect).not.toHaveBeenCalled();
expect(res.sent).toBe(STREAM_SENTINEL);
});
it('defensively decodes broken percent-encoding and treats it as unknown', async () => {
const { controller, shareAliasService } = makeController({ resolved: null });
const res = makeRes();
// '%E0%A4%A' is invalid -> decodeURIComponent throws -> raw value is used,
// and the alias resolves to nothing (no crash, served as index).
await controller.resolve('%E0%A4%A', selfReq, res);
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith(
'%E0%A4%A',
'ws-self',
);
expect(res.redirect).not.toHaveBeenCalled();
expect(res.sent).toBe(STREAM_SENTINEL);
});
it('decodes a valid percent-encoded alias before resolving', async () => {
const { controller, shareAliasService } = makeController({ resolved: null });
const res = makeRes();
await controller.resolve('my%2Dlink', selfReq, res);
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith(
'my-link',
'ws-self',
);
});
it('resolves the workspace via findFirst on the self-hosted path', async () => {
const { controller, workspaceRepo, shareAliasService } = makeController({
selfHosted: true,
resolved: null,
});
const res = makeRes();
await controller.resolve('promo', selfReq, res);
expect(workspaceRepo.findFirst).toHaveBeenCalled();
expect(workspaceRepo.findByHostname).not.toHaveBeenCalled();
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith(
'promo',
'ws-self',
);
});
it('resolves the workspace via findByHostname (subdomain) on the cloud path', async () => {
const { controller, workspaceRepo, shareAliasService } = makeController({
selfHosted: false,
resolved: null,
});
const res = makeRes();
const req: any = { raw: { headers: { host: 'acme.example.com' } } };
await controller.resolve('promo', req, res);
expect(workspaceRepo.findByHostname).toHaveBeenCalledWith('acme');
expect(workspaceRepo.findFirst).not.toHaveBeenCalled();
expect(shareAliasService.resolveReadableTarget).toHaveBeenCalledWith(
'promo',
'ws-acme',
);
});
it('serves a 404 when no built client index exists', async () => {
jest.spyOn(fs, 'existsSync').mockReturnValue(false);
const { controller } = makeController({ resolved: null });
const res = makeRes();
await controller.resolve('promo', selfReq, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.redirect).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,95 @@
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
import { FastifyReply, FastifyRequest } from 'fastify';
import { join } from 'path';
import * as fs from 'node:fs';
import slugify from '@sindresorhus/slugify';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { Workspace } from '@docmost/db/types/entity.types';
import { ShareAliasService } from './share-alias.service';
/**
* Public resolver for vanity links `GET /l/:alias`. Excluded from the global
* `/api` prefix (see main.ts) and parallel to ShareSeoController.
*
* On a hit it issues a 302 (NEVER 301) to the canonical
* `/share/:key/p/:slug` page, so:
* - the existing share render + SSR meta is reused verbatim (crawlers follow
* the 302 and get the correct preview);
* - because the alias target is mutable, a temporary redirect is always
* re-resolved — a cached 301 would pin clients to the pre-swap page.
*
* Any unknown / dangling / no-longer-readable alias serves the plain SPA index
* (same as any unknown path) so the existence of a name never leaks.
*/
@Controller('l')
export class ShareAliasRedirectController {
constructor(
private readonly shareAliasService: ShareAliasService,
private readonly workspaceRepo: WorkspaceRepo,
private readonly environmentService: EnvironmentService,
) {}
@Get(':alias')
async resolve(
@Param('alias') rawAlias: string,
@Req() req: FastifyRequest,
@Res({ passthrough: false }) res: FastifyReply,
) {
// NestJS does not apply middlewares to paths excluded from the global /api
// prefix, so the DomainMiddleware workspace resolution is duplicated here
// (same workaround as ShareSeoController).
let workspace: Workspace = null;
if (this.environmentService.isSelfHosted()) {
workspace = await this.workspaceRepo.findFirst();
} else {
const header = req.raw.headers.host;
const subdomain = header?.split('.')[0];
workspace = subdomain
? await this.workspaceRepo.findByHostname(subdomain)
: null;
}
const clientDistPath = join(__dirname, '..', '..', '..', '..', 'client/dist');
const indexFilePath = join(clientDistPath, 'index.html');
let decoded = rawAlias;
try {
decoded = decodeURIComponent(rawAlias);
} catch {
// Malformed percent-encoding -> treat as unknown alias.
}
const resolved = workspace
? await this.shareAliasService.resolveReadableTarget(
decoded,
workspace.id,
)
: null;
if (!resolved) {
return this.sendIndex(indexFilePath, res);
}
const slug = buildPageSlug(resolved.page.slugId, resolved.page.title);
// 302, NOT 301: the alias is retargetable, so the redirect must always be
// re-resolved by clients/crawlers.
return res.redirect(`/share/${resolved.share.key}/p/${slug}`, 302);
}
private sendIndex(indexFilePath: string, res: FastifyReply) {
if (!fs.existsSync(indexFilePath)) {
// No built client (e.g. API-only dev): nothing to serve.
res.status(404).send('Not found');
return;
}
const stream = fs.createReadStream(indexFilePath);
res.type('text/html').send(stream);
}
}
/** Canonical share page slug: `<title-slug>-<slugId>` (mirrors the client). */
function buildPageSlug(slugId: string, title?: string): string {
const titleSlug = slugify(title?.substring(0, 70) || 'untitled');
return `${titleSlug}-${slugId}`;
}

View File

@@ -0,0 +1,260 @@
import {
BadRequestException,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { ShareAliasController } from './share-alias.controller';
/**
* Authz-gate tests for the authenticated alias management controller. The access
* decisions for creating/retargeting/removing an alias live in THIS controller
* (the service spec delegates authorization to the caller), so each gate is
* pinned here against mocked PageRepo / ShareService / ShareAliasService /
* PageAccessService. A regression that drops any gate must fail here.
*/
describe('ShareAliasController authz gates', () => {
function makeController() {
const shareAliasService = {
setAlias: jest.fn(async () => ({ id: 'alias-1' })),
removeAlias: jest.fn(async () => undefined),
getAliasById: jest.fn(),
getAliasForPage: jest.fn(),
checkAvailability: jest.fn(),
};
const shareService = {
resolveReadableSharePage: jest.fn(),
isSharingAllowed: jest.fn(),
};
const pageRepo = { findById: jest.fn() };
const pageAccessService = {
validateCanEdit: jest.fn(async () => undefined),
validateCanView: jest.fn(async () => undefined),
};
const controller = new ShareAliasController(
shareAliasService as any,
shareService as any,
pageRepo as any,
pageAccessService as any,
);
return {
controller,
shareAliasService,
shareService,
pageRepo,
pageAccessService,
};
}
const user: any = { id: 'u-1' };
const workspace: any = { id: 'ws-1' };
describe('set', () => {
it('throws NotFoundException for a nonexistent page', async () => {
const { controller, pageRepo, pageAccessService } = makeController();
pageRepo.findById.mockResolvedValue(null);
await expect(
controller.set({ pageId: 'p-x', alias: 'promo' } as any, user, workspace),
).rejects.toBeInstanceOf(NotFoundException);
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
});
it('throws NotFoundException for a page in another workspace', async () => {
const { controller, pageRepo } = makeController();
pageRepo.findById.mockResolvedValue({
id: 'p-1',
workspaceId: 'ws-OTHER',
});
await expect(
controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace),
).rejects.toBeInstanceOf(NotFoundException);
});
it('enforces validateCanEdit before setting the alias', async () => {
const { controller, pageRepo, pageAccessService, shareService } =
makeController();
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
pageAccessService.validateCanEdit.mockRejectedValue(
new ForbiddenException('no edit'),
);
await expect(
controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace),
).rejects.toBeInstanceOf(ForbiddenException);
// Gate short-circuits before any share resolution.
expect(shareService.resolveReadableSharePage).not.toHaveBeenCalled();
});
it('throws BadRequestException when the page is not publicly shared', async () => {
const { controller, pageRepo, shareService } = makeController();
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
shareService.resolveReadableSharePage.mockResolvedValue(null);
await expect(
controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace),
).rejects.toThrow('Page is not publicly shared');
await expect(
controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace),
).rejects.toBeInstanceOf(BadRequestException);
});
it('throws ForbiddenException when public sharing is disabled', async () => {
const { controller, pageRepo, shareService } = makeController();
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
shareService.resolveReadableSharePage.mockResolvedValue({
share: { spaceId: 'sp-1' },
});
shareService.isSharingAllowed.mockResolvedValue(false);
await expect(
controller.set({ pageId: 'p-1', alias: 'promo' } as any, user, workspace),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('delegates to setAlias on the happy path with all gates passed', async () => {
const { controller, pageRepo, shareService, shareAliasService } =
makeController();
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
shareService.resolveReadableSharePage.mockResolvedValue({
share: { spaceId: 'sp-1' },
});
shareService.isSharingAllowed.mockResolvedValue(true);
const result = await controller.set(
{ pageId: 'p-1', alias: 'promo', confirmReassign: true } as any,
user,
workspace,
);
expect(shareAliasService.setAlias).toHaveBeenCalledWith({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'promo',
confirmReassign: true,
});
expect(result).toEqual({ id: 'alias-1' });
});
});
describe('remove', () => {
it('throws NotFoundException for an unknown alias', async () => {
const { controller, shareAliasService } = makeController();
shareAliasService.getAliasById.mockResolvedValue(null);
await expect(
controller.remove({ aliasId: 'a-x' } as any, user, workspace),
).rejects.toBeInstanceOf(NotFoundException);
expect(shareAliasService.removeAlias).not.toHaveBeenCalled();
});
it('requires validateCanEdit on the current target before removing', async () => {
const { controller, shareAliasService, pageRepo, pageAccessService } =
makeController();
shareAliasService.getAliasById.mockResolvedValue({
id: 'a-1',
pageId: 'p-1',
});
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
pageAccessService.validateCanEdit.mockRejectedValue(
new ForbiddenException('no edit'),
);
await expect(
controller.remove({ aliasId: 'a-1' } as any, user, workspace),
).rejects.toBeInstanceOf(ForbiddenException);
expect(shareAliasService.removeAlias).not.toHaveBeenCalled();
});
it('removes a dangling alias (pageId null) WITHOUT an edit check', async () => {
const { controller, shareAliasService, pageRepo, pageAccessService } =
makeController();
shareAliasService.getAliasById.mockResolvedValue({
id: 'a-1',
pageId: null,
});
await controller.remove({ aliasId: 'a-1' } as any, user, workspace);
expect(pageRepo.findById).not.toHaveBeenCalled();
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
expect(shareAliasService.removeAlias).toHaveBeenCalledWith('a-1', 'ws-1');
});
it('removes when the editor can edit the current target', async () => {
const { controller, shareAliasService, pageRepo, pageAccessService } =
makeController();
shareAliasService.getAliasById.mockResolvedValue({
id: 'a-1',
pageId: 'p-1',
});
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
await controller.remove({ aliasId: 'a-1' } as any, user, workspace);
expect(pageAccessService.validateCanEdit).toHaveBeenCalled();
expect(shareAliasService.removeAlias).toHaveBeenCalledWith('a-1', 'ws-1');
});
it('removes even if the recorded target page no longer exists', async () => {
const { controller, shareAliasService, pageRepo, pageAccessService } =
makeController();
shareAliasService.getAliasById.mockResolvedValue({
id: 'a-1',
pageId: 'p-gone',
});
pageRepo.findById.mockResolvedValue(null);
await controller.remove({ aliasId: 'a-1' } as any, user, workspace);
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
expect(shareAliasService.removeAlias).toHaveBeenCalledWith('a-1', 'ws-1');
});
});
describe('forPage', () => {
it('throws NotFoundException for a cross-workspace/nonexistent page', async () => {
const { controller, pageRepo, pageAccessService } = makeController();
pageRepo.findById.mockResolvedValue({
id: 'p-1',
workspaceId: 'ws-OTHER',
});
await expect(
controller.forPage({ pageId: 'p-1' } as any, user, workspace),
).rejects.toBeInstanceOf(NotFoundException);
expect(pageAccessService.validateCanView).not.toHaveBeenCalled();
});
it('requires validateCanView and returns the alias (or null)', async () => {
const { controller, pageRepo, pageAccessService, shareAliasService } =
makeController();
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
shareAliasService.getAliasForPage.mockResolvedValue({ id: 'a-1' });
const result = await controller.forPage(
{ pageId: 'p-1' } as any,
user,
workspace,
);
expect(pageAccessService.validateCanView).toHaveBeenCalled();
expect(result).toEqual({ id: 'a-1' });
});
it('returns null when the page has no alias', async () => {
const { controller, pageRepo, shareAliasService } = makeController();
pageRepo.findById.mockResolvedValue({ id: 'p-1', workspaceId: 'ws-1' });
shareAliasService.getAliasForPage.mockResolvedValue(undefined);
const result = await controller.forPage(
{ pageId: 'p-1' } as any,
user,
workspace,
);
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,139 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PageAccessService } from '../page/page-access/page-access.service';
import { ShareService } from './share.service';
import { ShareAliasService } from './share-alias.service';
import {
RemoveShareAliasDto,
SetShareAliasDto,
ShareAliasAvailabilityDto,
ShareAliasForPageDto,
} from './dto/share-alias.dto';
/**
* Authenticated management of vanity `/l/:alias` links. The PUBLIC resolve path
* lives in `ShareAliasRedirectController` (`/l/:alias`); this controller only
* creates/retargets/removes/looks-up aliases for editors.
*/
@UseGuards(JwtAuthGuard)
@Controller('share-aliases')
export class ShareAliasController {
constructor(
private readonly shareAliasService: ShareAliasService,
private readonly shareService: ShareService,
private readonly pageRepo: PageRepo,
private readonly pageAccessService: PageAccessService,
) {}
@HttpCode(HttpStatus.OK)
@Post('set')
async set(
@Body() dto: SetShareAliasDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page || page.workspaceId !== workspace.id) {
throw new NotFoundException('Page not found');
}
// Editing the page is required to point an address at it.
await this.pageAccessService.validateCanEdit(page, user);
// The page must currently be publicly readable through the share graph; an
// alias to a non-shared page would only ever 404.
const resolved = await this.shareService.resolveReadableSharePage(
undefined,
page.id,
workspace.id,
);
if (!resolved) {
throw new BadRequestException('Page is not publicly shared');
}
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
resolved.share.spaceId,
);
if (!sharingAllowed) {
throw new ForbiddenException('Public sharing is disabled');
}
return this.shareAliasService.setAlias({
workspaceId: workspace.id,
pageId: page.id,
creatorId: user.id,
alias: dto.alias,
confirmReassign: dto.confirmReassign,
});
}
@HttpCode(HttpStatus.OK)
@Post('remove')
async remove(
@Body() dto: RemoveShareAliasDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const alias = await this.shareAliasService.getAliasById(
dto.aliasId,
workspace.id,
);
if (!alias) {
throw new NotFoundException('Alias not found');
}
// Only someone who can edit the (current) target page may free the address.
// A dangling alias (page deleted) can be removed by any workspace member.
if (alias.pageId) {
const page = await this.pageRepo.findById(alias.pageId);
if (page) {
await this.pageAccessService.validateCanEdit(page, user);
}
}
await this.shareAliasService.removeAlias(alias.id, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('availability')
async availability(
@Body() dto: ShareAliasAvailabilityDto,
@AuthWorkspace() workspace: Workspace,
) {
return this.shareAliasService.checkAvailability(dto.alias, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('for-page')
async forPage(
@Body() dto: ShareAliasForPageDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page || page.workspaceId !== workspace.id) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
return (
(await this.shareAliasService.getAliasForPage(page.id, workspace.id)) ??
null
);
}
}

View File

@@ -0,0 +1,252 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { ShareAliasService } from './share-alias.service';
/**
* Behaviour tests for the alias write/resolve semantics: create vs no-op vs the
* 409 reassign guard, uniqueness-race handling, availability probe, and the
* request-time readable-target resolution (which re-runs the share boundary).
*/
describe('ShareAliasService', () => {
function makeService() {
const shareAliasRepo = {
findByAliasAndWorkspace: jest.fn(),
findByPageId: jest.fn(),
findById: jest.fn(),
insert: jest.fn(),
updatePageId: jest.fn(),
delete: jest.fn(),
};
const pageRepo = { findById: jest.fn() };
const shareService = {
resolveReadableSharePage: jest.fn(),
isSharingAllowed: jest.fn(),
};
const service = new ShareAliasService(
shareAliasRepo as any,
pageRepo as any,
shareService as any,
);
return { service, shareAliasRepo, pageRepo, shareService };
}
describe('setAlias', () => {
it('rejects an invalid alias before touching the db', async () => {
const { service, shareAliasRepo } = makeService();
await expect(
service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'A', // too short + uppercase
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(shareAliasRepo.findByAliasAndWorkspace).not.toHaveBeenCalled();
});
it('normalizes then inserts a brand-new alias', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.insert.mockResolvedValue({ id: 'a-1', alias: 'my-page' });
const res = await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: ' My Page ',
});
expect(shareAliasRepo.findByAliasAndWorkspace).toHaveBeenCalledWith(
'my-page',
'ws-1',
);
expect(shareAliasRepo.insert).toHaveBeenCalledWith({
workspaceId: 'ws-1',
alias: 'my-page',
pageId: 'p-1',
creatorId: 'u-1',
});
expect(res).toMatchObject({ id: 'a-1' });
});
it('is a no-op when the alias already points at the same page', async () => {
const { service, shareAliasRepo } = makeService();
const existing = { id: 'a-1', alias: 'foo', pageId: 'p-1' };
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(existing);
const res = await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
});
expect(res).toBe(existing);
expect(shareAliasRepo.insert).not.toHaveBeenCalled();
expect(shareAliasRepo.updatePageId).not.toHaveBeenCalled();
});
it('throws 409 with current target when name is taken and not confirmed', async () => {
const { service, shareAliasRepo, pageRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
id: 'a-1',
alias: 'foo',
pageId: 'p-other',
});
pageRepo.findById.mockResolvedValue({ id: 'p-other', title: 'Other' });
try {
await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
});
fail('expected ConflictException');
} catch (err) {
expect(err).toBeInstanceOf(ConflictException);
expect((err as ConflictException).getResponse()).toMatchObject({
code: 'ALIAS_REASSIGN_REQUIRED',
currentPageId: 'p-other',
currentPageTitle: 'Other',
});
}
expect(shareAliasRepo.updatePageId).not.toHaveBeenCalled();
});
it('retargets (UPDATE page_id) when confirmReassign is set', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
id: 'a-1',
alias: 'foo',
pageId: 'p-other',
});
shareAliasRepo.updatePageId.mockResolvedValue({ id: 'a-1', pageId: 'p-1' });
const res = await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
confirmReassign: true,
});
expect(shareAliasRepo.updatePageId).toHaveBeenCalledWith(
'a-1',
'p-1',
'ws-1',
);
expect(res).toMatchObject({ pageId: 'p-1' });
});
it('maps a unique-violation race to 409', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.insert.mockRejectedValue({ code: '23505' });
await expect(
service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
}),
).rejects.toBeInstanceOf(ConflictException);
});
});
describe('checkAvailability', () => {
it('reports invalid for a bad slug without a db hit', async () => {
const { service, shareAliasRepo } = makeService();
const res = await service.checkAvailability('Bad Slug!', 'ws-1');
expect(res).toMatchObject({ valid: false, available: false });
expect(shareAliasRepo.findByAliasAndWorkspace).not.toHaveBeenCalled();
});
it('reports available when no row exists', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
const res = await service.checkAvailability('free-name', 'ws-1');
expect(res).toMatchObject({
alias: 'free-name',
valid: true,
available: true,
currentPageId: null,
});
});
it('reports taken with the current target page', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
id: 'a-1',
pageId: 'p-9',
});
const res = await service.checkAvailability('taken', 'ws-1');
expect(res).toMatchObject({ available: false, currentPageId: 'p-9' });
});
});
describe('resolveReadableTarget', () => {
it('returns null for an invalid alias', async () => {
const { service } = makeService();
expect(await service.resolveReadableTarget('!!', 'ws-1')).toBeNull();
});
it('returns null for an unknown or dangling alias', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValueOnce(undefined);
expect(await service.resolveReadableTarget('foo', 'ws-1')).toBeNull();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValueOnce({
id: 'a-1',
pageId: null,
});
expect(await service.resolveReadableTarget('foo', 'ws-1')).toBeNull();
});
it('returns null when the page is no longer publicly readable', async () => {
const { service, shareAliasRepo, shareService } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
id: 'a-1',
pageId: 'p-1',
});
shareService.resolveReadableSharePage.mockResolvedValue(null);
expect(await service.resolveReadableTarget('foo', 'ws-1')).toBeNull();
});
it('returns null when sharing is disabled for the space', async () => {
const { service, shareAliasRepo, shareService } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
id: 'a-1',
pageId: 'p-1',
});
shareService.resolveReadableSharePage.mockResolvedValue({
share: { key: 'k', spaceId: 's-1' },
page: { slugId: 'sid', title: 'T' },
});
shareService.isSharingAllowed.mockResolvedValue(false);
expect(await service.resolveReadableTarget('foo', 'ws-1')).toBeNull();
});
it('returns the resolved share+page on success', async () => {
const { service, shareAliasRepo, shareService } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
id: 'a-1',
pageId: 'p-1',
});
const resolved = {
share: { key: 'k', spaceId: 's-1' },
page: { slugId: 'sid', title: 'T' },
};
shareService.resolveReadableSharePage.mockResolvedValue(resolved);
shareService.isSharingAllowed.mockResolvedValue(true);
const res = await service.resolveReadableTarget('FOO', 'ws-1');
expect(res).toBe(resolved);
// alias was normalized to lowercase before lookup
expect(shareAliasRepo.findByAliasAndWorkspace).toHaveBeenCalledWith(
'foo',
'ws-1',
);
});
});
});

View File

@@ -0,0 +1,187 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
} from '@nestjs/common';
import { ShareAliasRepo } from '@docmost/db/repos/share-alias/share-alias.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { ShareService } from './share.service';
import { Page, ShareAlias } from '@docmost/db/types/entity.types';
import { isValidShareAlias, normalizeShareAlias } from './share-alias.util';
/** Postgres unique_violation; the (workspace_id, alias) constraint races here. */
const PG_UNIQUE_VIOLATION = '23505';
export interface ResolvedAliasTarget {
share: NonNullable<
Awaited<ReturnType<ShareService['resolveReadableSharePage']>>
>['share'];
page: Page;
}
@Injectable()
export class ShareAliasService {
private readonly logger = new Logger(ShareAliasService.name);
constructor(
private readonly shareAliasRepo: ShareAliasRepo,
private readonly pageRepo: PageRepo,
private readonly shareService: ShareService,
) {}
/**
* Create or retarget a vanity alias. The alias is workspace-scoped:
* - no row for this name -> INSERT a new pointer
* - row already points at pageId -> no-op (idempotent)
* - row points elsewhere -> the "swap". Without confirmReassign we
* throw 409 carrying the current target so the client can confirm; with
* it we UPDATE the single row's page_id (every /l/<alias> link follows the
* 302 to the new page instantly — no stale 301 cache).
*
* Caller is responsible for authorizing the page (edit rights + public
* readability); this method owns only the alias-name semantics.
*/
async setAlias(opts: {
workspaceId: string;
pageId: string;
creatorId: string;
alias: string;
confirmReassign?: boolean;
}): Promise<ShareAlias> {
const { workspaceId, pageId, creatorId, confirmReassign } = opts;
const alias = normalizeShareAlias(opts.alias);
if (!isValidShareAlias(alias)) {
throw new BadRequestException(
'Invalid alias. Use 2-60 lowercase letters, digits and hyphens.',
);
}
const existing = await this.shareAliasRepo.findByAliasAndWorkspace(
alias,
workspaceId,
);
if (!existing) {
try {
return await this.shareAliasRepo.insert({
workspaceId,
alias,
pageId,
creatorId,
});
} catch (err: any) {
// Lost a uniqueness race: another request claimed the name first.
if (err?.code === PG_UNIQUE_VIOLATION) {
throw new ConflictException({ message: 'Alias already taken' });
}
this.logger.error(err);
throw new BadRequestException('Failed to set alias');
}
}
// Already points at this page -> nothing to do.
if (existing.pageId === pageId) {
return existing;
}
// Name occupied by a different (or dangling) target: require confirmation.
if (!confirmReassign) {
const currentPage = existing.pageId
? await this.pageRepo.findById(existing.pageId)
: null;
throw new ConflictException({
message: 'Alias already in use',
code: 'ALIAS_REASSIGN_REQUIRED',
currentPageId: existing.pageId,
currentPageTitle: currentPage?.title ?? null,
});
}
return this.shareAliasRepo.updatePageId(existing.id, pageId, workspaceId);
}
/** Free a vanity name (no history kept). */
async removeAlias(aliasId: string, workspaceId: string): Promise<void> {
await this.shareAliasRepo.delete(aliasId, workspaceId);
}
/** Debounced availability probe for the modal. */
async checkAvailability(
rawAlias: string,
workspaceId: string,
): Promise<{
alias: string;
valid: boolean;
available: boolean;
currentPageId: string | null;
}> {
const alias = normalizeShareAlias(rawAlias);
if (!isValidShareAlias(alias)) {
return { alias, valid: false, available: false, currentPageId: null };
}
const existing = await this.shareAliasRepo.findByAliasAndWorkspace(
alias,
workspaceId,
);
return {
alias,
valid: true,
available: !existing,
currentPageId: existing?.pageId ?? null,
};
}
/** A single alias row scoped to the workspace, or undefined. */
getAliasById(
aliasId: string,
workspaceId: string,
): Promise<ShareAlias | undefined> {
return this.shareAliasRepo.findById(aliasId, workspaceId);
}
/** The alias currently targeting a page (modal display), or undefined. */
getAliasForPage(
pageId: string,
workspaceId: string,
): Promise<ShareAlias | undefined> {
return this.shareAliasRepo.findByPageId(pageId, workspaceId);
}
/**
* Resolve a vanity alias to the canonical, publicly-READABLE share page, or
* null. This re-runs the authoritative share boundary at request time (so a
* later-unshared / restricted / sharing-disabled target collapses to null and
* the caller serves the generic SPA 404 — no existence leak). The alias row
* itself is just a pointer; this is where access is actually decided.
*/
async resolveReadableTarget(
rawAlias: string,
workspaceId: string,
): Promise<ResolvedAliasTarget | null> {
const alias = normalizeShareAlias(rawAlias);
if (!isValidShareAlias(alias)) return null;
const aliasRow = await this.shareAliasRepo.findByAliasAndWorkspace(
alias,
workspaceId,
);
// Unknown name or a dangling alias (target page deleted) -> not resolvable.
if (!aliasRow?.pageId) return null;
const resolved = await this.shareService.resolveReadableSharePage(
undefined,
aliasRow.pageId,
workspaceId,
);
if (!resolved) return null;
const sharingAllowed = await this.shareService.isSharingAllowed(
workspaceId,
resolved.share.spaceId,
);
if (!sharingAllowed) return null;
return resolved;
}
}

View File

@@ -0,0 +1,60 @@
import { isValidShareAlias, normalizeShareAlias } from './share-alias.util';
describe('normalizeShareAlias', () => {
it('lowercases and trims', () => {
expect(normalizeShareAlias(' HelloWorld ')).toBe('helloworld');
});
it('converts spaces and underscores to single hyphens', () => {
expect(normalizeShareAlias('my cool page')).toBe('my-cool-page');
expect(normalizeShareAlias('my_cool_page')).toBe('my-cool-page');
});
it('collapses repeated hyphens and trims edge hyphens', () => {
expect(normalizeShareAlias('--a---b--')).toBe('a-b');
});
it('handles null/undefined defensively', () => {
expect(normalizeShareAlias(undefined as unknown as string)).toBe('');
});
});
describe('isValidShareAlias', () => {
it('accepts ascii lowercase hyphen-separated slugs', () => {
expect(isValidShareAlias('hello')).toBe(true);
expect(isValidShareAlias('hello-world-2')).toBe(true);
expect(isValidShareAlias('a1')).toBe(true);
});
it('rejects too short / too long', () => {
expect(isValidShareAlias('a')).toBe(false);
expect(isValidShareAlias('a'.repeat(61))).toBe(false);
expect(isValidShareAlias('a'.repeat(60))).toBe(true);
});
it('rejects leading/trailing/double hyphens', () => {
expect(isValidShareAlias('-abc')).toBe(false);
expect(isValidShareAlias('abc-')).toBe(false);
expect(isValidShareAlias('a--b')).toBe(false);
});
it('rejects uppercase, cyrillic and other non-ascii', () => {
expect(isValidShareAlias('Hello')).toBe(false);
expect(isValidShareAlias('привет')).toBe(false);
expect(isValidShareAlias('a b')).toBe(false);
expect(isValidShareAlias('a_b')).toBe(false);
expect(isValidShareAlias('a.b')).toBe(false);
});
it('normalize + validate round-trips a messy input to a valid slug', () => {
const alias = normalizeShareAlias(' My Cool_Page!! ');
// "!!" is not stripped by normalize (only case/separators), so the result
// still fails validation — the charset gate is intentionally separate.
expect(alias).toBe('my-cool-page!!');
expect(isValidShareAlias(alias)).toBe(false);
const ok = normalizeShareAlias(' My Cool Page ');
expect(ok).toBe('my-cool-page');
expect(isValidShareAlias(ok)).toBe(true);
});
});

View File

@@ -0,0 +1,30 @@
/**
* Vanity share-alias helpers shared by the write path (set/availability) and the
* `/l/:alias` resolve path. Aliases are ASCII-only, lowercase, hyphen-separated
* slugs — deliberately no Cyrillic / transliteration: the user types the exact
* canonical form. Keep this in sync with the client copy in
* `apps/client/src/features/share/share-alias.util.ts`.
*/
// Normalize a user-provided vanity alias into canonical ASCII storage form.
// This only canonicalizes shape (case, separators); it does NOT enforce the
// charset — call isValidShareAlias afterwards to reject anything illegal.
export function normalizeShareAlias(raw: string): string {
return (raw ?? '')
.trim()
.toLowerCase()
.replace(/[\s_]+/g, '-') // spaces/underscores -> single hyphen
.replace(/-{2,}/g, '-') // collapse repeated hyphens
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
}
// ASCII only: lowercase letters/digits in hyphen-separated groups, length 2..60.
const ALIAS_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export function isValidShareAlias(alias: string): boolean {
return (
typeof alias === 'string' &&
alias.length >= 2 &&
alias.length <= 60 &&
ALIAS_RE.test(alias)
);
}

View File

@@ -0,0 +1,133 @@
import * as fs from 'node:fs';
import { ShareSeoController } from './share-seo.controller';
/**
* Routing guard for ShareSeoController.getShare (red-team finding #3).
*
* The SEO route must NOT leak a shared page's <title>/og:title to anonymous
* visitors / crawlers when the page is not publicly readable. It previously
* called the raw `getShareForPage`, which skips the restricted-ancestor gate, so
* a permission-restricted descendant of an includeSubPages share leaked its
* title. The fix funnels through `resolveReadableSharePage` (the canonical gate)
* AND honours `isSharingAllowed`. These tests pin that routing: a non-readable
* page or sharing-disabled space serves the plain SPA index (no title); only a
* readable, still-shared page gets meta tags.
*/
const SECRET_TITLE = 'Restricted Quarterly Numbers';
const INDEX_HTML = `<!doctype html><html><head><title>App</title><!--meta-tags--></head><body></body></html>`;
const STREAM_SENTINEL = { __isStream: true } as unknown as fs.ReadStream;
// Stub fs at CALL time (jest.spyOn), NOT module load (jest.mock): the controller
// transitively pulls bcrypt, whose native module is located by node-gyp-build
// reading the filesystem at import time — a module-level fs mock breaks that.
beforeEach(() => {
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'readFileSync').mockReturnValue(INDEX_HTML);
jest.spyOn(fs, 'createReadStream').mockReturnValue(STREAM_SENTINEL);
});
afterEach(() => jest.restoreAllMocks());
function makeRes() {
const res: any = {
sent: undefined as unknown,
type: jest.fn(() => res),
send: jest.fn((v: unknown) => {
res.sent = v;
}),
};
return res;
}
function makeController(opts: {
resolved: { share: any; page: any } | null;
sharingAllowed?: boolean;
}) {
const shareService = {
resolveReadableSharePage: jest.fn(async () => opts.resolved),
isSharingAllowed: jest.fn(async () => opts.sharingAllowed ?? true),
// Must NEVER be used by the SEO path anymore (the bypass is the bug).
getShareForPage: jest.fn(async () => {
throw new Error('getShareForPage must not be called by the SEO path');
}),
};
const workspaceRepo = {
findFirst: async () => ({ id: 'ws-1', settings: {} }),
};
const environmentService = { isSelfHosted: () => true };
const controller = new ShareSeoController(
shareService as any,
workspaceRepo as any,
environmentService as any,
);
return { controller, shareService };
}
const req: any = { raw: { headers: { host: 'self' } } };
describe('ShareSeoController.getShare routing (#3 title-leak gate)', () => {
it('serves the plain index (NO title) when the page is not publicly readable', async () => {
const { controller, shareService } = makeController({ resolved: null });
const res = makeRes();
await controller.getShare(res, req, 'share-key', `slug-pageB`);
// The restricted-ancestor gate ran; the raw bypass did not.
expect(shareService.resolveReadableSharePage).toHaveBeenCalled();
expect(shareService.getShareForPage).not.toHaveBeenCalled();
// The plain index stream was sent — NOT the title-bearing meta HTML.
expect(res.sent).toBe(STREAM_SENTINEL);
});
it('serves the plain index when sharing was disabled at the workspace/space level', async () => {
const { controller } = makeController({
resolved: {
share: { spaceId: 'sp-1', searchIndexing: true },
page: { title: SECRET_TITLE },
},
sharingAllowed: false,
});
const res = makeRes();
await controller.getShare(res, req, 'share-key', 'slug-pageB');
// The plain index stream was sent, so the restricted title never reached
// the response (it is only ever interpolated into the meta HTML string).
expect(res.sent).toBe(STREAM_SENTINEL);
expect(res.sent).not.toBe(SECRET_TITLE);
});
it('injects the title + meta for a readable, still-shared page', async () => {
const { controller } = makeController({
resolved: {
share: { spaceId: 'sp-1', searchIndexing: true },
page: { title: 'Public Handbook' },
},
sharingAllowed: true,
});
const res = makeRes();
await controller.getShare(res, req, 'share-key', 'slug-pageA');
expect(typeof res.sent).toBe('string');
expect(res.sent as string).toContain('<title>Public Handbook</title>');
expect(res.sent as string).toContain('og:title');
// searchIndexing on => crawlable (no noindex).
expect(res.sent as string).not.toContain('content="noindex"');
});
it('adds robots=noindex when the share opted out of search indexing', async () => {
const { controller } = makeController({
resolved: {
share: { spaceId: 'sp-1', searchIndexing: false },
page: { title: 'Internal Notes' },
},
sharingAllowed: true,
});
const res = makeRes();
await controller.getShare(res, req, 'share-key', 'slug-pageA');
expect(res.sent as string).toContain('content="noindex"');
});
});

View File

@@ -63,19 +63,38 @@ export class ShareSeoController {
const pageId = this.extractPageSlugId(pageSlug);
const share = await this.shareService.getShareForPage(
// Funnel through the canonical readable-share boundary (NOT the raw
// getShareForPage) so the restricted-ancestor gate runs: a permission-
// restricted descendant of an includeSubPages share must NOT leak its
// title to anonymous visitors / crawlers (red-team finding #3). null =>
// not publicly readable => serve the plain SPA index with no meta.
const resolved = await this.shareService.resolveReadableSharePage(
undefined,
pageId,
workspace.id,
);
if (!share) {
if (!resolved) {
return this.sendIndex(indexFilePath, res);
}
// Honour a workspace/space-level sharing toggle flipped off AFTER this
// share was created: the content API gates on isSharingAllowed, so the SEO
// path must too or it keeps serving the title for a no-longer-shared page.
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
resolved.share.spaceId,
);
if (!sharingAllowed) {
return this.sendIndex(indexFilePath, res);
}
const html = fs.readFileSync(indexFilePath, 'utf8');
// Title of the PAGE being viewed (server-resolved), and noindex unless the
// share opted into search indexing (buildShareMetaHtml injects it).
let transformedHtml = buildShareMetaHtml(html, {
title: share?.sharedPage.title,
searchIndexing: share.searchIndexing,
title: resolved.page.title,
searchIndexing: resolved.share.searchIndexing,
});
// Deliberate same-origin tracker surface: this is the ONE place where an

View File

@@ -5,13 +5,22 @@ import { TokenModule } from '../auth/token.module';
import { ShareSeoController } from './share-seo.controller';
import { TransclusionModule } from '../page/transclusion/transclusion.module';
import { AiModule } from '../../integrations/ai/ai.module';
import { ShareAliasService } from './share-alias.service';
import { ShareAliasController } from './share-alias.controller';
import { ShareAliasRedirectController } from './share-alias-redirect.controller';
@Module({
// AiModule (AiSettingsService) is used by the page-info route to surface
// whether the anonymous public-share assistant is enabled for the workspace.
imports: [TokenModule, TransclusionModule, AiModule],
controllers: [ShareController, ShareSeoController],
providers: [ShareService],
exports: [ShareService],
controllers: [
ShareController,
ShareSeoController,
// Vanity /l/:alias: authenticated management + public 302 resolver.
ShareAliasController,
ShareAliasRedirectController,
],
providers: [ShareService, ShareAliasService],
exports: [ShareService, ShareAliasService],
})
export class ShareModule {}

View File

@@ -23,6 +23,7 @@ import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { ShareAliasRepo } from '@docmost/db/repos/share-alias/share-alias.repo';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { LabelRepo } from '@docmost/db/repos/label/label.repo';
@@ -96,6 +97,7 @@ import { normalizePostgresUrl } from '../common/helpers';
UserSessionRepo,
BacklinkRepo,
ShareRepo,
ShareAliasRepo,
NotificationRepo,
WatcherRepo,
LabelRepo,
@@ -128,6 +130,7 @@ import { normalizePostgresUrl } from '../common/helpers';
UserSessionRepo,
BacklinkRepo,
ShareRepo,
ShareAliasRepo,
NotificationRepo,
WatcherRepo,
LabelRepo,

View File

@@ -0,0 +1,38 @@
import { jsonbBind } from './utils';
/**
* Unit tests for jsonbBind: THE shared helper that encodes a JS array/object as
* a jsonb bind (or null when there is nothing to persist). It is the last line
* of defence before a jsonb column write, so the null-vs-bind decision is what
* matters here. We assert only null vs non-null because the non-null value is a
* kysely `sql` template fragment whose internal shape is an implementation
* detail of the SQL tag (the `::text::jsonb` double-encoding fix is verified
* end-to-end by the repo integration specs, where a real DB round-trip can
* actually observe `jsonb_typeof`).
*/
describe('jsonbBind', () => {
it('returns null for null / undefined', () => {
expect(jsonbBind(null)).toBeNull();
expect(jsonbBind(undefined)).toBeNull();
});
it('returns null for an empty array (nothing to persist)', () => {
expect(jsonbBind([])).toBeNull();
});
it('returns null for an empty object (nothing to persist)', () => {
expect(jsonbBind({})).toBeNull();
});
it('returns a (non-null) bind for a non-empty array', () => {
const out = jsonbBind(['search', 'crawl']);
expect(out).not.toBeNull();
expect(out).toBeDefined();
});
it('returns a (non-null) bind for a non-empty object', () => {
const out = jsonbBind({ driver: 'gemini', chatModel: 'gemini-2.0-flash' });
expect(out).not.toBeNull();
expect(out).toBeDefined();
});
});

View File

@@ -0,0 +1,19 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Per-server, admin-authored instruction text injected into the agent system
// prompt next to the server's tool descriptions (#180). NON-secret (unlike
// headers_enc): it IS returned in admin views/forms. Nullable: a server may
// have no guidance. Trusted text — it goes inside the prompt safety sandwich.
await db.schema
.alterTable('ai_mcp_servers')
.addColumn('instructions', 'text', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('ai_mcp_servers')
.dropColumn('instructions')
.execute();
}

View File

@@ -0,0 +1,18 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Step-granular durability for the assistant turn (#183). The assistant row is
// now created UPFRONT (status 'streaming') and UPDATEd as each step completes,
// so a process death mid-turn no longer loses the whole answer. The column is
// NULLABLE on purpose: rows written before this migration carry NULL, which the
// app treats as 'completed' (a settled, pre-status message). Values written by
// the app: 'streaming' | 'completed' | 'error' | 'aborted'.
await db.schema
.alterTable('ai_chat_messages')
.addColumn('status', 'text', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('ai_chat_messages').dropColumn('status').execute();
}

View File

@@ -0,0 +1,54 @@
import { type Kysely, sql } from 'kysely';
/**
* Vanity share aliases: a retargetable, human-readable pointer (`/l/<alias>`)
* that lives independently of any single `shares` row. The alias belongs to the
* WORKSPACE (stable address), and `page_id` is nullable with ON DELETE SET NULL
* so the address survives deletion of its current target (it 404s until
* retargeted) rather than disappearing with the page.
*/
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('share_aliases')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
// Normalized ASCII, lowercase. Uniqueness is enforced per-workspace below.
.addColumn('alias', 'varchar', (col) => col.notNull())
// Nullable + SET NULL: the address outlives its target page.
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('set null'),
)
.addColumn('creator_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
// The vanity name is unique within a workspace (mirrors shares.key scoping).
await db.schema
.createIndex('share_aliases_workspace_id_alias_unique')
.on('share_aliases')
.columns(['workspace_id', 'alias'])
.unique()
.execute();
// "Which alias targets this page?" lookup for the share modal.
await db.schema
.createIndex('share_aliases_page_id_idx')
.on('share_aliases')
.column('page_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('share_aliases').execute();
}

View File

@@ -0,0 +1,94 @@
import * as migration from './20260626T130000-share-aliases';
import type {
InsertableShareAlias,
ShareAlias,
UpdatableShareAlias,
} from '../types/entity.types';
/**
* Sanity checks for the share_aliases migration + entity types. We don't run a
* live Postgres here (that's the integration suite); instead we assert the
* migration exposes the expected up/down contract and creates the table with
* the unique (workspace_id, alias) constraint and the page_id index, and that
* the generated entity types line up with the column set.
*/
describe('share-aliases migration', () => {
it('up creates the table, the unique index and the page_id index', async () => {
const calls: string[] = [];
const tableBuilder: any = new Proxy(
{},
{
get(_t, prop: string) {
if (prop === 'execute') return async () => undefined;
// addColumn/addConstraint/etc. are chainable no-ops.
return () => tableBuilder;
},
},
);
const indexBuilder: any = new Proxy(
{},
{
get(_t, prop: string) {
if (prop === 'execute') return async () => undefined;
return () => indexBuilder;
},
},
);
const schema = {
createTable: (name: string) => {
calls.push(`createTable:${name}`);
return tableBuilder;
},
createIndex: (name: string) => {
calls.push(`createIndex:${name}`);
return indexBuilder;
},
};
await migration.up({ schema } as any);
expect(calls).toContain('createTable:share_aliases');
expect(calls).toContain(
'createIndex:share_aliases_workspace_id_alias_unique',
);
expect(calls).toContain('createIndex:share_aliases_page_id_idx');
});
it('down drops the table', async () => {
const calls: string[] = [];
const dropBuilder: any = { execute: async () => undefined };
const schema = {
dropTable: (name: string) => {
calls.push(`dropTable:${name}`);
return dropBuilder;
},
};
await migration.down({ schema } as any);
expect(calls).toContain('dropTable:share_aliases');
});
it('entity types expose the alias columns', () => {
// Compile-time only: these typed declarations fail `tsc` if the entity types
// drift (missing/renamed columns, wrong nullability). The runtime assertions
// would be tautological, so the value is purely in the type-check.
const row: ShareAlias = {
id: 'a-1',
workspaceId: 'ws-1',
alias: 'foo',
pageId: 'p-1',
creatorId: 'u-1',
createdAt: new Date(),
updatedAt: new Date(),
};
const insert: InsertableShareAlias = {
workspaceId: 'ws-1',
alias: 'foo',
};
const update: UpdatableShareAlias = { pageId: null };
expect([row, insert, update]).toHaveLength(3);
});
});

View File

@@ -35,7 +35,13 @@ describe('AiAgentRoleRepo.findLiveEnabled', () => {
const result = await repo.findLiveEnabled('r-1', 'ws-1');
expect(result).toBe(role);
// The repo normalizes the row (modelConfig parse), so it returns a COPY, not
// the same reference; assert the row's fields are carried through.
expect(result).toMatchObject({
id: 'r-1',
workspaceId: 'ws-1',
enabled: true,
});
expect(db.selectFrom).toHaveBeenCalledWith('aiAgentRoles');
// Every security filter must be present.
expect(where).toHaveBeenCalledWith('id', '=', 'r-1');

View File

@@ -1,8 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { sql } from 'kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import { dbOrTx, jsonbBind, parseJsonbValue } from '../../utils';
import { AiAgentRole } from '@docmost/db/types/entity.types';
/** The jsonb shape persisted in `model_config` (loosely typed for the column). */
@@ -23,13 +22,14 @@ export class AiAgentRoleRepo {
id: string,
workspaceId: string,
): Promise<AiAgentRole | undefined> {
return this.db
const row = await this.db
.selectFrom('aiAgentRoles')
.selectAll('aiAgentRoles')
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.executeTakeFirst();
return row ? normalizeRow(row) : row;
}
/**
@@ -45,7 +45,7 @@ export class AiAgentRoleRepo {
id: string,
workspaceId: string,
): Promise<AiAgentRole | undefined> {
return this.db
const row = await this.db
.selectFrom('aiAgentRoles')
.selectAll('aiAgentRoles')
.where('id', '=', id)
@@ -53,17 +53,19 @@ export class AiAgentRoleRepo {
.where('deletedAt', 'is', null)
.where('enabled', '=', true)
.executeTakeFirst();
return row ? normalizeRow(row) : row;
}
/** All live roles for the workspace (management list + chat picker). */
async listByWorkspace(workspaceId: string): Promise<AiAgentRole[]> {
return this.db
const rows = await this.db
.selectFrom('aiAgentRoles')
.selectAll('aiAgentRoles')
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.orderBy('createdAt', 'asc')
.execute();
return rows.map(normalizeRow);
}
async insert(
@@ -83,7 +85,7 @@ export class AiAgentRoleRepo {
trx?: KyselyTransaction,
): Promise<AiAgentRole> {
const db = dbOrTx(this.db, trx);
return db
const row = await db
.insertInto('aiAgentRoles')
.values({
workspaceId: values.workspaceId,
@@ -92,7 +94,11 @@ export class AiAgentRoleRepo {
emoji: values.emoji ?? null,
description: values.description ?? null,
instructions: values.instructions,
modelConfig: jsonbObject(values.modelConfig),
// Cast: the generated `model_config` column type is the broad JsonValue
// union, which the concrete RawBuilder<Record> is not structurally
// assignable to (same reason the old jsonbObject cast to any).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
modelConfig: jsonbBind(values.modelConfig) as any,
enabled: values.enabled ?? true,
autoStart: values.autoStart ?? true,
// Empty string is treated as "no custom text" => null.
@@ -100,6 +106,7 @@ export class AiAgentRoleRepo {
})
.returningAll()
.executeTakeFirst();
return normalizeRow(row);
}
async update(
@@ -127,7 +134,7 @@ export class AiAgentRoleRepo {
if (patch.description !== undefined) set.description = patch.description;
if (patch.instructions !== undefined) set.instructions = patch.instructions;
if (patch.modelConfig !== undefined) {
set.modelConfig = jsonbObject(patch.modelConfig);
set.modelConfig = jsonbBind(patch.modelConfig);
}
if (patch.enabled !== undefined) set.enabled = patch.enabled;
if (patch.autoStart !== undefined) set.autoStart = patch.autoStart;
@@ -163,16 +170,36 @@ export class AiAgentRoleRepo {
}
/**
* Encode an object as a jsonb bind for the `model_config` column. The postgres
* driver would otherwise need an explicit cast; bind the JSON text and cast it.
* Returns null for null/undefined/empty objects. Cast to `any` because the
* generated column type is the broad `JsonValue` union, which a concrete object
* type is not structurally assignable to.
* Parse the `model_config` value read from the DB into the object the entity
* type promises. Rows written by the old double-encoding bind (`::jsonb` instead
* of `::text::jsonb`) round-trip as a JSON STRING, so the driver hands back e.g.
* `'{"driver":"gemini"}'` rather than an object; the read-path check
* `typeof cfg === 'object'` then failed and the model override was SILENTLY
* dropped (the role fell back to the default model). Be tolerant: a JSON string
* is parsed; an already-parsed object passes through; null / a non-object (incl.
* an array) / unparseable value becomes null (= no override). This self-heals
* already-corrupted rows on read, no migration required.
*/
export function jsonbObject(value: ModelConfigValue | undefined) {
if (value === null || value === undefined || Object.keys(value).length === 0) {
return null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return sql`${JSON.stringify(value)}::jsonb` as any;
export function parseModelConfig(
value: unknown,
): Record<string, unknown> | null {
// Shape guard only; the legacy double-encoding self-heal lives in
// parseJsonbValue (database/utils.ts).
return parseJsonbValue(
value,
(v): v is Record<string, unknown> =>
v !== null && typeof v === 'object' && !Array.isArray(v),
);
}
/** Normalize a DB row so `modelConfig` is always an object or null. The cast
* bridges parseModelConfig's concrete `Record | null` to the column's broad
* generated `JsonValue` type (an object is a valid JsonValue at runtime). */
function normalizeRow(row: AiAgentRole): AiAgentRole {
return {
...row,
modelConfig: parseModelConfig(
row.modelConfig,
) as AiAgentRole['modelConfig'],
};
}

View File

@@ -0,0 +1,46 @@
import { parseModelConfig } from './ai-agent-roles.repo';
/**
* Unit tests for parseModelConfig: the read-side normalizer that repairs the
* jsonb double-encoding regression on `model_config`. Rows written by the old
* `::jsonb` bind round-trip as a JSON STRING, which the read path's
* `typeof === 'object'` check rejected — silently dropping the model override.
* parseModelConfig accepts an already-parsed object, parses a legacy JSON
* string, and rejects everything that is not an object (null = no override).
*/
describe('parseModelConfig', () => {
it('passes an already-parsed object through', () => {
expect(parseModelConfig({ driver: 'gemini' })).toEqual({
driver: 'gemini',
});
});
it('parses a legacy double-encoded JSON string into an object', () => {
expect(parseModelConfig('{"driver":"gemini","chatModel":"x"}')).toEqual({
driver: 'gemini',
chatModel: 'x',
});
});
it('returns null for null / undefined', () => {
expect(parseModelConfig(null)).toBeNull();
expect(parseModelConfig(undefined)).toBeNull();
});
it('returns null for a non-object JSON value (string/number/array)', () => {
expect(parseModelConfig('"justastring"')).toBeNull();
expect(parseModelConfig('42')).toBeNull();
// An array is an object in JS but not a valid model_config shape.
expect(parseModelConfig('["a","b"]')).toBeNull();
expect(parseModelConfig(['a', 'b'])).toBeNull();
});
it('returns null for an unparseable string', () => {
expect(parseModelConfig('not json at all')).toBeNull();
});
it('returns null for a raw non-object primitive', () => {
expect(parseModelConfig(42 as unknown)).toBeNull();
expect(parseModelConfig(true as unknown)).toBeNull();
});
});

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
@@ -9,8 +9,24 @@ import {
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
// Crash-recovery sweep recency threshold (#183 review): a 'streaming' row is
// only swept to 'aborted' once it has been UNTOUCHED for this long. A live turn
// bumps `updatedAt` on every step (well under this window), so its row never
// matches; only a turn whose process truly died (no step update for >threshold)
// is swept. Chosen safely ABOVE the longest realistic turn so a fresh replica's
// boot-sweep can never abort a turn another replica is actively streaming
// (multi-instance deploy).
const SWEEP_STREAMING_STALE_MS = 10 * 60 * 1000; // 10 minutes
// Hard upper bound on the rows materialized by `findAllByChat` (export path).
// A generous cap so a pathologically huge chat cannot load an unbounded result
// into memory; far above any realistic transcript length.
const FIND_ALL_BY_CHAT_LIMIT = 5000;
@Injectable()
export class AiChatMessageRepo {
private readonly logger = new Logger(AiChatMessageRepo.name);
constructor(@InjectKysely() private readonly db: KyselyDB) {}
// The `tsv` column is a trigger-maintained tsvector used only for
@@ -25,6 +41,7 @@ export class AiChatMessageRepo {
'content',
'toolCalls',
'metadata',
'status',
'createdAt',
'updatedAt',
'deletedAt',
@@ -60,6 +77,46 @@ export class AiChatMessageRepo {
});
}
// Load ALL (non-deleted) messages of a chat in ascending chronological order
// (oldest -> newest), unpaginated. Used by the server-side Markdown export
// (#183), where the DB is the single source of truth and the whole transcript
// must be rendered in one pass (findByChat is cursor-paginated and would only
// return the first page).
//
// Hard-capped at FIND_ALL_BY_CHAT_LIMIT rows (a generous bound, far above any
// realistic transcript) so exporting a pathologically huge chat cannot
// materialize an unbounded result set in memory.
async findAllByChat(
chatId: string,
workspaceId: string,
// Injectable for tests so truncation can be exercised on a modest volume.
limit: number = FIND_ALL_BY_CHAT_LIMIT,
): Promise<AiChatMessage[]> {
// Fetch newest-first (+1 to DETECT truncation), so on overflow we keep the
// NEWEST `limit` messages — the recent conversation matters most for an
// export — rather than silently dropping the tail (#183 review). Reverse back
// to chronological for rendering, like findRecent.
const rows = await this.db
.selectFrom('aiChatMessages')
.select(this.baseFields)
.where('chatId', '=', chatId)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.orderBy('createdAt', 'desc')
.orderBy('id', 'desc')
.limit(limit + 1)
.execute();
if (rows.length > limit) {
rows.length = limit; // keep the newest `limit` (rows are newest-first here)
this.logger.warn(
`Chat ${chatId} export truncated to the newest ${limit} messages ` +
`(older messages omitted).`,
);
}
return rows.reverse();
}
// Load the most RECENT `limit` messages for a chat and return them in
// ascending chronological order (oldest -> newest), as the model expects.
// `findByChat` returns the FIRST page ASC (the OLDEST messages), which loses
@@ -96,4 +153,68 @@ export class AiChatMessageRepo {
.returning(this.baseFields)
.executeTakeFirst();
}
/**
* Update a single message in place by id + workspace (#183 step-granular
* durability). The assistant row is created UPFRONT (status 'streaming') and
* patched as each step completes, then finalized once on the terminal status.
* `updatedAt` is always bumped. Returns the updated row (baseFields) or
* undefined when no row matched (e.g. a foreign workspace / deleted row).
*/
async update(
id: string,
workspaceId: string,
patch: Partial<{
content: string | null;
toolCalls: unknown;
metadata: unknown;
status: string | null;
}>,
opts?: { onlyIfStreaming?: boolean; trx?: KyselyTransaction },
): Promise<AiChatMessage | undefined> {
const db = dbOrTx(this.db, opts?.trx);
let query = db
.updateTable('aiChatMessages')
.set({ ...(patch as Record<string, unknown>), updatedAt: new Date() })
.where('id', '=', id)
.where('workspaceId', '=', workspaceId);
// Concurrency guard (#183 review): a per-step 'streaming' update must NEVER
// overwrite a row the terminal callback already finalized. onStepFinish
// fires the streaming update fire-and-forget, so its UPDATE can land AFTER
// finalize on a DIFFERENT pool connection (commit order is not guaranteed).
// Scoping the streaming update to rows STILL in 'streaming' makes a late
// update a no-op once the row is completed/error/aborted — regardless of
// commit order. The terminal finalize runs WITHOUT this guard so it always
// wins.
if (opts?.onlyIfStreaming) {
query = query.where('status', '=', 'streaming');
}
return query.returning(this.baseFields).executeTakeFirst();
}
/**
* Crash-recovery sweep (#183): flip every assistant row still left in the
* 'streaming' state (a turn that died mid-write before reaching a terminal
* status) to 'aborted'. Run once on server start. Returns the number of rows
* swept so the caller can log it. Workspace-wide on purpose — a crash can have
* dangling streaming rows across any workspace.
*
* Bounded by recency (#183 review): only rows UNTOUCHED for
* SWEEP_STREAMING_STALE_MS are swept. A live turn bumps `updatedAt` on every
* step, so an actively-streaming row never matches; this prevents a fresh
* replica's boot-sweep from aborting a turn another replica is still streaming
* in a multi-instance deploy.
*/
async sweepStreaming(trx?: KyselyTransaction): Promise<number> {
const db = dbOrTx(this.db, trx);
const staleBefore = new Date(Date.now() - SWEEP_STREAMING_STALE_MS);
const rows = await db
.updateTable('aiChatMessages')
.set({ status: 'aborted', updatedAt: new Date() })
.where('status', '=', 'streaming')
.where('updatedAt', '<', staleBefore)
.returning('id')
.execute();
return rows.length;
}
}

View File

@@ -1,4 +1,4 @@
import { parseToolAllowlist } from './ai-mcp-server.repo';
import { parseToolAllowlist, blankToNull } from './ai-mcp-server.repo';
/**
* The `tool_allowlist` jsonb column historically round-trips as a JSON STRING
@@ -10,7 +10,10 @@ import { parseToolAllowlist } from './ai-mcp-server.repo';
*/
describe('parseToolAllowlist', () => {
it('passes a real string array through unchanged', () => {
expect(parseToolAllowlist(['search', 'crawl'])).toEqual(['search', 'crawl']);
expect(parseToolAllowlist(['search', 'crawl'])).toEqual([
'search',
'crawl',
]);
});
it('parses a JSON-string array (the double-encoded read) into an array', () => {
@@ -46,3 +49,26 @@ describe('parseToolAllowlist', () => {
expect(parseToolAllowlist(true as unknown)).toBeNull();
});
});
/**
* `blankToNull` normalizes the per-server `instructions` free text before it is
* stored (#180): a missing/blank/whitespace-only value becomes null (so an empty
* guide is never persisted), any other value is trimmed.
*/
describe('blankToNull', () => {
it('returns null for null / undefined', () => {
expect(blankToNull(null)).toBeNull();
expect(blankToNull(undefined)).toBeNull();
});
it('returns null for an empty / whitespace-only string', () => {
expect(blankToNull('')).toBeNull();
expect(blankToNull(' ')).toBeNull();
expect(blankToNull('\n\t ')).toBeNull();
});
it('trims and returns a non-blank string', () => {
expect(blankToNull(' use the search tool ')).toBe('use the search tool');
expect(blankToNull('guide')).toBe('guide');
});
});

View File

@@ -1,10 +1,11 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { sql } from 'kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import { dbOrTx, jsonbBind, parseJsonbValue } from '../../utils';
import { AiMcpServer } from '@docmost/db/types/entity.types';
const logger = new Logger('AiMcpServerRepo');
/**
* Repository for per-workspace external MCP servers the agent may use (§5.4).
*
@@ -60,6 +61,8 @@ export class AiMcpServerRepo {
url: string;
headersEnc?: string | null;
toolAllowlist?: string[] | null;
// Admin-authored prompt guidance; blank/whitespace normalizes to null.
instructions?: string | null;
enabled?: boolean;
},
trx?: KyselyTransaction,
@@ -75,7 +78,9 @@ export class AiMcpServerRepo {
headersEnc: values.headersEnc ?? null,
// jsonb column: the postgres driver would otherwise encode a JS array as
// a Postgres array literal. Bind the JSON text and cast it to jsonb.
toolAllowlist: jsonbArray(values.toolAllowlist),
toolAllowlist: jsonbBind(values.toolAllowlist),
// Plain text column: blank/whitespace-only guidance is stored as null.
instructions: blankToNull(values.instructions),
enabled: values.enabled ?? true,
})
.returningAll()
@@ -93,6 +98,8 @@ export class AiMcpServerRepo {
headersEnc?: string | null;
// undefined => leave unchanged; null => clear; string[] => set.
toolAllowlist?: string[] | null;
// undefined => leave unchanged; null/blank => clear; string => set.
instructions?: string | null;
enabled?: boolean;
},
trx?: KyselyTransaction,
@@ -104,7 +111,11 @@ export class AiMcpServerRepo {
if (patch.url !== undefined) set.url = patch.url;
if (patch.headersEnc !== undefined) set.headersEnc = patch.headersEnc;
if (patch.toolAllowlist !== undefined) {
set.toolAllowlist = jsonbArray(patch.toolAllowlist);
set.toolAllowlist = jsonbBind(patch.toolAllowlist);
}
if (patch.instructions !== undefined) {
// Blank/whitespace-only guidance clears the column (stored as null).
set.instructions = blankToNull(patch.instructions);
}
if (patch.enabled !== undefined) set.enabled = patch.enabled;
await db
@@ -130,57 +141,49 @@ export class AiMcpServerRepo {
}
/**
* Encode a string[] as a jsonb bind for the `tool_allowlist` column. Passing a
* plain JS array to the postgres driver would serialize it as a Postgres array
* literal (incompatible with jsonb), so we bind the JSON text and cast it.
*
* The cast is `::text::jsonb`, NOT `::jsonb`: if the parameter is bound straight
* to a jsonb cast, node-postgres infers its type as jsonb and JSON-stringifies
* the (already-JSON) string a SECOND time, so the column ends up holding a jsonb
* STRING SCALAR (`"[\"a\"]"`) instead of a jsonb ARRAY. Forcing the param through
* `::text` first binds it as text (sent verbatim), and `::jsonb` then parses it
* into a real array. (`normalizeRow` below repairs rows written the old way.)
*
* Returns null for null/empty arrays (an empty allowlist means "no restriction"
* is not intended — callers pass null to clear; an empty array is normalized to
* null here so it never round-trips as `[]`).
* Normalize an optional free-text field to a stored value: a missing/blank/
* whitespace-only string becomes null (so an "empty" guide is never persisted),
* any other string is trimmed. Returns null for null/undefined input.
*/
function jsonbArray(value: string[] | null | undefined) {
if (value === null || value === undefined || value.length === 0) {
return null;
}
// Typed as string[] so it is assignable to the toolAllowlist column.
return sql<string[]>`${JSON.stringify(value)}::text::jsonb`;
export function blankToNull(value: string | null | undefined): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
/**
* Parse the `toolAllowlist` value read from the DB into the `string[] | null`
* the entity type promises. The jsonb column historically round-trips as a JSON
* STRING (rows written by the old double-encoding `jsonbArray`, see above), so
* the driver hands back a string like `'["a","b"]'` rather than an array. Be
* tolerant: an already-parsed array passes through; a JSON string is parsed; null
* / a non-array / unparseable value becomes null (unrestricted).
* STRING (rows written by the old double-encoding bind before the `::text::jsonb`
* fix), so the driver hands back a string like `'["a","b"]'` rather than an
* array. Be tolerant: normalize a JSON string to its value, then accept it only
* if it is an array of strings; null / a non-array / unparseable value / an
* array with a non-string element all become null (unrestricted).
*/
export function parseToolAllowlist(value: unknown): string[] | null {
if (value == null) return null;
if (Array.isArray(value)) {
return value.every((v) => typeof v === 'string') ? (value as string[]) : null;
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) &&
parsed.every((v) => typeof v === 'string')
? (parsed as string[])
: null;
} catch {
return null;
}
}
return null;
// Shape guard only; the legacy double-encoding self-heal lives in
// parseJsonbValue (database/utils.ts).
return parseJsonbValue(
value,
(v): v is string[] =>
Array.isArray(v) && v.every((x) => typeof x === 'string'),
);
}
/** Normalize a DB row so `toolAllowlist` is always `string[] | null`. */
/**
* Normalize a DB row so `toolAllowlist` is always `string[] | null`.
*
* FAIL-OPEN logging: a stored value that is present but cannot be parsed into a
* string[] (corrupt JSON, a non-array, non-string elements) degrades to `null` =
* "no restriction", so the agent silently gets ALL of the server's tools. Log
* one line (server id only, never the contents) so that widening is not silent.
*/
function normalizeRow(row: AiMcpServer): AiMcpServer {
return { ...row, toolAllowlist: parseToolAllowlist(row.toolAllowlist) };
const parsed = parseToolAllowlist(row.toolAllowlist);
if (parsed === null && row.toolAllowlist != null) {
logger.warn(
`Corrupt tool_allowlist for MCP server ${row.id}; ignoring it (no tool restriction applied)`,
);
}
return { ...row, toolAllowlist: parsed };
}

View File

@@ -0,0 +1,120 @@
import { ShareAliasRepo } from './share-alias.repo';
import type { KyselyDB } from '../../types/kysely.types';
/**
* SQL-shape unit tests for ShareAliasRepo. A live Postgres is out of scope;
* instead we spy on the Kysely builder to assert each method pins the
* workspace scope (so a name in one workspace can never resolve another's
* page) and threads the right columns.
*/
describe('ShareAliasRepo', () => {
function makeSelectRepo(result: unknown) {
const where = jest.fn();
const builder: any = {
select: jest.fn(() => builder),
where: jest.fn((...args: unknown[]) => {
where(...args);
return builder;
}),
executeTakeFirst: jest.fn().mockResolvedValue(result),
};
const db = { selectFrom: jest.fn(() => builder) } as unknown as KyselyDB;
return { repo: new ShareAliasRepo(db), db, where, builder };
}
it('findByAliasAndWorkspace scopes by alias AND workspace', async () => {
const row = { id: 'a-1', alias: 'foo', workspaceId: 'ws-1' };
const { repo, db, where } = makeSelectRepo(row);
const res = await repo.findByAliasAndWorkspace('foo', 'ws-1');
expect(res).toBe(row);
expect(db.selectFrom).toHaveBeenCalledWith('shareAliases');
expect(where).toHaveBeenCalledWith('alias', '=', 'foo');
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
});
it('findByPageId scopes by page AND workspace', async () => {
const { repo, where } = makeSelectRepo(undefined);
await repo.findByPageId('p-1', 'ws-1');
expect(where).toHaveBeenCalledWith('pageId', '=', 'p-1');
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
});
it('insert writes the provided columns and returns the row', async () => {
const values = jest.fn();
const inserted = { id: 'a-1' };
const builder: any = {
values: jest.fn((v: unknown) => {
values(v);
return builder;
}),
returning: jest.fn(() => builder),
executeTakeFirst: jest.fn().mockResolvedValue(inserted),
};
const db = { insertInto: jest.fn(() => builder) } as unknown as KyselyDB;
const repo = new ShareAliasRepo(db);
const res = await repo.insert({
workspaceId: 'ws-1',
alias: 'foo',
pageId: 'p-1',
creatorId: 'u-1',
});
expect(db.insertInto).toHaveBeenCalledWith('shareAliases');
expect(values).toHaveBeenCalledWith({
workspaceId: 'ws-1',
alias: 'foo',
pageId: 'p-1',
creatorId: 'u-1',
});
expect(res).toBe(inserted);
});
it('updatePageId retargets a single row scoped by id + workspace', async () => {
const set = jest.fn();
const where = jest.fn();
const builder: any = {
set: jest.fn((s: unknown) => {
set(s);
return builder;
}),
where: jest.fn((...args: unknown[]) => {
where(...args);
return builder;
}),
returning: jest.fn(() => builder),
executeTakeFirst: jest.fn().mockResolvedValue({ id: 'a-1' }),
};
const db = { updateTable: jest.fn(() => builder) } as unknown as KyselyDB;
const repo = new ShareAliasRepo(db);
await repo.updatePageId('a-1', 'p-2', 'ws-1');
expect(db.updateTable).toHaveBeenCalledWith('shareAliases');
expect(set.mock.calls[0][0].pageId).toBe('p-2');
expect(set.mock.calls[0][0].updatedAt).toBeInstanceOf(Date);
expect(where).toHaveBeenCalledWith('id', '=', 'a-1');
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
});
it('delete scopes by id + workspace', async () => {
const where = jest.fn();
const builder: any = {
where: jest.fn((...args: unknown[]) => {
where(...args);
return builder;
}),
execute: jest.fn().mockResolvedValue(undefined),
};
const db = { deleteFrom: jest.fn(() => builder) } as unknown as KyselyDB;
const repo = new ShareAliasRepo(db);
await repo.delete('a-1', 'ws-1');
expect(db.deleteFrom).toHaveBeenCalledWith('shareAliases');
expect(where).toHaveBeenCalledWith('id', '=', 'a-1');
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
});
});

View File

@@ -0,0 +1,109 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import {
InsertableShareAlias,
ShareAlias,
} from '@docmost/db/types/entity.types';
/**
* Repository for vanity share aliases (`/l/:alias`). An alias is a long-lived,
* workspace-scoped pointer to a page; retargeting is a single UPDATE of
* `page_id`. All lookups are workspace-scoped so a name in one workspace can
* never resolve a page in another.
*/
@Injectable()
export class ShareAliasRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
private baseFields: Array<keyof ShareAlias> = [
'id',
'workspaceId',
'alias',
'pageId',
'creatorId',
'createdAt',
'updatedAt',
];
/** Resolve a (normalized) alias within a workspace, or undefined. */
async findByAliasAndWorkspace(
alias: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<ShareAlias | undefined> {
return dbOrTx(this.db, trx)
.selectFrom('shareAliases')
.select(this.baseFields)
.where('alias', '=', alias)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
/** The alias currently pointing at a page (for the share modal). */
async findByPageId(
pageId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<ShareAlias | undefined> {
return dbOrTx(this.db, trx)
.selectFrom('shareAliases')
.select(this.baseFields)
.where('pageId', '=', pageId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
async findById(
id: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<ShareAlias | undefined> {
return dbOrTx(this.db, trx)
.selectFrom('shareAliases')
.select(this.baseFields)
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
async insert(
insertable: InsertableShareAlias,
trx?: KyselyTransaction,
): Promise<ShareAlias> {
return dbOrTx(this.db, trx)
.insertInto('shareAliases')
.values(insertable)
.returning(this.baseFields)
.executeTakeFirst();
}
/** Retarget an existing alias to a new page (the "swap" operation). */
async updatePageId(
id: string,
pageId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<ShareAlias> {
return dbOrTx(this.db, trx)
.updateTable('shareAliases')
.set({ pageId, updatedAt: new Date() })
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
}
async delete(
id: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await dbOrTx(this.db, trx)
.deleteFrom('shareAliases')
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.execute();
}
}

View File

@@ -20,8 +20,15 @@ export interface AiMcpServers {
// Encrypted JSON of the auth headers. Nullable (a server may need no auth).
headersEnc: string | null;
// Optional allowlist of remote tool names to expose; null = expose all.
// Stored as jsonb; reads come back as a string[] from the postgres driver.
// Stored as jsonb. The postgres driver may return a JSON string for legacy
// double-encoded rows; `AiMcpServerRepo` normalizes every read to
// `string[] | null` via `parseToolAllowlist`.
toolAllowlist: string[] | null;
// Admin-authored guidance ("how/when to use this server's tools") injected
// into the agent system prompt (#180). Unlike `headersEnc` this is NON-secret
// and IS returned in admin views/forms. Plain text column (no jsonb). Null =
// no guidance. Trusted text — it goes inside the prompt safety sandwich.
instructions: string | null;
enabled: Generated<boolean>;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;

View File

@@ -305,6 +305,16 @@ export interface Pages {
ydoc: Buffer | null;
}
export interface ShareAliases {
alias: string;
createdAt: Generated<Timestamp>;
creatorId: string | null;
id: Generated<string>;
pageId: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
export interface Shares {
createdAt: Generated<Timestamp>;
creatorId: string | null;
@@ -620,6 +630,10 @@ export interface AiChatMessages {
content: string | null;
toolCalls: Json | null;
metadata: Json | null;
// Turn lifecycle status (#183): 'streaming' | 'completed' | 'error' |
// 'aborted'. NULL on rows written before the status column existed; the app
// treats NULL as 'completed' (a settled, pre-status message).
status: string | null;
tsv: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
@@ -670,6 +684,7 @@ export interface DB {
pageVerifiers: PageVerifiers;
pages: Pages;
scimTokens: ScimTokens;
shareAliases: ShareAliases;
shares: Shares;
spaceMembers: SpaceMembers;
spaces: Spaces;

View File

@@ -30,6 +30,7 @@ import {
AuthProviders,
AuthAccounts,
Shares,
ShareAliases,
Favorites,
FileTasks,
UserMfa as _UserMFA,
@@ -172,6 +173,11 @@ export type Share = Selectable<Shares>;
export type InsertableShare = Insertable<Shares>;
export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;
// Share alias (vanity /l/:alias pointer)
export type ShareAlias = Selectable<ShareAliases>;
export type InsertableShareAlias = Insertable<ShareAliases>;
export type UpdatableShareAlias = Updateable<Omit<ShareAliases, 'id'>>;
// Favorite
export type Favorite = Selectable<Favorites>;
export type InsertableFavorite = Insertable<Favorites>;

View File

@@ -1,3 +1,4 @@
import { sql, RawBuilder } from 'kysely';
import { KyselyDB, KyselyTransaction } from './types/kysely.types';
/*
@@ -31,3 +32,61 @@ export function dbOrTx(
return db; // Use normal database instance
}
}
/**
* Bind a JS array/object as a `jsonb` column value, working around a postgres
* driver double-encoding quirk. THE single implementation — repos that persist
* jsonb (`tool_allowlist`, `model_config`, ...) call this instead of re-deriving
* the cast.
*
* THE QUIRK: with the `kysely-postgres-js` / postgres.js driver, casting a bound
* parameter straight to `::jsonb` makes the driver infer the param type as jsonb
* and JSON-stringify the (already-JSON) text a SECOND time, so the column ends
* up holding a jsonb STRING SCALAR (`"[\"a\"]"` / `"{\"k\":1}"`) instead of a
* real jsonb array/object. Read paths then see a string, not the structure, and
* silently fall back (an allowlist becomes "unrestricted", a model override is
* ignored). Forcing the param through `::text` first binds it as text (sent
* verbatim); `::jsonb` then parses it into a real array/object. Read-side
* parsers repair rows written the old buggy way without a migration.
*
* Returns `null` for null/undefined and for "empty" values (an empty array, or
* an object with no own enumerable keys) — callers treat empty as "clear/unset",
* so an empty allowlist/config never round-trips as `[]`/`{}`.
*/
export function jsonbBind<T>(
value: T | null | undefined,
): RawBuilder<T> | null {
if (value === null || value === undefined) return null;
if (Array.isArray(value)) {
if (value.length === 0) return null;
} else if (typeof value === 'object') {
if (Object.keys(value as object).length === 0) return null;
}
return sql<T>`${JSON.stringify(value)}::text::jsonb`;
}
/**
* READ-side counterpart to {@link jsonbBind}: tolerantly decode a jsonb value
* read back from the DB and validate its shape with `guard`. THE single place
* the legacy double-encoding self-heal lives, so repos keep only a type-guard.
*
* A row written by the old `::jsonb` bind round-trips as a JSON STRING (see the
* quirk in jsonbBind), so the driver hands back e.g. `'["a"]'` / `'{"k":1}'`
* rather than the structure. This parses such a string once, then applies the
* caller's `guard`. Returns `null` for null / an unparseable string / a value
* the guard rejects (so a corrupt or wrong-shaped value degrades to "unset").
*/
export function parseJsonbValue<T>(
value: unknown,
guard: (v: unknown) => v is T,
): T | null {
let v: unknown = value;
if (typeof v === 'string') {
try {
v = JSON.parse(v); // legacy double-encoded read
} catch {
return null;
}
}
return guard(v) ? v : null;
}

View File

@@ -40,7 +40,14 @@ async function bootstrap() {
app.useLogger(app.get(PinoLogger));
app.setGlobalPrefix('api', {
exclude: ['robots.txt', 'share/:shareId/p/:pageSlug', 'mcp'],
exclude: [
'robots.txt',
'share/:shareId/p/:pageSlug',
// Vanity link resolver lives outside /api so /l/<alias> is a clean
// public URL that 302s to the canonical share page.
'l/:alias',
'mcp',
],
});
const reflector = app.get(Reflector);

View File

@@ -1,4 +1,5 @@
import { Kysely } from 'kysely';
import { Kysely, sql } from 'kysely';
import { randomUUID } from 'node:crypto';
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
import { getTestDb, destroyTestDb, createWorkspace } from './db';
@@ -25,8 +26,16 @@ describe('AiAgentRoleRepo isolation + partial unique index [integration]', () =>
});
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' });
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();
@@ -38,7 +47,11 @@ describe('AiAgentRoleRepo isolation + partial unique index [integration]', () =>
});
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' });
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.
@@ -58,21 +71,100 @@ describe('AiAgentRoleRepo isolation + partial unique index [integration]', () =>
});
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' });
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' });
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' });
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);
});
// model_config jsonb round-trip (issue #173 §1): the same double-encoding bug
// PR #172 fixed for tool_allowlist lived in jsonbObject. A DB round-trip is the
// only way to observe it — the write must land as a real jsonb OBJECT, and a
// legacy string-scalar row must self-heal on read (else the model override is
// silently dropped and the role falls back to the default model).
const jsonbTypeof = async (id: string): Promise<string | null> => {
const res = await sql<{ t: string | null }>`
SELECT jsonb_typeof(model_config) AS t
FROM ai_agent_roles WHERE id = ${id}
`.execute(db);
return res.rows[0]?.t ?? null;
};
it('insert stores model_config as a jsonb OBJECT and reads it back as an object', async () => {
const role = await repo.insert({
workspaceId: w1,
name: `Model-${randomUUID()}`,
instructions: 'x',
modelConfig: { driver: 'gemini', chatModel: 'gemini-2.0-flash' },
});
expect(await jsonbTypeof(role.id)).toBe('object');
// The returned row is already normalized to an object.
expect(role.modelConfig).toEqual({
driver: 'gemini',
chatModel: 'gemini-2.0-flash',
});
const found = await repo.findById(role.id, w1);
expect(found?.modelConfig).toEqual({
driver: 'gemini',
chatModel: 'gemini-2.0-flash',
});
});
it('an empty model_config is normalized to null (no override)', async () => {
const role = await repo.insert({
workspaceId: w1,
name: `Empty-${randomUUID()}`,
instructions: 'x',
modelConfig: {},
});
// The column is SQL NULL, so jsonb_typeof returns SQL NULL (JS null).
expect(await jsonbTypeof(role.id)).toBeNull();
expect((await repo.findById(role.id, w1))?.modelConfig).toBeNull();
});
it('repairs a legacy double-encoded (string scalar) model_config on read', async () => {
const id = randomUUID();
// Seed the corrupt string-scalar shape the old `::jsonb` bind produced.
await sql`
INSERT INTO ai_agent_roles (id, workspace_id, name, instructions, model_config)
VALUES (
${id}, ${w1}, ${`Legacy-${id}`}, 'x',
to_jsonb(${'{"driver":"openai","chatModel":"gpt"}'}::text)
)
`.execute(db);
expect(await jsonbTypeof(id)).toBe('string'); // sanity: really corrupt
expect((await repo.findById(id, w1))?.modelConfig).toEqual({
driver: 'openai',
chatModel: 'gpt',
});
});
});

View File

@@ -0,0 +1,270 @@
import { Kysely } from 'kysely';
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
import {
getTestDb,
destroyTestDb,
createWorkspace,
createUser,
createChat,
createMessage,
} from './db';
/**
* Integration coverage for the #183 step-granular durability primitives on
* AiChatMessageRepo: `update` (in-place patch by id+workspace, bumps updatedAt,
* returns the row) and `sweepStreaming` (crash recovery: flip dangling
* 'streaming' rows to 'aborted'). Real SQL against docmost_test, not a mock.
*/
describe('AiChatMessageRepo.update + sweepStreaming [integration]', () => {
let db: Kysely<any>;
let repo: AiChatMessageRepo;
let workspaceId: string;
let otherWorkspaceId: string;
let userId: string;
let chatId: string;
let otherChatId: string;
beforeAll(async () => {
db = getTestDb();
repo = new AiChatMessageRepo(db as any);
workspaceId = (await createWorkspace(db)).id;
otherWorkspaceId = (await createWorkspace(db)).id;
userId = (await createUser(db, workspaceId)).id;
chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
const otherUser = await createUser(db, otherWorkspaceId);
otherChatId = (
await createChat(db, {
workspaceId: otherWorkspaceId,
creatorId: otherUser.id,
})
).id;
});
afterAll(async () => {
await destroyTestDb();
});
it('update patches content/status/metadata and bumps updatedAt', async () => {
const seeded = await repo.insert({
chatId,
workspaceId,
userId,
role: 'assistant',
content: '',
status: 'streaming',
metadata: { parts: [] } as never,
});
const before = seeded.updatedAt;
// Ensure a measurable timestamp delta.
await new Promise((r) => setTimeout(r, 5));
const updated = await repo.update(seeded.id, workspaceId, {
content: 'final answer',
status: 'completed',
metadata: { parts: [{ type: 'text', text: 'final answer' }] },
});
expect(updated).toBeDefined();
expect(updated!.content).toBe('final answer');
expect(updated!.status).toBe('completed');
expect((updated!.metadata as any).parts).toHaveLength(1);
// The 5ms sleep above guarantees a strictly-later timestamp.
expect(new Date(updated!.updatedAt).getTime()).toBeGreaterThan(
new Date(before).getTime(),
);
});
it('onlyIfStreaming update is a NO-OP once the row is finalized (race guard)', async () => {
// Reproduce the step-update-vs-finalize race (#183 review): the row is
// finalized to 'completed', then a LATE per-step 'streaming' update lands.
// With `onlyIfStreaming` it must match nothing and leave the finalized row
// untouched (no clobber back to 'streaming', no lost usage).
const seeded = await repo.insert({
chatId,
workspaceId,
userId,
role: 'assistant',
content: 'partial',
status: 'streaming',
});
// Terminal finalize (unguarded) wins.
await repo.update(seeded.id, workspaceId, {
content: 'final answer',
status: 'completed',
metadata: { usage: { totalTokens: 42 } } as never,
});
// A straggler per-step update arrives AFTER finalize.
const late = await repo.update(
seeded.id,
workspaceId,
{ content: 'partial', status: 'streaming', metadata: {} as never },
{ onlyIfStreaming: true },
);
expect(late).toBeUndefined(); // matched no 'streaming' row -> no-op
const rows = await repo.findAllByChat(chatId, workspaceId);
const row = rows.find((r) => r.id === seeded.id)!;
expect(row.status).toBe('completed'); // NOT clobbered back to streaming
expect(row.content).toBe('final answer');
expect((row.metadata as any).usage.totalTokens).toBe(42); // usage preserved
});
it('update is workspace-scoped: a foreign workspace id matches nothing', async () => {
const seeded = await repo.insert({
chatId,
workspaceId,
userId,
role: 'assistant',
content: 'orig',
status: 'streaming',
});
const res = await repo.update(seeded.id, otherWorkspaceId, {
status: 'completed',
});
expect(res).toBeUndefined();
// The row in the real workspace is untouched.
const rows = await repo.findAllByChat(chatId, workspaceId);
const stillThere = rows.find((r) => r.id === seeded.id);
expect(stillThere!.status).toBe('streaming');
// Clean up so it does not pollute the sweep test below.
await repo.update(seeded.id, workspaceId, { status: 'completed' });
});
// Backdate a row's updatedAt so it qualifies as a STALE streaming row (the
// sweep only flips rows untouched for >10 minutes — a live turn bumps
// updatedAt every step, so it would never match).
async function backdateUpdatedAt(
id: string,
minutesAgo: number,
): Promise<void> {
await db
.updateTable('aiChatMessages')
.set({ updatedAt: new Date(Date.now() - minutesAgo * 60 * 1000) })
.where('id', '=', id)
.execute();
}
it('sweepStreaming flips STALE dangling streaming rows to aborted and counts them', async () => {
// Two dangling streaming rows in our workspace + one in another workspace —
// all backdated past the staleness threshold so the sweep picks them up.
const a = await createMessage(db, {
workspaceId,
chatId,
role: 'assistant',
status: 'streaming',
});
const b = await createMessage(db, {
workspaceId,
chatId,
role: 'assistant',
status: 'streaming',
});
const other = await createMessage(db, {
workspaceId: otherWorkspaceId,
chatId: otherChatId,
role: 'assistant',
status: 'streaming',
});
await backdateUpdatedAt(a.id, 20);
await backdateUpdatedAt(b.id, 20);
await backdateUpdatedAt(other.id, 20);
// A settled row must NOT be touched.
const done = await createMessage(db, {
workspaceId,
chatId,
role: 'assistant',
status: 'completed',
});
// A legacy NULL-status row must NOT be touched.
const legacy = await createMessage(db, {
workspaceId,
chatId,
role: 'assistant',
status: null,
});
const swept = await repo.sweepStreaming();
// At least the 3 stale streaming rows we created (2 here + 1 in the other ws).
expect(swept).toBeGreaterThanOrEqual(3);
const rows = await repo.findAllByChat(chatId, workspaceId);
const byId = new Map(rows.map((r) => [r.id, r]));
expect(byId.get(a.id)!.status).toBe('aborted');
expect(byId.get(b.id)!.status).toBe('aborted');
expect(byId.get(done.id)!.status).toBe('completed');
expect(byId.get(legacy.id)!.status).toBeNull();
// Idempotent: a second sweep finds nothing left in our seeded set.
const again = await repo.sweepStreaming();
const rows2 = await repo.findAllByChat(chatId, workspaceId);
// Our two rows stay aborted regardless of `again`'s global count.
expect(rows2.find((r) => r.id === a.id)!.status).toBe('aborted');
expect(again).toBeGreaterThanOrEqual(0);
});
it('sweepStreaming does NOT sweep a FRESH streaming row (recency bound, #183 review)', async () => {
// A row that is actively streaming (recent updatedAt) must survive the sweep:
// a fresh replica's boot-sweep must never abort a turn another replica is
// still streaming in a multi-instance deploy.
const fresh = await createMessage(db, {
workspaceId,
chatId,
role: 'assistant',
status: 'streaming',
});
// A STALE streaming row created alongside it IS swept — proving the sweep
// ran and the only difference is recency.
const stale = await createMessage(db, {
workspaceId,
chatId,
role: 'assistant',
status: 'streaming',
});
await backdateUpdatedAt(stale.id, 20);
await repo.sweepStreaming();
const rows = await repo.findAllByChat(chatId, workspaceId);
const byId = new Map(rows.map((r) => [r.id, r]));
// Fresh (recently-updated) streaming row is left untouched...
expect(byId.get(fresh.id)!.status).toBe('streaming');
// ...while the stale one alongside it was swept to 'aborted'.
expect(byId.get(stale.id)!.status).toBe('aborted');
});
it('findAllByChat caps the result, keeping the NEWEST messages in order (#183 review)', async () => {
// A dedicated chat so the cap test is independent of the rows above.
const cappedChat = (
await createChat(db, { workspaceId, creatorId: userId })
).id;
const base = Date.now();
// Three messages at strictly increasing timestamps.
await createMessage(db, {
workspaceId,
chatId: cappedChat,
content: 'm1-oldest',
createdAt: new Date(base),
});
await createMessage(db, {
workspaceId,
chatId: cappedChat,
content: 'm2',
createdAt: new Date(base + 1000),
});
await createMessage(db, {
workspaceId,
chatId: cappedChat,
content: 'm3-newest',
createdAt: new Date(base + 2000),
});
// Cap of 2 -> the OLDEST message is dropped; the newest two stay, in
// chronological order (oldest -> newest).
const capped = await repo.findAllByChat(cappedChat, workspaceId, 2);
expect(capped.map((r) => r.content)).toEqual(['m2', 'm3-newest']);
// Without a cap (well above the row count) all three come back in order.
const all = await repo.findAllByChat(cappedChat, workspaceId, 100);
expect(all.map((r) => r.content)).toEqual(['m1-oldest', 'm2', 'm3-newest']);
});
});

View File

@@ -0,0 +1,194 @@
import { Kysely, sql } from 'kysely';
import { randomUUID } from 'node:crypto';
import { AiMcpServerRepo } from '@docmost/db/repos/ai-chat/ai-mcp-server.repo';
import { getTestDb, destroyTestDb, createWorkspace } from './db';
/**
* AiMcpServerRepo `tool_allowlist` jsonb round-trip (PR #172 / issue #173 §3).
*
* The fix under test is a DB round-trip, so a unit test cannot observe it: the
* write must land as a real jsonb ARRAY (not a double-encoded string scalar),
* and the read must repair any legacy string-scalar rows. The read-side
* `parseToolAllowlist` MASKS a write regression (it parses the string back), so
* without this integration check, reverting `::text::jsonb` to `::jsonb` would
* keep every unit test green while silently corrupting the column again.
*/
describe('AiMcpServerRepo tool_allowlist jsonb round-trip [integration]', () => {
let db: Kysely<any>;
let repo: AiMcpServerRepo;
let ws: string;
beforeAll(async () => {
db = getTestDb();
repo = new AiMcpServerRepo(db as any);
ws = (await createWorkspace(db)).id;
});
afterAll(async () => {
await destroyTestDb();
});
const jsonbTypeof = async (id: string): Promise<string | null> => {
const res = await sql<{ t: string | null }>`
SELECT jsonb_typeof(tool_allowlist) AS t
FROM ai_mcp_servers WHERE id = ${id}
`.execute(db);
return res.rows[0]?.t ?? null;
};
it('insert stores the allowlist as a jsonb ARRAY (not a string scalar)', async () => {
const row = await repo.insert({
workspaceId: ws,
name: `srv-${randomUUID()}`,
transport: 'http',
url: 'https://example.com/mcp',
toolAllowlist: ['search', 'crawl'],
});
// The column holds a real jsonb array — the whole point of ::text::jsonb.
expect(await jsonbTypeof(row.id)).toBe('array');
// And the read returns a genuine string[], not a JSON string.
const found = await repo.findById(row.id, ws);
expect(found?.toolAllowlist).toEqual(['search', 'crawl']);
expect(Array.isArray(found?.toolAllowlist)).toBe(true);
});
it('an empty allowlist is normalized to null (no restriction), not []', async () => {
const row = await repo.insert({
workspaceId: ws,
name: `srv-${randomUUID()}`,
transport: 'http',
url: 'https://example.com/mcp',
toolAllowlist: [],
});
// The column is SQL NULL, so jsonb_typeof returns SQL NULL (JS null).
expect(await jsonbTypeof(row.id)).toBeNull();
expect((await repo.findById(row.id, ws))?.toolAllowlist).toBeNull();
});
it('repairs a legacy double-encoded (string scalar) row on read (self-heal)', async () => {
// Seed a row whose tool_allowlist is a jsonb STRING SCALAR holding the JSON
// text — exactly what the old `::jsonb` double-encoding produced.
const id = randomUUID();
await sql`
INSERT INTO ai_mcp_servers (id, workspace_id, name, transport, url, tool_allowlist)
VALUES (
${id}, ${ws}, ${`srv-${id}`}, 'http', 'https://example.com/mcp',
to_jsonb(${'["alpha","beta"]'}::text)
)
`.execute(db);
// Sanity: the seeded column really IS the corrupt string-scalar shape.
expect(await jsonbTypeof(id)).toBe('string');
// The repo read heals it back to a real string[].
expect((await repo.findById(id, ws))?.toolAllowlist).toEqual([
'alpha',
'beta',
]);
const enabled = await repo.listEnabled(ws);
const healed = enabled.find((r) => r.id === id);
expect(healed?.toolAllowlist).toEqual(['alpha', 'beta']);
});
it('FAIL-OPEN: a present-but-corrupt tool_allowlist reads back as null (no restriction)', async () => {
// #185 re-review pt 8: normalizeRow's fail-open branch — the column is
// PRESENT but does not parse into a string[] (here a jsonb string scalar
// holding non-array JSON). The read must degrade to `null` ("no restriction"),
// not crash. (A warn is logged with the server id; not asserted here.)
const id = randomUUID();
await sql`
INSERT INTO ai_mcp_servers (id, workspace_id, name, transport, url, tool_allowlist)
VALUES (
${id}, ${ws}, ${`srv-${id}`}, 'http', 'https://example.com/mcp',
to_jsonb(${'{"not":"an array"}'}::text)
)
`.execute(db);
// Sanity: the column is present (a jsonb string scalar), not SQL NULL.
expect(await jsonbTypeof(id)).toBe('string');
// ...yet the read degrades to null (fail-open).
expect((await repo.findById(id, ws))?.toolAllowlist).toBeNull();
});
});
/**
* AiMcpServerRepo `instructions` text round-trip (#180). The column is plain
* text (no jsonb); blank/whitespace is normalized to null on both insert and
* update so an empty guide is never persisted.
*/
describe('AiMcpServerRepo instructions round-trip [integration]', () => {
let db: Kysely<any>;
let repo: AiMcpServerRepo;
let ws: string;
beforeAll(async () => {
db = getTestDb();
repo = new AiMcpServerRepo(db as any);
ws = (await createWorkspace(db)).id;
});
afterAll(async () => {
await destroyTestDb();
});
it('insert stores trimmed non-blank instructions and reads them back', async () => {
const row = await repo.insert({
workspaceId: ws,
name: `srv-${randomUUID()}`,
transport: 'http',
url: 'https://example.com/mcp',
instructions: ' Use search for fresh facts. ',
});
expect((await repo.findById(row.id, ws))?.instructions).toBe(
'Use search for fresh facts.',
);
});
it('insert normalizes blank/whitespace instructions to null', async () => {
const row = await repo.insert({
workspaceId: ws,
name: `srv-${randomUUID()}`,
transport: 'http',
url: 'https://example.com/mcp',
instructions: ' ',
});
expect((await repo.findById(row.id, ws))?.instructions).toBeNull();
});
it('insert with omitted instructions stores null', async () => {
const row = await repo.insert({
workspaceId: ws,
name: `srv-${randomUUID()}`,
transport: 'http',
url: 'https://example.com/mcp',
});
expect((await repo.findById(row.id, ws))?.instructions).toBeNull();
});
it('update sets, clears (blank => null), and leaves unchanged when absent', async () => {
const row = await repo.insert({
workspaceId: ws,
name: `srv-${randomUUID()}`,
transport: 'http',
url: 'https://example.com/mcp',
instructions: 'initial guide',
});
// Set a new value.
await repo.update(row.id, ws, { instructions: 'updated guide' });
expect((await repo.findById(row.id, ws))?.instructions).toBe(
'updated guide',
);
// Absent in the patch => unchanged.
await repo.update(row.id, ws, { name: 'renamed' });
expect((await repo.findById(row.id, ws))?.instructions).toBe(
'updated guide',
);
// Blank => cleared to null.
await repo.update(row.id, ws, { instructions: ' ' });
expect((await repo.findById(row.id, ws))?.instructions).toBeNull();
});
});

View File

@@ -104,7 +104,8 @@ export async function createWorkspace(
name: overrides.name ?? `ws-${suffix}`,
// hostname is uniquely constrained; keep it unique per workspace.
hostname: `host-${suffix}`,
settings: overrides.settings === undefined ? null : (overrides.settings as any),
settings:
overrides.settings === undefined ? null : (overrides.settings as any),
})
.returning(['id', 'settings'])
.executeTakeFirstOrThrow();
@@ -226,3 +227,37 @@ export async function createChat(
.executeTakeFirstOrThrow();
return { id: row.id as string };
}
export async function createMessage(
db: Kysely<any>,
args: {
workspaceId: string;
chatId: string;
userId?: string | null;
role?: string;
content?: string | null;
status?: string | null;
metadata?: unknown;
// Explicit timestamp so a test can control message ORDER (the default DB
// now() can tie within a millisecond, and the v4 id is not time-ordered).
createdAt?: Date;
},
): Promise<{ id: string }> {
const id = randomUUID();
const row = await db
.insertInto('aiChatMessages')
.values({
id,
workspaceId: args.workspaceId,
chatId: args.chatId,
userId: args.userId ?? null,
role: args.role ?? 'assistant',
content: args.content ?? null,
status: args.status ?? null,
metadata: (args.metadata ?? null) as any,
...(args.createdAt ? { createdAt: args.createdAt } : {}),
})
.returning(['id'])
.executeTakeFirstOrThrow();
return { id: row.id as string };
}

View File

@@ -1,14 +1,15 @@
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { Node as ProseMirrorNode } from '@tiptap/pm/model';
import {
FOOTNOTE_DEFINITION_NAME,
FOOTNOTE_REFERENCE_NAME,
computeFootnoteNumbers,
} from "./footnote-util";
computeFootnoteRefCounts,
} from './footnote-util';
export const footnoteNumberingPluginKey = new PluginKey<FootnoteNumberingState>(
"footnoteNumbering",
'footnoteNumbering',
);
/**
@@ -21,6 +22,9 @@ export const footnoteNumberingPluginKey = new PluginKey<FootnoteNumberingState>(
interface FootnoteNumberingState {
/** referenceId -> 1-based display number, for the current doc. */
numbers: Map<string, number>;
/** referenceId -> number of reference occurrences (>= 1), for the definition's
* multi-backlink UI (#168). */
refCounts: Map<string, number>;
/** Decorations rendering those numbers (refs + definitions). */
decorations: DecorationSet;
}
@@ -46,6 +50,7 @@ function buildFootnoteNumberingState(
doc: ProseMirrorNode,
): FootnoteNumberingState {
const numbers = computeFootnoteNumbers(doc);
const refCounts = computeFootnoteRefCounts(doc);
const decorations: Decoration[] = [];
doc.descendants((node, pos) => {
@@ -54,7 +59,7 @@ function buildFootnoteNumberingState(
if (num != null) {
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
"data-footnote-number": String(num),
'data-footnote-number': String(num),
style: `--footnote-number: "${num}";`,
}),
);
@@ -65,7 +70,7 @@ function buildFootnoteNumberingState(
if (num != null) {
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
"data-footnote-number": String(num),
'data-footnote-number': String(num),
style: `--footnote-number: "${num}";`,
}),
);
@@ -73,7 +78,11 @@ function buildFootnoteNumberingState(
}
});
return { numbers, decorations: DecorationSet.create(doc, decorations) };
return {
numbers,
refCounts,
decorations: DecorationSet.create(doc, decorations),
};
}
/**
@@ -90,6 +99,16 @@ export function getFootnoteNumber(
return footnoteNumberingPluginKey.getState(state)?.numbers.get(id);
}
/**
* Read the cached reference-occurrence count for `id` (how many `[^id]` links
* point at this definition). Drives the definition's multi-backlink UI (#168):
* `> 1` renders ↩ a b c …, each scrolling to its own occurrence. Returns 0 when
* the plugin is not installed or the id is unknown (caller treats as single).
*/
export function getFootnoteRefCount(state: EditorState, id: string): number {
return footnoteNumberingPluginKey.getState(state)?.refCounts.get(id) ?? 0;
}
/**
* ProseMirror plugin that renders footnote numbers as decorations. It never
* mutates the document (safe in read-only / share and in collaboration) — it

View File

@@ -1,14 +1,14 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { TextSelection, Transaction } from "@tiptap/pm/state";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { mergeAttributes, Node } from '@tiptap/core';
import { TextSelection, Transaction } from '@tiptap/pm/state';
import { ReactNodeViewRenderer } from '@tiptap/react';
import {
FOOTNOTE_DEFINITION_NAME,
FOOTNOTE_REFERENCE_NAME,
FOOTNOTES_LIST_NAME,
generateFootnoteId,
} from "./footnote-util";
import { footnoteNumberingPlugin } from "./footnote-numbering";
import { footnoteSyncPlugin, footnotePastePlugin } from "./footnote-sync";
} from './footnote-util';
import { footnoteNumberingPlugin } from './footnote-numbering';
import { footnoteSyncPlugin, footnotePastePlugin } from './footnote-sync';
export interface FootnoteReferenceOptions {
HTMLAttributes: Record<string, any>;
@@ -27,7 +27,7 @@ export interface FootnoteReferenceOptions {
enableSync?: boolean;
}
declare module "@tiptap/core" {
declare module '@tiptap/core' {
interface Commands<ReturnType> {
footnote: {
/**
@@ -42,8 +42,11 @@ declare module "@tiptap/core" {
removeFootnote: (id: string) => ReturnType;
/** Scroll to (and focus) a footnote definition by id. */
scrollToFootnote: (id: string) => ReturnType;
/** Scroll to (and select) a footnote reference by id. */
scrollToReference: (id: string) => ReturnType;
/** Scroll to a footnote reference by id. `index` selects WHICH occurrence
* to scroll to when the id is referenced more than once (reuse, #166):
* 0-based, defaults to the first. Used by the definition's multi-backlink
* UI (#168). */
scrollToReference: (id: string, index?: number) => ReturnType;
};
}
}
@@ -66,7 +69,7 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
// Superscript mark's <sup> rule.
priority: 101,
group: "inline",
group: 'inline',
inline: true,
atom: true,
selectable: true,
@@ -99,10 +102,10 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
return {
id: {
default: null,
parseHTML: (element) => element.getAttribute("data-id"),
parseHTML: (element) => element.getAttribute('data-id'),
renderHTML: (attributes) => {
if (!attributes.id) return {};
return { "data-id": attributes.id };
return { 'data-id': attributes.id };
},
},
};
@@ -113,7 +116,7 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
{
// High priority so the Superscript mark (which also matches <sup>) does
// not claim a footnote reference and drop it as empty content.
tag: "sup[data-footnote-ref]",
tag: 'sup[data-footnote-ref]',
priority: 100,
},
];
@@ -121,9 +124,9 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
renderHTML({ HTMLAttributes }) {
return [
"sup",
'sup',
mergeAttributes(
{ "data-footnote-ref": "", class: "footnote-ref" },
{ 'data-footnote-ref': '', class: 'footnote-ref' },
this.options.HTMLAttributes,
HTMLAttributes,
),
@@ -132,7 +135,7 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
// Plain-text representation (used by generateText / markdown text fallbacks).
renderText({ node }) {
return `[^${node.attrs.id ?? ""}]`;
return `[^${node.attrs.id ?? ''}]`;
},
addNodeView() {
@@ -170,8 +173,10 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
// Make sure the parent accepts an inline atom here.
const insertPos = selection.from;
if (!$from.parent.type.spec.content?.includes("inline") &&
!$from.parent.isTextblock) {
if (
!$from.parent.type.spec.content?.includes('inline') &&
!$from.parent.isTextblock
) {
return false;
}
@@ -311,19 +316,23 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
`[data-footnote-def][data-id="${id}"]`,
) as HTMLElement | null;
if (!dom) return false;
dom.scrollIntoView({ behavior: "smooth", block: "center" });
dom.scrollIntoView({ behavior: 'smooth', block: 'center' });
return true;
},
scrollToReference:
(id: string) =>
(id: string, index = 0) =>
({ editor }) => {
if (!id) return false;
const dom = editor.view.dom.querySelector(
// querySelectorAll returns the occurrences in document order, so the
// index maps 1:1 to the definition's a/b/c backlink (#168). Fall back
// to the first match for an out-of-range index.
const matches = editor.view.dom.querySelectorAll(
`sup[data-footnote-ref][data-id="${id}"]`,
) as HTMLElement | null;
);
const dom = (matches[index] ?? matches[0]) as HTMLElement | undefined;
if (!dom) return false;
dom.scrollIntoView({ behavior: "smooth", block: "center" });
dom.scrollIntoView({ behavior: 'smooth', block: 'center' });
return true;
},
};

View File

@@ -1,12 +1,12 @@
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { Node as ProseMirrorNode } from '@tiptap/pm/model';
/**
* Node type names for the footnote feature. Centralized so every part of the
* feature (nodes, plugins, commands) references the same string.
*/
export const FOOTNOTE_REFERENCE_NAME = "footnoteReference";
export const FOOTNOTES_LIST_NAME = "footnotesList";
export const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition";
export const FOOTNOTE_REFERENCE_NAME = 'footnoteReference';
export const FOOTNOTES_LIST_NAME = 'footnotesList';
export const FOOTNOTE_DEFINITION_NAME = 'footnoteDefinition';
/**
* Generate a uuidv7-style id (time-ordered). Implemented locally so editor-ext
@@ -15,10 +15,10 @@ export const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition";
*/
export function generateFootnoteId(): string {
const now = Date.now();
const timeHex = now.toString(16).padStart(12, "0");
const timeHex = now.toString(16).padStart(12, '0');
const rand = (length: number) => {
let out = "";
let out = '';
for (let i = 0; i < length; i++) {
out += Math.floor(Math.random() * 16).toString(16);
}
@@ -26,19 +26,19 @@ export function generateFootnoteId(): string {
};
// version 7 nibble, then variant (8..b) nibble.
const versioned = "7" + rand(3);
const versioned = '7' + rand(3);
const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16);
const variant = variantNibble + rand(3);
return (
timeHex.slice(0, 8) +
"-" +
'-' +
timeHex.slice(8, 12) +
"-" +
'-' +
versioned +
"-" +
'-' +
variant +
"-" +
'-' +
rand(12)
);
}
@@ -89,7 +89,7 @@ export function deriveFootnoteId(
* Purely deterministic.
*/
function suffix(n: number): string {
let out = "";
let out = '';
let x = n;
while (x > 0) {
const rem = (x - 1) % 25;
@@ -131,3 +131,19 @@ export function computeFootnoteNumbers(
}
return numbers;
}
/**
* Build a map of `referenceId -> number of reference occurrences` (>= 1) from
* document order. After #166 the same id may be referenced multiple times
* (reuse: one number, one definition, N forward links); this count drives the
* definition's multi-backlink UI (↩ a b c …, #168). Pure function of the doc.
*/
export function computeFootnoteRefCounts(
doc: ProseMirrorNode,
): Map<string, number> {
const counts = new Map<string, number>();
for (const id of collectReferenceIds(doc)) {
counts.set(id, (counts.get(id) ?? 0) + 1);
}
return counts;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,109 @@
/**
* The client seam. `pull.ts`/`push.ts` depend on a narrow STRUCTURAL interface
* rather than any concrete client, because the gitmost server writes NATIVELY —
* through repositories + collab `openDirectConnection`.
*
* `GitSyncClient` is that interface: the native datasource (server side)
* implements it, and the engine only ever uses `Pick<GitSyncClient, ...>`
* subsets of it. The signatures below MIRROR exactly the methods the engine's
* `pull.ts`/`push.ts` actually call (arg shapes + the fields the engine reads
* off each result), so a REST-style client is still structurally assignable and
* the native adapter has a precise contract.
*/
/**
* A page node as returned by `listSpaceTree` (the sidebar/tree walk, no body).
* The engine layout (`buildVaultLayout`) consumes `PageNode` from `./layout`,
* which only requires `id` (+ optional `title`/`slugId`/`parentPageId`); this
* lite shape documents the fields the tree walk surfaces. Real tree nodes also
* carry `position`, `icon`, `hasChildren` — kept open via the index signature.
*/
export interface GitSyncPageNodeLite {
id: string;
slugId?: string;
title?: string;
parentPageId?: string | null;
hasChildren?: boolean;
/** `listSpaceTree` nodes carry extra fields (position, icon, …). */
[key: string]: unknown;
}
/**
* The structural client the engine depends on. Only `Pick<GitSyncClient, ...>`
* subsets are ever used:
* - pull reads: `getPageJson` (+ the tree walk's `listSpaceTree`),
* - push writes: `importPageMarkdown` / `createPage` / `deletePage` /
* `movePage` / `renamePage`,
* - continuous (phase B+): `listRecentSince` / `listTrash` / `restorePage`.
*/
export interface GitSyncClient {
/**
* Full tree of page nodes for the space (or the subtree rooted at
* `rootPageId`), each WITHOUT body content. `complete` is `false` when the
* walk was truncated / a fetch failed — the pull side suppresses absence
* deletions on an incomplete tree (SPEC §8). Native impl returns
* `complete: true` always (reads the DB, not a paginated REST endpoint).
*/
listSpaceTree(spaceId: string, rootPageId?: string): Promise<{
pages: GitSyncPageNodeLite[];
complete: boolean;
}>;
/**
* One page WITH its ProseMirror body content. `applyPullActions` reads
* `id`, `slugId`, `title`, `parentPageId`, `spaceId` (for the file meta) and
* `content` (to stabilize/serialize). `updatedAt` is carried for the
* poll-suppression loop-guard.
*/
getPageJson(pageId: string): Promise<{
id: string;
slugId: string;
title: string;
parentPageId: string | null;
spaceId: string;
updatedAt: string;
content: unknown;
}>;
/**
* Merge a page's body from a self-contained markdown file (meta + body). The
* collab/Yjs write path (SPEC §2/§15.6) — never a raw jsonb overwrite.
* `applyPushActions` reads only an optional `updatedAt` off the result
* (via `extractUpdatedAt`, tolerant of extra fields).
*
* `baseMarkdown` is the last-synced version of the file (`refs/docmost/
* last-pushed`), the common ancestor for a THREE-WAY merge against the live
* doc so concurrent human edits survive (review #5). Optional/null -> 2-way.
*/
importPageMarkdown(pageId: string, fullMarkdown: string, baseMarkdown?: string | null): Promise<{
updatedAt?: string;
[key: string]: unknown;
}>;
/**
* Create a new page and return the assigned id at `data.id`
* (`applyPushActions` reads `result.data.id`, then writes it back into the
* file's meta). An optional top-level/`data.updatedAt` feeds the loop-guard.
*/
createPage(title: string, content: string, spaceId: string, parentPageId?: string): Promise<{
data: {
id: string;
};
updatedAt?: string;
[key: string]: unknown;
}>;
/** Soft-delete a page to Trash (SPEC §8). Result is not inspected. */
deletePage(pageId: string): Promise<unknown>;
/**
* Reparent a page (and optionally set its fractional-index `position`). The
* engine passes `position` UNDEFINED for now; the native impl computes a
* default between siblings. Result is not inspected.
*/
movePage(pageId: string, parentPageId: string | null, position?: string): Promise<unknown>;
/** Change a page's title only (no body touch). Result is not inspected. */
renamePage(pageId: string, title: string): Promise<unknown>;
/**
* Pages updated since `sinceIso` (the poll-safety reconciliation, SPEC §8).
* `spaceId` may be undefined (all spaces); `hardPageCap` bounds the walk.
*/
listRecentSince(spaceId: string | undefined, sinceIso: string | null, hardPageCap?: number): Promise<unknown[]>;
/** List soft-deleted (trashed) pages for the space (deletion detection). */
listTrash(spaceId: string): Promise<unknown[]>;
/** Restore a soft-deleted page from Trash. Result is not inspected. */
restorePage(pageId: string): Promise<unknown>;
}

View File

@@ -0,0 +1,13 @@
/**
* The client seam. `pull.ts`/`push.ts` depend on a narrow STRUCTURAL interface
* rather than any concrete client, because the gitmost server writes NATIVELY —
* through repositories + collab `openDirectConnection`.
*
* `GitSyncClient` is that interface: the native datasource (server side)
* implements it, and the engine only ever uses `Pick<GitSyncClient, ...>`
* subsets of it. The signatures below MIRROR exactly the methods the engine's
* `pull.ts`/`push.ts` actually call (arg shapes + the fields the engine reads
* off each result), so a REST-style client is still structurally assignable and
* the native adapter has a precise contract.
*/
export {};

View File

@@ -0,0 +1 @@
export declare function loadSettingsOrExit<T>(factory: () => T): T;

View File

@@ -0,0 +1,50 @@
import { ZodError } from 'zod';
// Turn a ZodError from settings validation into a clear, actionable startup
// message that names the offending env var(s), then exit(1) — no raw stack
// trace. Mirrors the Python new-project skeleton's load_settings_or_exit.
// A non-ZodError is left to propagate unchanged.
export function loadSettingsOrExit(factory) {
try {
return factory();
}
catch (err) {
if (!(err instanceof ZodError))
throw err;
const missing = [];
const invalid = [];
for (const issue of err.issues) {
const name = issue.path.length ? String(issue.path[0]) : '?';
// A missing required variable surfaces as an `invalid_type` issue whose
// received value was `undefined`. zod 3 exposed `issue.received` directly;
// zod 4 dropped that field and instead folds it into the message
// ("expected string, received undefined"). Detect both shapes so the
// missing-vs-invalid split holds across zod majors. NOTE: an invalid (but
// present) value uses a different code (invalid_format / invalid_value) or
// an `invalid_type` message that reports a non-undefined received (e.g.
// "received NaN" from a coerced number), so neither is misread as missing.
const i = issue;
const isMissing = issue.code === 'invalid_type' &&
(i.received === 'undefined' ||
/received undefined/i.test(i.message ?? ''));
if (isMissing)
missing.push(name);
else
invalid.push(`${name}: ${issue.message}`);
}
const lines = ['Configuration error in environment / .env:'];
if (missing.length) {
lines.push(' Missing required variable(s):');
for (const n of [...new Set(missing)])
lines.push(` - ${n}`);
}
if (invalid.length) {
lines.push(' Invalid value(s):');
for (const item of invalid)
lines.push(` - ${item}`);
}
lines.push('');
lines.push('Set them in .env (see .env.example) and try again.');
process.stderr.write(lines.join('\n') + '\n');
process.exit(1);
}
}

View File

@@ -0,0 +1,70 @@
import { VaultGit } from "./git.js";
import { GitSyncClient } from "./client.types.js";
import { Settings } from "./settings.js";
/**
* Absolute-path filesystem primitives the cycle needs. Injected (not imported)
* so the engine stays IO-free and unit-testable. `mkdir` is recursive; `rm` is
* force (a missing file is a no-op).
*/
export interface CycleFs {
readFile: (absPath: string) => Promise<string>;
writeFile: (absPath: string, text: string) => Promise<void>;
mkdir: (absDir: string) => Promise<void>;
rm: (absPath: string) => Promise<void>;
}
export interface RunCycleDeps {
spaceId: string;
/** The Docmost seam (reads for pull, writes for push). */
client: GitSyncClient;
/** The per-space git vault (a real working repo). */
vault: VaultGit;
/** Engine settings; `vaultPath` roots the relPath -> absolute-path mapping. */
settings: Settings;
fs: CycleFs;
log: (line: string) => void;
/**
* Delete-cap hook (the ONLY caller-specific policy). Called with the push
* dry-run's planned delete count (`Number.POSITIVE_INFINITY` when the dry-run
* itself failed, so the hook can fail safe) and the live client; returns the
* client to use for the REAL apply. The default (omitted) applies every op
* unmodified. gitmost uses it to neutralize deletes when over its cap.
*
* When omitted, NO dry-run is performed (one fewer push planning pass).
*/
resolveApplyClient?: (plannedDeletes: number, client: GitSyncClient) => GitSyncClient;
}
export interface RunCycleResult {
ran: boolean;
/** Set when the cycle short-circuited without running pull/push. */
skipped?: "merge-in-progress";
pull?: {
written: number;
deleted: number;
conflict: boolean;
};
push?: {
mode: string;
failures: number;
};
}
/**
* Run ONE full reconcile cycle for a space: PULL (Docmost -> vault) then PUSH
* (vault -> Docmost), under the engine's required branch choreography. This is
* the single entry point the app drives — it owns the staging order so it can
* never drift from the engine it ships with.
*
* Staging (the ⭐ data-loss-critical order, SPEC §6/§9):
* 1. assertGitAvailable + ensureRepo (the git state store must exist).
* 2. refuse on an unresolved merge (a prior conflicting pull); next checkout
* would fail otherwise.
* 3. ensureBranch('docmost','main') + checkout('docmost'). Pull writes MUST
* land on `docmost`, not `main`: applyPullActions commits on `docmost`,
* then checks out `main` and merges docmost -> main. Writing Docmost
* content straight onto `main` would clobber local file edits before push
* can diff them.
* 4. PULL: readExisting -> listSpaceTree -> computePullActions -> apply.
* 5. PUSH: optional dry-run to feed the delete-cap hook, then the real apply.
*
* Lock + cap POLICY live in the caller; this owns only the mechanics.
*/
export declare function runCycle(deps: RunCycleDeps): Promise<RunCycleResult>;

View File

@@ -0,0 +1,97 @@
import { readExisting, computePullActions, applyPullActions } from "./pull.js";
import { runPush } from "./push.js";
/**
* Run ONE full reconcile cycle for a space: PULL (Docmost -> vault) then PUSH
* (vault -> Docmost), under the engine's required branch choreography. This is
* the single entry point the app drives — it owns the staging order so it can
* never drift from the engine it ships with.
*
* Staging (the ⭐ data-loss-critical order, SPEC §6/§9):
* 1. assertGitAvailable + ensureRepo (the git state store must exist).
* 2. refuse on an unresolved merge (a prior conflicting pull); next checkout
* would fail otherwise.
* 3. ensureBranch('docmost','main') + checkout('docmost'). Pull writes MUST
* land on `docmost`, not `main`: applyPullActions commits on `docmost`,
* then checks out `main` and merges docmost -> main. Writing Docmost
* content straight onto `main` would clobber local file edits before push
* can diff them.
* 4. PULL: readExisting -> listSpaceTree -> computePullActions -> apply.
* 5. PUSH: optional dry-run to feed the delete-cap hook, then the real apply.
*
* Lock + cap POLICY live in the caller; this owns only the mechanics.
*/
export async function runCycle(deps) {
const { spaceId, client, vault, settings, fs, log, resolveApplyClient } = deps;
const vaultRoot = settings.vaultPath;
const abs = (relPath) => `${vaultRoot}/${relPath}`;
// 1. The engine state store is git: make sure the repo + branches exist
// before any tracked-file listing or diff.
await vault.assertGitAvailable();
await vault.ensureRepo();
// 2. Refuse to run on top of an unresolved merge (SPEC §9): a prior
// conflicting pull leaves the vault mid-merge; the next checkout would fail.
if (await vault.isMergeInProgress()) {
log(`vault has an unresolved merge — resolve it (or 'git merge --abort') ` +
`and re-run (SPEC §9); skipping cycle.`);
return { ran: false, skipped: "merge-in-progress" };
}
// 3. Pull writes happen on `docmost`; be on it BEFORE applying (see docstring).
await vault.ensureBranch("docmost", "main");
await vault.checkout("docmost");
// 4. PULL --------------------------------------------------------------------
const existing = await readExisting({
listTracked: () => vault.listTrackedFiles("*.md"),
readFile: (relPath) => fs.readFile(abs(relPath)),
});
const tree = await client.listSpaceTree(spaceId);
const pullActions = computePullActions({
pages: tree.pages,
treeComplete: tree.complete,
existing,
});
const pullResult = await applyPullActions({
client,
git: vault,
writeFile: (absPath, text) => fs.writeFile(absPath, text),
mkdir: (absDir) => fs.mkdir(absDir),
rm: (absPath) => fs.rm(absPath),
}, pullActions, vaultRoot);
// 5. PUSH --------------------------------------------------------------------
const pushDeps = {
settings,
git: vault,
makeClient: () => client,
readFile: (relPath) => fs.readFile(abs(relPath)),
writeFile: (relPath, text) => fs.writeFile(abs(relPath), text),
log,
};
let applyClient = client;
if (resolveApplyClient) {
// Plan the push as a DRY-RUN first to read the delete count, then let the
// caller decide the apply client (e.g. neutralize deletes over a cap). A
// failed dry-run yields Infinity so the hook can fail safe.
let plannedDeletes;
try {
const dry = await runPush(pushDeps, { dryRun: true });
plannedDeletes = dry.planned?.deletes ?? 0;
}
catch (err) {
log(`push dry-run planning failed (${err instanceof Error ? err.message : String(err)}); deferring deletion policy to the cap hook (fail-safe).`);
plannedDeletes = Number.POSITIVE_INFINITY;
}
applyClient = resolveApplyClient(plannedDeletes, client);
}
const pushResult = await runPush({ ...pushDeps, makeClient: () => applyClient }, { dryRun: false });
return {
ran: true,
pull: {
written: pullResult.written,
deleted: pullResult.deleted,
conflict: pullResult.merge.conflict,
},
push: {
mode: pushResult.mode,
failures: pushResult.failures?.length ?? 0,
},
};
}

259
packages/git-sync/build/engine/git.d.ts vendored Normal file
View File

@@ -0,0 +1,259 @@
/** Bot identity used for engine-authored vault commits (SPEC §7.3). */
export declare const BOT_AUTHOR_NAME = "Docmost Sync";
export declare const BOT_AUTHOR_EMAIL = "docmost-sync@local";
/** Default branch the vault repo is initialized on. */
export declare const DEFAULT_BRANCH = "main";
/**
* One row of `git diff --name-status` (SPEC §6 "ФС → Docmost"). `status` is the
* single-letter change code (`-M` rename detection on), `path` is the (new) file
* path; for a rename/copy (`R`/`C`) `oldPath` is the source and `path` is the
* destination, with `score` carrying git's similarity index (0–100).
*/
export interface DiffEntry {
status: "A" | "M" | "D" | "R" | "C";
/** New (destination) path. For A/M/D it is the only path. */
path: string;
/** Source path — present only for R/C. */
oldPath?: string;
/** Rename/copy similarity score (0–100) — present only for R/C. */
score?: number;
}
/** Result of a `merge`: whether it succeeded cleanly or left conflict markers. */
export interface MergeResult {
/** True when the merge applied cleanly (fast-forward or clean 3-way). */
ok: boolean;
/** True when the merge stopped on conflicts (markers left in the worktree). */
conflict: boolean;
/** Raw combined stdout+stderr, for logging/diagnostics. */
output: string;
}
/** Options for an engine-authored commit (provenance, SPEC §7.3). */
export interface CommitOptions {
authorName: string;
authorEmail: string;
/**
* Trailer lines appended to the commit message body (e.g.
* `Docmost-Sync-Source: docmost`). These are the machine-readable provenance
* the loop-guard keys on (SPEC §12, "commit-attribution").
*/
trailers?: string[];
}
/**
* A git wrapper bound to a single vault path. Construct once per vault; every
* method runs git with `cwd = vaultPath`.
*/
export declare class VaultGit {
private readonly vaultPath;
constructor(vaultPath: string);
/**
* Preflight: verify a runnable `git` binary is on PATH. The daemon shells out
* to system `git` for every vault operation, so a missing binary (e.g. a slim
* container image without git) must fail fast with an actionable message
* rather than a cryptic ENOENT deep inside the first real git call. Presence
* check only — we do NOT gate on a specific version. Runs `git --version`
* with NO `cwd` (the vault dir may not exist yet at preflight time).
*/
assertGitAvailable(): Promise<void>;
/**
* Run a git command in the vault and return trimmed stdout. THIN wrapper over
* the single `runRaw` primitive: throws a clear, unified Error (including
* stderr/stdout) on a non-zero exit.
*/
private run;
/**
* The ONE primitive every git invocation in this module flows through. Builds
* the full argv (`--no-pager -c core.quotepath=false <args>`), env, cwd, and
* maxBuffer, runs git, and NEVER throws — it returns the exit info so callers
* can treat a non-zero exit as either an error (`run`) or a meaningful state
* (e.g. a merge conflict, a porcelain diff that "fails" deliberately).
*
* - argv: ALWAYS prepends `--no-pager -c core.quotepath=false`, so git never
* blocks on a pager and always prints verbatim UTF-8 paths (no octal
* escaping/quoting). `quotepath=false` is the baseline for ALL path-
* printing commands (ls-files, diff --name-only, …).
* - cwd: `opts.cwd === null` -> do NOT set cwd (the preflight, where the
* vault dir may not exist); otherwise `opts.cwd ?? this.vaultPath`.
* - env: `vaultGitEnv(opts?.env)` (cwd-isolation + caller extras).
* - On a spawn/exec error we capture the error `message` too, so a failure
* before git could write to stderr (e.g. ENOENT) is NOT lost.
*/
private runRaw;
/**
* Ensure the vault directory exists and is an initialized git repo on `main`
* with an initial (empty) commit so branches exist. Idempotent: safe to call
* on every run. Sets a LOCAL bot identity for the vault repo if none is set
* (so engine commits never fall back to a global/unset identity).
*/
ensureRepo(): Promise<void>;
/** True if `cwd` is inside a git work-tree (the vault is initialized). */
private isRepo;
/** True if a LOCAL git config key is set in the vault repo. */
private hasLocalConfig;
/** True if the repo has at least one commit (HEAD resolves). */
private hasAnyCommit;
/** True if a branch with the given name exists. */
branchExists(name: string): Promise<boolean>;
/**
* Create `name` from `fromBranch` if it does not already exist. No-op (and no
* checkout) when the branch is already present.
*/
ensureBranch(name: string, fromBranch: string): Promise<void>;
/** Name of the currently checked-out branch. */
currentBranch(): Promise<string>;
/** Check out an existing branch. */
checkout(name: string): Promise<void>;
/** Stage everything (adds, modifications, deletions). */
stageAll(): Promise<void>;
/**
* True if the vault is mid-merge (an unresolved merge from a previous run,
* SPEC §9 / §12). Detected via a `MERGE_HEAD` ref OR any unmerged
* (conflicted) index entries (`git ls-files -u`). The pull cycle checks this
* BEFORE any checkout so a left-over merge produces a clear, actionable
* message instead of a raw "you need to resolve your current index first"
* failure deep inside `checkout`. This is what makes re-runs converge
* (resumability, SPEC §12).
*/
isMergeInProgress(): Promise<boolean>;
/**
* Commit the currently STAGED changes with an explicit author/committer
* identity and the given trailers appended to the message body (SPEC §7.3
* provenance). Returns `true` if a commit was made, `false` if there was
* nothing to commit (graceful no-op). The caller is expected to have staged
* its changes first (e.g. via `stageAll`).
*/
commit(message: string, opts: CommitOptions): Promise<boolean>;
/**
* Low-level commit used by both `commit` and `ensureRepo`'s initial commit.
* Builds the full message with appended trailers and sets author + committer
* identity via env vars (so the committer matches the author, not the repo
* default).
*/
private commitRaw;
/**
* Merge `fromBranch` into the current branch (`git merge --no-edit`).
* Fast-forwards when possible; performs a real 3-way merge otherwise. Conflict
* state is SURFACED (returned), NOT auto-resolved (SPEC §9): the conflict
* markers are left in the worktree for manual resolution by a later increment,
* and — critically — nothing is pushed to Docmost (we never write to Docmost
* anyway).
*/
merge(fromBranch: string): Promise<MergeResult>;
/** True if the index has any unmerged (conflicted) paths. */
private hasUnmergedPaths;
/**
* List tracked files on the current branch (paths relative to the vault
* root, forward-slash separated). An optional glob (a git pathspec) narrows
* the listing, e.g. `"*.md"`.
*
* The target wiki is RUSSIAN, so vault file names routinely contain Cyrillic
* (e.g. `Колонка.md`). With git's DEFAULT `core.quotepath=true`, `ls-files`
* returns non-ASCII paths octal-escaped and double-quoted (`"\320\232..."`),
* which `src/pull.ts` `readExisting` would then parse as garbage paths,
* breaking move/duplicate detection. We defeat that two ways at once:
* - `core.quotepath=false` disables the octal-escape/quoting. It is now the
* `runRaw` argv baseline (prepended to EVERY invocation), so we no longer
* pass it inline here.
* - `-z` emits NUL-delimited RAW UTF-8 paths (no quoting, no newline
* ambiguity), which we split on `\0`.
* We read the RAW stdout (NOT the trimming `run()` helper, which would mangle
* the NUL-delimited bytes) and split on `\0`, dropping empty entries. Paths
* are returned verbatim — git already emits forward slashes.
*/
listTrackedFiles(glob?: string): Promise<string[]>;
/**
* Diff two refs with `--name-status -M -z` and parse the NUL-delimited output
* (SPEC §6: the FS→Docmost push direction diffs `main` against
* `refs/docmost/last-pushed`). Rename detection is ON (`-M`), so a moved/renamed
* file is reported as a single `R` row with both its old and new path instead
* of a delete+add pair — that distinction is what lets the push planner tell a
* move from a delete+create (SPEC §8 "Move vs delete").
*
* `-z` makes git emit NUL-delimited RAW UTF-8 records (the Russian wiki has
* Cyrillic file names) with NO quoting/escaping. The record shape differs by
* status:
* - A/M/D: `status\0path\0`
* - R/C: `Rnnn\0oldPath\0newPath\0` (nnn = similarity score, e.g. `R100`)
* We read the RAW stdout (not the trimming `run()` helper, which would mangle
* the NUL bytes), split on `\0`, drop the trailing empty entry, and walk the
* tokens pulling 1 or 2 path tokens per status. Paths are returned verbatim.
*/
diffNameStatus(fromRef: string, toRef: string): Promise<DiffEntry[]>;
/**
* Resolve a ref/commit-ish to its full SHA, or `null` if it does not exist.
* `rev-parse --verify --quiet` exits non-zero (and prints nothing) for an
* unknown ref, so a non-zero exit maps cleanly to `null`. Used to read
* `refs/docmost/last-pushed` (SPEC §5) — which is absent before the first push.
*/
revParse(ref: string): Promise<string | null>;
/**
* Read a ref to its SHA, or `null` if unset. Thin alias over `revParse`,
* named for the push direction's marker `refs/docmost/last-pushed` (SPEC §5:
* "что из `main` уже отражено в Docmost").
*/
readRef(ref: string): Promise<string | null>;
/**
* Point `ref` at `target` (`git update-ref <ref> <target>`). Used to advance
* `refs/docmost/last-pushed` to the just-pushed `main` commit after a push
* (SPEC §6 step 3 / §5). `target` may be a SHA or any commit-ish git accepts.
*/
updateRef(ref: string, target: string): Promise<void>;
/**
* Fast-forward `branch` to `toCommit` — but ONLY if it is a TRUE fast-forward,
* i.e. the current `branch` tip is an ancestor of `toCommit` (verified via
* `git merge-base --is-ancestor <branch> <toCommit>`). Used to advance the
* `docmost` mirror branch after a clean push (SPEC §6 step 3 / §10): once a
* push succeeds, Docmost already contains the pushed `main` content, so the
* mirror must reflect it — otherwise the NEXT pull would diff our own write
* back and re-pull it (loop-guard).
*
* SAFETY — never force, never clobber divergent history:
* - If `branch` IS an ancestor of `toCommit`, advance it with
* `git update-ref refs/heads/<branch> <toCommit>`. The `docmost` branch is
* NOT checked out during a push (push works on `main`), so updating the ref
* directly is safe and avoids any working-tree touch.
* - If `branch` is NOT an ancestor (divergent / would-be non-fast-forward),
* do NOT move it — return `{ ok: false, reason: 'not-fast-forward' }` and
* let the caller log it. We must never overwrite a `docmost` history that
* has commits the push base does not contain.
*
* Returns `{ ok: true }` when the branch was advanced (or already at
* `toCommit`, a degenerate fast-forward), `{ ok: false, reason }` otherwise.
* A missing `branch` or `toCommit` also yields `{ ok: false }` with a reason.
*/
fastForwardBranch(branch: string, toCommit: string): Promise<{
ok: boolean;
reason?: string;
}>;
/**
* Read a file's content at a specific ref (`git show <ref>:<path>`), or `null`
* if the path does not exist there. Used by the push direction to read the
* PRE-IMAGE of a DELETED file (e.g. at `refs/docmost/last-pushed`) so its
* `docmost:meta` — and therefore its `pageId` — can be recovered to translate
* the deletion into a `delete_page` (SPEC §6/§8: only TRACKED files, i.e. ones
* that had a pageId, are deleted in Docmost). A non-zero exit (path absent at
* that ref) maps to `null` rather than throwing.
*/
showFileAtRef(ref: string, path: string): Promise<string | null>;
}
/**
* Build the environment for a vault git invocation (SPEC §12 cwd-isolation).
* Used by the single `runRaw` primitive every git command flows through, so
* these pins apply uniformly (including the `git --version` preflight).
*
* cwd-isolation is this module's central safety guarantee: every git command
* MUST operate on the vault repo at `cwd: vaultPath` and nothing else. An
* inherited `GIT_DIR` / `GIT_WORK_TREE` in `process.env` would silently
* redirect the operation away from `cwd` (e.g. to the source repo or another
* checkout), defeating that guarantee. So we always strip them, regardless of
* whatever else the caller adds (author/committer identity, etc.).
*
* Exported for unit testing.
*/
export declare function vaultGitEnv(extra?: Record<string, string>): NodeJS.ProcessEnv;
/**
* Build a commit message body with trailer lines appended (SPEC §7.3). The
* trailers are separated from the subject by a blank line so `git interpret-
* trailers` / `git log --format=%(trailers)` parse them as trailers.
* Exported for unit testing.
*/
export declare function buildCommitMessage(subject: string, trailers?: string[]): string;

Some files were not shown because too many files have changed in this diff Show More