Compare commits

...

36 Commits

Author SHA1 Message Date
a
40d1cdfc77 refactor(review): address #230 third review — callout dedup, ticket/type tidy
Approve-with-comments follow-ups (no blockers):

- callout: unify the GitHub-callout feature ticket on #192 (the callout-paste
  feature the CHANGELOG already tracks); #218 is the public-share security work.
  Fixed the code comment and test reference.
- export/utils.spec: pin current behavior of a leading-dot name (".gitignore" ->
  "") — same bug class as #204 but unreachable via the sole caller, so document
  not change.
- share.types: narrow ISharedPage to the actual /shares/page-info allowlist
  (page -> Pick of id/slugId/title/icon/content; trimmed share; dropped the
  spurious `extends IShare`). Verified all three consumers (shared-page,
  link-view, mention-view) read only allowlist fields.
- editor-ext: extract shared CALLOUT_TYPES / normalizeCalloutType /
  renderCalloutHtml into callout-common.marked.ts; both tokenizers
  (`:::type` and `> [!type]`) now share the renderer + type dict while staying
  separate. Eliminates the byte-identical renderer + duplicated type list.
- share.service: extract named predicate shareIdGrantsAccess(requestedShareId,
  resolvedShare) for the id-or-key fast path (naming only, no control-flow
  change); kept narrower than resolveReadableSharePage's id-only gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 22:11:16 +03:00
a
525172104a fix(review): address #230 re-review — stale breadcrumb, swallowed error, i18n, docs
Approve-with-comments follow-ups:

- breadcrumb: fix the reverse regression where navigating A->B to a page absent
  from the lazily-built tree (before its ancestors load) left the previous
  page's clickable chain on screen. New pure computeBreadcrumbState clears a
  stale chain that doesn't end at the current page, while keeping one that does
  (no blank flash for an already-resolved page); unit-tested for the
  navigated-to-absent-page case.
- share.service: getShareAncestorPage no longer swallows DB errors silently —
  now a live public-share path (isPageReachableThroughShare), so a transient
  error is logged with ancestor/child ids and still fails closed (caller 404s)
  instead of becoming a traceless misleading "not found".
- i18n: register the new "Connecting… (read-only)" key (U+2026 ellipsis) in
  en-US (source of truth) and ru-RU (Подключение… (только чтение)).
- share.service: correct the FUTURE note — 3 callers pass no shareId
  (share-alias.controller/.service, share-seo.controller); the two ai-chat
  callers already pass a real shareId.
- CHANGELOG: add Unreleased Changed/Fixed/Security entries for #216 opt-in
  sub-pages default, #218 trimmed page-info payload + forged-shareId 404, #204
  export internal-link name, #206/#218 breadcrumb, #192 callout paste, #218
  editor pre-sync read-only gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:31:49 +03:00
a
c9d252cf2a fix(review): address PR #230 review — payload type, breadcrumb helper, tests
Review follow-ups for the combined QA-UI fixes (#216/#206/#204/#218/#192):

- export/utils: correct the misleading getInternalLinkPageName comment — a
  bare `v1.2` loses its last dot-segment (`v1`); dots survive only in
  multi-segment names like `v1.2.md` -> `v1.2`.
- share: extract toPublicSharePayload(page, share): PublicSharePayload, an
  explicit allowlist type+mapper replacing the inline literal in the
  /shares/page-info anonymous path (#218). Add share.controller.spec.ts that
  stubs getSharedPage returning internal fields and asserts the response key
  set EXACTLY equals the whitelist (page + share), so any `...shareData`
  regression or new leaking field fails. Also key-tests the extracted mapper.
- breadcrumb: extract pure resolveBreadcrumbNodes(treeData, ancestors, pageId)
  (tree-hit -> tree; tree-miss -> map ancestors via canonical pageToTreeNode,
  dropping the as-any casts; else null) and unit-test all three branches.
- share-modal: RTL test asserting enabling a share calls mutateAsync with
  includeSubPages: false (#216 security default).
- share.service: one-line note at getSharedPage on the deferred consolidation
  of the ancestor-aware match into resolveReadableSharePage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 20:09:48 +03:00
claude code agent 227
2d36641f28 test(coverage): add regression tests for issues #192, #206, #204
Additive test coverage across server, editor-ext, client and mcp.

#192 — AiChatService.stream integration (Section 3, against real Postgres):
- new apps/server/test/integration/ai-chat-stream.int-spec.ts drives the real
  streamText through a seeded ai/test MockLanguageModelV3 and a real Node
  ServerResponse, covering: onError persists an assistant error record
  (status 'error' + partial answer + provider cause in metadata); external MCP
  client closed exactly once on BOTH onFinish and onError; anti-tamper —
  history is rebuilt from the DB transcript, not from body.messages.

#206 — red-team findings (most already fixed+tested in #212):
- mdrt-2 (UNFIXED, data loss): turndown.dataloss.test.ts documents that
  pageBreak / transclusionReference / mention are silently dropped on Markdown
  export (characterization + it.fails for the desired survive-export contract).
- persist-6 (UNFIXED, data loss): persistence-store.spec.ts adds an it.failing
  documenting that a momentarily-empty live doc overwrites non-empty content
  (left unfixed — a store-side empty-guard is a behaviour change).

#204 — test-strategy plan, highest-priority subset:
- Phase 1: mcp-clients.lease.spec.ts covers the external MCP client
  lease/refcount/eviction lifecycle (leak / premature-close / double-close).
- Phase 2 data-integrity pure functions: editor-ext table-utils
  (transpose/moveRow/convert round-trip) and math tokenizer false-positive
  guard; client emoji-menu (+ it.fails for the unguarded localStorage
  JSON.parse bug), sort-cells, normalizeTableColumnWidths; mcp htmlEmbed/
  pageBreak markdown data-loss + footnote-diff; server export
  getInternalLinkPageName extensionless-path bug — FIXED (small/clear) + tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:15:55 +03:00
claude code agent 227
22852be2e2 fix(qa): resolve UI bugs from #216 and #218
Public sharing (#218):
- Bind public-share content to the requested shareId. getSharedPage now
  enforces dto.shareId (forwarded from /share/:shareId/p/:slug): the page must
  be reachable THROUGH that exact share (its own share, or an includeSubPages
  ancestor that contains it). A forged/mismatched shareId 404s instead of
  rendering off the slug alone and no longer leaks the real canonical key via
  redirect. A request with no shareId keeps the legacy slug-capability path.
- Trim /shares/page-info: drop internal metadata (creatorId, spaceId,
  workspaceId, contributorIds, lastUpdated*, parent/position, lock/template
  flags, timestamps) from the anonymous payload.
- Default share-to-web includeSubPages to false (opt-in), so enabling a share
  no longer silently exposes the whole sub-tree (#216).

Editor (#218):
- Harden the new-page pre-sync window: the body editor is kept read-only until
  the collab provider is Connected and synced, so early keystrokes can't land
  only in local ProseMirror and then be clobbered by the server's empty doc.
- Surface a "Connecting… (read-only)" affordance during the static phase so
  input isn't silently swallowed.

Other:
- Breadcrumb: resolve from the page's own ancestor data (/pages/breadcrumbs)
  instead of waiting for the lazily-built sidebar tree, so deep pages don't
  render a blank breadcrumb for seconds.
- Pasting GitHub `> [!type]` callouts now converts to a callout node instead of
  a literal blockquote (new marked extension wired into markdownToHtml).

Tests: editor-sync-state gate (client), getSharedPage share-binding (server),
github-callout markdown conversion (editor-ext).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 05:54:06 +03:00
claude_code
904f7b4303 fix(agent-roles): bump proofreader v3 + guard against content edits without a version bump
The proofreader role content was changed (STYLE SHEET block removed) without
bumping its catalog version, so clients never saw an update. Bump proofreader
2 -> 3, and add a content-hash guard so this can't happen silently again.

- index.json: proofreader version 2 -> 3
- scripts/check.mjs: new content-hash guard. A scripts/content-hashes.json lock
  maps slug -> { version, hash } (sha256 over emoji/autoStart/name/description/
  instructions/launchMessage across all languages). check.mjs now fails when a
  role's content changed without bumping its version; the new --update-hashes
  (alias --fix) refreshes the lock but refuses to write when a bump is missing.
- check.mjs: also require every index.json role to carry a finite numeric
  version (matches the server's catalog validation), with defense-in-depth so a
  missing version can't bypass the bump guard.
- scripts/content-hashes.json: new lock artifact (not part of the served catalog).
- README.md: document the guard, the lockfile, --update-hashes, and the
  prune-then-readd limitation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 05:18:39 +03:00
claude_code
cac84dec9b refactor(ai-roles): make catalog URL a per-branch image default, drop local-fs source
The agent-roles catalog source is no longer hardcoded in app code and no longer
supports a local filesystem directory. The provider fetches only from an
http(s):// base URL read at runtime from AI_AGENT_ROLES_CATALOG_URL; an empty or
non-http value yields a 502 (catalog unavailable). The image ships a per-branch
default for that URL (set in CI), still overridable at runtime via the env var.

- provider: drop readLocal + node:fs/node:path; readRelative requires http(s)
  and 502s otherwise; remote fetch/streaming-cap/SSRF guards unchanged.
- environment.service: keep AI_AGENT_ROLES_CATALOG_URL (default ''); comment
  reflects the per-branch build-time default that is runtime-overridable.
- Dockerfile: add ARG+ENV AI_AGENT_ROLES_CATALOG_URL in the installer stage as
  the image default.
- CI: develop.yml builds with the develop raw URL; release.yml defines the main
  raw URL once in workflow env and references it from both build steps.
- tests: replace local-fixture tests with remote-mock happy/malformed bundle
  tests and a non-http => 502 case; path-traversal block uses an https source.
- docs: update .env.example, CHANGELOG (#222), agent-roles-catalog/README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:54:43 +03:00
claude_code
90dd8f1481 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-27 03:54:24 +03:00
39113c9dbf Merge pull request 'fix(share): custom address edit renames in place instead of duplicating (#226)' (#227) from fix/share-alias-rename into develop
Reviewed-on: #227
2026-06-27 03:53:31 +03:00
claude_code
1367070468 refactor(agent-roles): drop style-sheet duties from copyeditor role
Remove the STYLE SHEET / СТАЙЛ-ШИТ section from the copyeditor
(proofreader) role and clean up all dangling references to it in both
the ru and en editorial bundles:
- description: drop "maintains a style sheet" / "ведёт стайл-шит"
- instructions: remove the STYLE SHEET block
- instructions: drop "record it in the style sheet" mentions in the
  WHAT YOU DO and WHEN UNSURE sections

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:46:03 +03:00
claude code agent 227
767ac9e7e2 fix(share): guard alias swap/rename against concurrent-delete race; share unique-violation helpers
Address PR #227 re-review (comment 2193).

- Stability: `updatePageId`/`updateAlias` now `executeTakeFirstOrThrow`, so a row
  reaped by a concurrent `removeAlias` between the read and the UPDATE (READ
  COMMITTED) raises `NoResultError` instead of returning `undefined`. The service
  maps that to a retryable `ConflictException` (`ALIAS_PAGE_RACE`) rather than a
  200-without-alias (swap) or a generic 400 from `undefined.id` (rename). Tests
  cover both branches.
- Simplification: drop the redundant secondary "unexpected unique index" warn and
  the now-unused `UNIQUE_ALIAS_INDEX` const (the constraint name is already logged
  unconditionally; both index branches still distinguish "Alias already taken" vs
  ALIAS_PAGE_RACE).
- Architecture: extract `isUniqueViolation`/`violatedConstraint` into
  database/utils.ts; adopt them in the share-alias service and favorite.repo
  (the bare `23505` check). ai-agent-roles (#222) is on a separate unmerged branch
  and should adopt them after #227 merges (noted at the helpers). Helper unit test
  added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 03:33:33 +03:00
claude_code
2a4ef9267e refactor(ai-roles): bake catalog URL at image build, drop local-fs source
The agent-roles catalog source is no longer hardcoded in app code and no
longer supports a local filesystem directory. The provider now fetches only
from an http(s):// base URL read from AI_AGENT_ROLES_CATALOG_URL; an empty or
non-http value yields a 502 (catalog unavailable). The default URL is baked
into the Docker image at build time and set per branch in CI.

- provider: drop readLocal + node:fs/node:path; readRelative requires http(s)
  and 502s otherwise; remote fetch/streaming-cap/SSRF guards unchanged.
- environment.service: keep AI_AGENT_ROLES_CATALOG_URL (default ''); comment
  updated to reflect build-time injection, remote-only.
- Dockerfile: add ARG+ENV AI_AGENT_ROLES_CATALOG_URL in the installer stage.
- CI: develop.yml builds with the develop raw URL; release.yml (both build
  steps) with the main raw URL.
- tests: replace local-fixture tests with remote-mock happy/malformed bundle
  tests and a non-http => 502 case; path-traversal block uses an https source.
- docs: update .env.example, CHANGELOG (#222), agent-roles-catalog/README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:32:48 +03:00
claude code agent 227
309719abc6 fix(share): show reassign hint instead of dead-end error for a taken custom address
The share modal flagged a custom address already owned by another page with a
red "This address is already in use" error driven by the availability probe.
That reads as terminal even though Save actually triggers the server's
409 `ALIAS_REASSIGN_REQUIRED` and opens the "Move custom address?" confirm
modal that retargets the address to the current page — so the reassign path was
hidden behind what looked like a hard stop.

Replace the red error with an informational description hint ("This address is
in use. Saving will move it to this page.") and keep Save enabled, so the
existing confirm-reassign flow is discoverable. Renaming to a FREE name was
already correct (the probe returns available -> no error -> server renames the
single row in place); this only changes the taken-name presentation.

Verified end-to-end in a real browser against a live stand on this branch:
- A (free rename `test`->`test2`): 200, same alias row renamed in place, link
  becomes `/l/test2`, no error, exactly one DB row for the page.
- B (`test2` owned by another page): hint shown (no dead-end error), Save ->
  409 ALIAS_REASSIGN_REQUIRED -> "Move custom address?" modal -> confirm -> 200,
  the single row retargets, one row each.
- C (same-name re-save): Save disabled (no-op); first-time set inserts.

Add a client component test covering both branches (taken name -> hint not
error + Save enabled; 409 -> reassign modal -> confirm sends confirmReassign).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 03:24:00 +03:00
claude_code
3511301331 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-27 03:12:27 +03:00
claude_code
b65ca6d7dd chore(agent-roles-catalog): merge copy-editor into proofreader, refresh editorial roles
Merge the copy-editor (📐) and proofreader (🧹 "Корректор") editorial roles
into a single role. Keep slug `proofreader`, drop slug `copy-editor`, and set
the merged role's emoji to 📐.

- index.json: remove copy-editor; bump structural-editor, line-editor,
  fact-checker, proofreader to version 2 (narrator unchanged); update editorial
  bundle description (ru/en).
- bundles/editorial/{ru,en}.json: delete copy-editor; refresh emoji/name/
  description/instructions of structural-editor, line-editor, fact-checker and
  the merged proofreader verbatim from gitmost-agenty-ru.md / gitmost-agents-en.md;
  preserve autoStart and launchMessage; leave narrator untouched.
- README.md: drop copy-editor from the editorial role list.

Validated with scripts/check.mjs (OK).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:12:14 +03:00
4a3819373d Merge pull request 'feat(ai-chat): auto-open last chat bound to the document (#191)' (#209) from feat/191-chat-doc-binding into develop
Reviewed-on: #209
2026-06-27 02:56:31 +03:00
claude code agent 227
e682bbccd1 fix(share): order swap delete-before-update and distinguish unique violations
Addresses review on PR #227.

- setAlias confirmed-reassign branch: DELETE the target page's existing
  alias row(s) BEFORE retargeting `byName` onto the page, instead of after.
  The new partial unique index `(workspace_id, page_id)` is non-deferrable
  and checked at each statement, so retargeting first momentarily left two
  rows for the page -> immediate 23505 -> rolled-back tx surfaced as a
  misleading "Alias already taken" (regressing a previously-working swap onto
  a page that already had its own alias). The reordered branch needs no
  trailing self-heal. JSDoc updated to describe the real ordering.

- catch block: the postgres@3.x driver exposes the violated index as
  `err.constraint_name` (with `.constraint` as a fallback). Map
  `share_aliases_workspace_id_alias_unique` -> "Alias already taken" and the
  new `share_aliases_workspace_id_page_id_unique` -> a distinct ALIAS_PAGE_RACE
  outcome (a concurrent same-page write, not a name clash). Always log the
  constraint name on any 23505 so the race is diagnosable.

- migration 20260627T120000: document that the dedup DELETE is intended,
  irreversible data loss (old duplicate `/l/<old>` links start 404ing after
  upgrade; `down()` cannot restore the rows). Same note added to CHANGELOG
  [Unreleased] Fixed.

Tests:
- integration: confirmed reassign onto a page that ALREADY has its own alias
  (RED before the reorder); migration up() dedup scoping across pages and a
  second workspace; mid-transaction error -> BadRequest with clean rollback.
- unit: constraint_name distinguishing (alias index, page_id index, fallback
  `.constraint`, no-info default) and non-unique error -> BadRequest; retarget
  test now asserts delete-before-update order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 02:52:33 +03:00
claude code agent 227
9d2bec8eb8 fix(share): keep exactly one custom address per page on alias edit (#226)
Editing an existing share alias (e.g. slug `te` -> `ted`) failed to update
the displayed `/l/<alias>` link: `setAlias()` looked the requested slug up by
name and, if free, INSERTed a brand-new row, leaving the page with multiple
alias rows. The modal then read via `findByPageId().executeTakeFirst()` with no
`ORDER BY`, so Postgres returned an arbitrary (in practice the oldest, stale)
row. Every edit also spawned an orphan row that kept a live `/l/<old>` link
forever. Regression of #205.

Enforce the invariant "a page has EXACTLY ONE custom address":
- `setAlias()` now resolves the page's current alias row and RENAMES it in
  place when the requested name is free (insert only when the page has none),
  keeps the same-name no-op and the cross-page 409 `ALIAS_REASSIGN_REQUIRED`
  + confirmed-retarget flow, and after any successful write DELETEs all other
  alias rows for the page (self-heal). Runs in one transaction so the page is
  never transiently empty or duplicated.
- repo: add `updateAlias` (rename) and `deleteOthersForPage`; make
  `findByPageId` deterministic with `ORDER BY created_at DESC, id DESC`.
- migration: dedup existing rows (keep newest per page) + a PARTIAL unique
  index `(workspace_id, page_id) WHERE page_id IS NOT NULL` so dangling
  aliases still coexist while live ones are one-per-page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 02:51:51 +03:00
b6630deb32 Merge pull request 'feat(ai-roles): импортируемый мультиязычный каталог ролей агента' (#222) from feature/agent-roles-catalog into develop
Reviewed-on: #222
2026-06-27 02:39:27 +03:00
claude code agent 227
7ef98a663b Address PR #222 review: import-mutation notification tests + redirect-SSRF hardening
ITEM 1: cover useImportAiRolesFromCatalogMutation onSuccess notifications.
Add import-from-catalog-message.test.tsx (twin of update-from-catalog-message)
asserting the always-shown summary (errors:[]) and the additional red
"Failed to import N role(s)" notification when result.errors is non-empty.

ITEM 2: pass redirect:'error' to the remote catalog fetch in fetchRemote so a
compromised-but-trusted upstream cannot 3xx the fetch into the internal network
(redirect-SSRF). Add provider specs asserting the option is passed and that a
redirect rejection maps to BadGatewayException.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 02:36:28 +03:00
109ab10fc5 Merge pull request 'fix(temporary-notes): tree clock marker updates without reload + mobile-friendly full-width create buttons' (#225) from fix/temporary-notes-ui into develop
Reviewed-on: #225
2026-06-27 01:39:10 +03:00
claude code agent 227
2b7c861f78 Address PR #222 re-review: fix source-uniqueness detection + coverage/cleanups
MUST-FIX
- isSourceUniqueViolation read the wrong error field: kysely-postgres-js
  (postgres@3.4.8) puts the violated constraint on `constraint_name`, not
  node-postgres' `.constraint`, so a concurrent same-slug+language import's
  23505 was never recognized as a source-collision and surfaced a false
  "name already exists" error. Now read `constraint_name` (with `.constraint`
  as a fallback for other drivers). Fix the faked test fixture (it built the
  error with the same wrong `.constraint` field, masking the bug): it now
  uses `constraint_name`, so the test genuinely exercises the skip path and
  FAILS against the unfixed code.
- Extract the catalog modal's role-state computation into a pure
  `catalogRoleInstallState(role, workspaceRoles, language)` helper (mirrors
  role-launch.ts) and cover it with vitest: import / installed / update /
  same-slug-different-language.

SUGGESTIONS
- Restore IAiRoleUpdateFromCatalogResult as a discriminated union mirroring
  the server; narrow the consumer via `"reason" in result` (the boolean
  discriminant does not narrow under strictNullChecks:false).
- README: add a "How it's served" section documenting AI_AGENT_ROLES_CATALOG_URL
  (remote http(s) base / local path / empty => in-repo folder).
- check.mjs: drop the redundant `const key = slug` alias.
- Cover the reason->message mapping in useUpdateAiRoleFromCatalogMutation
  (4 branches) via renderHook with a mocked service.
- Cover importFromCatalog "bundle not in index" => BadGateway.
- Cover updateFromCatalog "slug in index but missing in bundle file" =>
  not-in-catalog.

ARCHITECTURE
- Extract the shared catalog read prefix: a private `loadBundleById`
  (fetchIndex -> meta -> fetchBundle -> versionMap) reused by getCatalogBundle
  and importFromCatalog, and a `catalogRoleContentFields` mapper shared by the
  import insert and update patch. The three orchestrations and their distinct
  write paths stay separate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 01:01:29 +03:00
claude code agent 227
d181b5c4ff test(temporary-notes): cover the create race-guard, broadcast deadline + cache patch; unify page->tree-node mappers
Address review comment 2159 on the temporary-notes UI work.

Tests:
- tree-model: cover handleCreate's race-guard temporaryExpiresAt patch — (a)
  server node inserted WITHOUT a deadline + create response carries one => node
  gains the deadline; (b) node already has a deadline => not overwritten, prev
  returned by reference.
- ws-tree.service.spec: broadcastPageCreated now asserts the deadline is carried
  when present and pinned to null (`?? null`) when absent.
- page-embed-query (new spec): syncTemporaryExpiresInCache patches the in-tree
  node's temporaryExpiresAt, and leaves the atom value at the same reference when
  the id is absent from the loaded tree (no write).

Refactor (closes the drift bug-class at the root):
- Client: extract one canonical pageToTreeNode(page, overrides) mapper in
  tree/utils and route buildTree, handleCreate's optimistic insert, the restore
  mutation and the duplicate handler through it. Restore stays permanent (server
  nulls temporaryExpiresAt) and duplicate stays permanent (server arms no timer)
  — both now reflect the server without a reload, where before they dropped the
  field entirely.
- Server: extract one toTreeNodeSnapshot(page) helper called by both the
  PAGE_CREATED event enrichment (page.repo) and the addTreeNode broadcast
  (ws-tree.service), so the optional temporaryExpiresAt can't drift between the
  two literals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:58:40 +03:00
claude code agent 227
12ff76fb89 fix(temporary-notes): live sidebar clock marker + stacked mobile create buttons
Issue 1 — the sidebar tree's temporary-note clock marker did not appear/
disappear until a page reload when a note's temporary state changed.

- Make/unmake permanent from the page header menu and the in-page banner went
  through syncTemporaryExpiresInCache(), which patched the page query cache but
  never touched treeDataAtom, so the sidebar node kept its stale
  temporaryExpiresAt. Patch the tree node there too (via jotai's default store),
  so the marker updates without a reload.
- Creating a note as temporary showed no marker until reload: the create flow's
  cache write (invalidateOnCreatePage) omitted temporaryExpiresAt, so the tree
  rebuild (buildTree -> mergeRootTrees) overwrote the optimistic/socket node's
  marker with undefined. Carry temporaryExpiresAt in that cached entry.
- Thread temporaryExpiresAt through the server addTreeNode broadcast (PAGE_CREATED
  snapshot -> TreeNodeSnapshot -> broadcastPageCreated) so OTHER clients watching
  the space also render the marker immediately, and harden handleCreate's
  idempotency guard to patch the deadline if the broadcast won the insert race.

Issue 2 — the home and space-overview "New note" / "New temporary note" buttons
sat side-by-side and the temporary label clipped on narrow mobile widths. Lay
them out full-width, stacked vertically, and tint the temporary button orange
(matching the clock marker + banner) while the regular one stays neutral gray.

Tests: extend tree-socket-reducers.test.ts (addTreeNode carries
temporaryExpiresAt). Verified live with Playwright: marker appears on create and
toggles both ways with no reload; mobile buttons are stacked, full-width,
unclipped, and differently colored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:29:19 +03:00
claude code agent 227
26ca19f89e agent-roles: concurrency-safe catalog import + unified source validator
Item 1 (concurrency-safe import): add a partial UNIQUE index on
(workspace_id, source->>'slug', source->>'language') WHERE source IS NOT NULL
AND deleted_at IS NULL, so two concurrent imports of the same bundle can no
longer create duplicate roles for one catalog slug+language. The in-memory
installedKeys snapshot cannot see a sibling request's writes; the index is the
backstop. importFromCatalog now catches the 23505 from THIS index (keyed off
the constraint name) and treats it as "already installed" -> skip, batch
continues. A 23505 from the name-uniqueness index keeps its existing friendly
per-role error behavior (distinguished by constraint name; an indeterminate
23505 falls back to that path, so no regression).

Item 2 (single source validator): strengthen parseSource into THE single form
validator for the source jsonb column -> returns a fully-valid RoleSource | null
(slug/language non-empty strings, version a number). The service's weaker
roleSource is removed and both layers share the RoleSource type (defined in the
db entity.types module both already import AiAgentRole from, so no import
cycle). normalizeRow / the read path now only ever yield a valid RoleSource or
null; a malformed stored source normalizes to null (tolerated by the service).

Tests: parseSource null for {} / {slug:123} / {slug:'a'} / empty-string keys /
string version, typed value for a full valid shape; service test that a
source-uniqueness 23505 is skipped (not errored) and the batch continues.
Verified the partial index rejects a duplicate source-not-null row but allows
two source-NULL rows, and the migration up/down run cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 23:40:25 +03:00
claude code agent 227
50e79275e1 Address review on agent-roles catalog: changelog, docs, BadGateway on body-read abort
- CHANGELOG: document the importable multilingual agent-roles catalog under
  [Unreleased] (browse/import/update, 4 new endpoints, source column, the new
  AI_AGENT_ROLES_CATALOG_URL env var) (#222).
- Fix importFromCatalog docstring: a role is skipped only on source.slug AND
  source.language; another language of the same slug still imports.
- Provider: map a timeout/abort (or any failure) during the response-BODY read
  to a logged BadGatewayException, so a slow/dripping source yields a 502, not a
  generic 500. Existing too-large BadGateway cases are rethrown as-is.
- Service: inject a Nest Logger and log the root cause (with workspaceId/
  bundleId/slug) on a non-23505 insert error during import.
- Modal: hoist the duplicated i18n base-subtag into a single baseLang const.
- Tests: AbortError body-read -> BadGateway; null-body text() fallback (under
  and over cap); invalid-JSON and malformed-index BadGateway; non-23505 import
  error -> generic message + logged root cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 23:15:45 +03:00
claude code agent 227
8be8279809 Address PR #222 review: migration order, provider logging, catalog tests
- Rename catalog-source migration 20260626T120000 -> T150000 so it sorts
  after develop's latest migration (T140000-page-temporary-notes); the old
  timestamp predated ai-chat-message-status/share-aliases and tripped
  Kysely's #ensureMigrationsInOrder, aborting server boot.
- Provider: inject a Nest Logger and log the real cause (incl. response
  status) in the parseJson / readLocal / fetchRemote catch blocks, and
  propagate a useful cause into the BadGatewayException message; add a
  shortError helper (robust to jest's realm-shifted Error-likes).
- Provider: replace the manual Uint8Array assembly with
  Buffer.concat(chunks).toString('utf8'); keep the streaming size cap.
- Controller spec: add admin-gate coverage for the 4 catalog routes
  (catalog/catalogBundle/import/updateFromCatalog) - non-admin Forbidden +
  service untouched, admin delegates with the right args.
- Service spec: add getCatalog/getCatalogBundle tests covering the
  localized() three-tier fallback, the sorted language union, the
  missing-bundle BadGateway, and the role-version default.
- Provider spec: add remote fetch-rejects and non-ok (503) error branches.
- Service: drop the dead Date.now() tail in freeName (now an explicit
  unreachable throw) and extract a shared isUniqueViolation() predicate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:36:26 +03:00
claude_code
19f84ca0e7 feat(ai-roles): add importable, multilingual agent roles catalog
Admins can browse a curated catalog of agent roles, import roles/bundles
into a workspace, and update an imported role when the catalog ships a
newer version.

Catalog: a set of JSON files (index.json manifest + bundles/<id>/<lang>.json)
served from a local folder (dev) or a remote http(s) base URL via
AI_AGENT_ROLES_CATALOG_URL. Seeded with the existing 7 RU roles (editorial +
research bundles) plus EN translations.

Server:
- migration: nullable jsonb `source` column on ai_agent_roles
  ({ slug, language, version }; null => manually created)
- catalog provider: remote fetch with timeout + streaming size cap, or local
  read; ^[a-z0-9-]+$ segment guard against path-traversal/SSRF
- admin endpoints: catalog, catalog/bundle, import, update-from-catalog
- import/update match by slug+language; update preserves `enabled`

Client:
- catalog modal with language selector and Import/Installed/Update states
- "Import from catalog" button + empty-state CTA in the roles settings panel
- en-US/ru-RU strings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:36:26 +03:00
claude_code
e9409e245b style(share): drop divider line from custom-address prefix
The right border on the address prefix read as a stray vertical line
between the domain and the slug. Remove it and rely on the subtle
prefix background alone to separate the two parts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:33:08 +03:00
claude_code
fa6a87e22d test(ai-chat): cover MessageList parent-side signature snapshot (#224)
PR #224 fixed an AI-chat streaming-render regression by moving the React.memo
content signature into the parent: MessageList now snapshots
messageSignature(message) per render and passes it to MessageItem as the
immutable `signature` prop. The existing memo tests only SIMULATED that
parent half by hardcoding `signature={messageSignature(message)}` in their
harness; the real MessageList was never exercised (chat-thread.test.tsx mocks
it out, and there was no message-list.test).

Add message-list.test.tsx that mounts the REAL MessageList (without mocking
MessageItem or messageSignature) and asserts that an in-place mutation of a
reused message object surfaces on re-render. This guards the parent-side
contract: re-caching the signature on message identity (stable across deltas
while parts mutate) would refreeze the row, and this test would fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:33:01 +03:00
claude_code
0fc9c4a998 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-26 22:09:22 +03:00
claude_code
40b8f7922a feat(client): quick-create regular and temporary notes from Home and Space screens
Add fast note-creation entry points alongside the existing space-sidebar
actions.

- Home: refactor new-note-button.tsx into a reusable inner CreateNoteButton
  (parametrized by `temporary`/label/icon, keeps the 0/1/many writable-space
  resolution and space-picker dropdown) and render two equal-width buttons via
  `Group grow` — a regular note and a temporary note (IconHourglass).
- Space overview: new SpaceCreateNoteButtons component with two buttons that
  create a regular/temporary note directly in the current space and open it,
  reusing useTreeMutation.handleCreate (optimistic sidebar-tree insert +
  navigation). Permission-gated to members who can manage pages; a local
  pending state shows a per-button spinner and disables both to prevent a
  double-create. Wired into space-home.tsx above the tabs.
- Reuse existing i18n keys (no new strings): "New note", "New temporary note",
  "Create in space".
- Docs: add a CHANGELOG [Unreleased] entry and a "Temporary notes" roadmap
  bullet to README.md and README.ru.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:09:09 +03:00
08c70cf550 Merge pull request 'fix(ai-chat): assistant turn renders nothing — memo signature defeated by AI-SDK in-place part mutation (#182 regression)' (#224) from fix/ai-chat-empty-render into develop
Reviewed-on: #224
2026-06-26 22:09:05 +03:00
claude code agent 227
ae6ed76d9a fix(ai-chat): assistant turn renders empty — memo froze on in-place part mutation
The floating AI chat rendered NOTHING for the assistant turn (user bubble +
"thinking" dots showed, but the streamed text and tool-call cards never
appeared) even though the agent ran server-side. The parts DID arrive in
`useChat.messages` — this was purely a render freeze.

Root cause: the MessageItem `React.memo` comparator (#182) decided whether to
re-render by recomputing `messageSignature(prev.message)` vs
`messageSignature(next.message)` inside `arePropsEqual` (plus a
`prev.message === next.message` fast path). But the AI SDK (ai@6 /
@ai-sdk/react@3) streams a turn by MUTATING the same `parts` in place and
handing back a message wrapper that SHARES those mutated parts. So inside the
comparator both `prev.message` and `next.message` already reflect the latest
content — the two signatures are ALWAYS equal — and the memo skipped every
post-mount render. The assistant row therefore froze at its initial empty
(null) render; reasoning-first providers (e.g. z.ai/GLM) start with a
non-visible reasoning part, so the whole answer + tool cards never showed.

Fix: snapshot the signature in the PARENT (MessageList) at render time and pass
it to MessageItem as an immutable `signature` string prop; `arePropsEqual` now
compares that prop. A captured string is immutable, so `prev.signature` holds
the previous render's content and `next.signature` the new content — they differ
as the turn streams in and the row re-renders. Drop the now-incorrect
`prev.message === next.message` fast path (same-ref-but-mutated must still
re-render). MarkdownPart's per-part memo is unaffected (it already keys on the
primitive `text`).

Verified end-to-end against a real OpenAI-compatible provider: the assistant
turn (reasoning + streamed text + tool-call card) now renders live and on
finish. Regression tests added (render + comparator) that fail before / pass
after.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:02:53 +03:00
claude_code
276ccc0783 refactor(ai): drop Generative AI flag, gate title generation on AI chat
Remove the separate, un-toggleable `settings.ai.generative` workspace flag
(and its write-side alias `generativeAi`) along with the dead "Ask AI"
generative editor menu, and re-gate the AI page-title generation on the
general AI chat flag (`settings.ai.chat`) — the same toggle that enables
the chat agent and the chat stream endpoint.

Why: the `generative` flag had no UI toggle (its switch was already removed,
leaving orphaned i18n strings), so the title-generation button was
unreachable on self-hosted. The "Ask AI" menu was dead — its atom was never
rendered. Consolidating onto the AI chat flag makes the title button follow
the one AI switch users actually have.

Changes:
- server: title-gen endpoint gate generative -> chat (ai-chat.controller.ts);
  remove generativeAi from update DTO and workspace service (update block,
  delete line, cloud default now { ai: { chat: true } }); fix repo comment;
  migrate generate-page-title spec assertions generative -> chat.
- client: title-gen gate -> settings.ai.chat (full-editor.tsx); remove the
  dead Ask AI button + showAiMenu wiring from bubble-menu; remove AskAiGroup
  usage/import and commented block from fixed-toolbar; delete ask-ai-group.tsx;
  remove showAiMenuAtom; drop generative/generativeAi from workspace types.
- i18n: remove 3 orphaned generative-AI keys from all 12 locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:35:30 +03:00
claude_code
406921ac6a fix(share): tighten and restyle custom-address prefix input
The "Custom address" slug field sized its leftSection with a
character-count heuristic (label.length * 7 + 12), which over-estimated
the real width of the small dimmed domain prefix and left an ugly empty
gap between "docs.../l/" and the input text.

- Measure the real prefix width via a ref + useLayoutEffect (scrollWidth)
  and feed it to leftSectionWidth so the slug sits flush against the
  prefix, regardless of host length or font metrics.
- Restyle the prefix as an attached addon: subtle background, a right
  divider border and input-matching left corner radii.
- Minor spacing tidy: description mb 4->6, action buttons mt xs->sm.

No behavior change: validation, availability probe, save/remove and the
reassign modal are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:05:23 +03:00
130 changed files with 8706 additions and 413 deletions

View File

@@ -132,6 +132,14 @@ MCP_DOCMOST_PASSWORD=
# NEVER set is_agent on a human or shared account — every action by that account
# (including normal human edits) would then be mis-attributed as AI.
# Agent-roles catalog source: an http(s):// base URL to the catalog's raw files
# (the server appends /index.json and /bundles/<id>/<lang>.json). This value is
# baked into the Docker image at build time per branch (see the Dockerfile ARG
# AI_AGENT_ROLES_CATALOG_URL and the CI build-args). Set it here only to point a
# local/non-Docker run at a catalog; if unset, the "import role from catalog"
# admin feature is unavailable. Local-filesystem sources are no longer supported.
# AI_AGENT_ROLES_CATALOG_URL=
# Per-embedding-call timeout in milliseconds for the RAG indexer.
# A slow/hung embeddings endpoint fails after this and the batch continues.
# AI_EMBEDDING_TIMEOUT_MS=120000

View File

@@ -52,6 +52,7 @@ jobs:
platforms: linux/amd64
build-args: |
APP_VERSION=${{ steps.version.outputs.value }}
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
push: true
tags: ${{ env.IMAGE }}:develop
cache-from: type=gha,scope=develop-amd64

View File

@@ -17,6 +17,7 @@ permissions:
env:
VERSION: ${{ inputs.version || github.ref_name }}
IMAGE: ghcr.io/vvzvlad/gitmost
AI_AGENT_ROLES_CATALOG_URL: https://raw.githubusercontent.com/vvzvlad/gitmost/main/agent-roles-catalog
jobs:
# Run the reusable test suite first so a failing test blocks the image build.
@@ -57,6 +58,7 @@ jobs:
platforms: ${{ matrix.platform }}
build-args: |
APP_VERSION=${{ env.VERSION }}
AI_AGENT_ROLES_CATALOG_URL=${{ env.AI_AGENT_ROLES_CATALOG_URL }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.suffix }}
cache-to: type=gha,scope=${{ matrix.suffix }},mode=max,ignore-error=true
@@ -85,6 +87,7 @@ jobs:
platforms: ${{ matrix.platform }}
build-args: |
APP_VERSION=${{ env.VERSION }}
AI_AGENT_ROLES_CATALOG_URL=${{ env.AI_AGENT_ROLES_CATALOG_URL }}
push: false
tags: |
${{ env.IMAGE }}:latest

View File

@@ -12,6 +12,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Quick-create regular and temporary notes from the Home and Space screens.**
The Home screen now shows a second action next to "New note" that creates a
*temporary* note (one that auto-moves to Trash after the workspace lifetime),
resolving the target space the same way the regular button does — created
directly when you can write to a single space, or via a space picker when
several. Each space overview screen gains two buttons — "New note" and "New
temporary note" — that create the page directly in that space and open it,
mirroring the existing space-sidebar actions and shown only to members who can
manage pages.
- **Interrupt the AI agent and send a queued message now.** A queued AI-chat
message gains a "send now" action that interrupts the streaming turn and
immediately sends that message, keeping the agent's partial output. The
@@ -19,6 +28,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
answer was cut off and builds on it instead of restarting; the rest of the
queue still flushes normally afterward. (#198)
- **Importable multilingual agent-roles catalog.** Admins can browse a curated
catalog of agent roles, grouped into bundles and offered in several languages,
and import the ones they want into the workspace (with skip-or-rename handling
for name collisions); the same role in a different language imports as a
separate install. An imported role remembers its catalog origin and offers a
one-click update when the catalog ships a newer revision. Backed by four new
admin endpoints — `POST /ai-chat/roles/catalog` (browse bundles),
`/catalog/bundle` (read one bundle's roles), `/import`, and
`/update-from-catalog` — and a new `source` column linking a role to its
catalog slug/language/version. The catalog source is configured via the
`AI_AGENT_ROLES_CATALOG_URL` env var — an `http(s)://` base URL to the
catalog's raw files; the image ships a per-branch default baked in CI, and it
can be overridden at runtime via the env var (see `.env.example`). (#222)
### Changed
- **Enabling a public share no longer auto-shares the whole sub-tree.** Turning
a page "Shared to web" now defaults to the page alone; descendant pages become
public only when you explicitly turn on the dedicated "Include sub-pages"
toggle. Previously the create call defaulted to including sub-pages, silently
exposing every child of a freshly shared page. (#216)
### Fixed
- **Internal links in exported Markdown no longer lose their visible text.** A
link whose target page name had no file extension (e.g. a bare title) was
collapsed to empty text during export, producing an unclickable, label-less
link; the page name is now preserved. (#204)
- **Deep pages no longer render a blank breadcrumb while the sidebar tree loads.**
The breadcrumb now falls back to the page's own ancestor chain (fetched
independently of the lazily-built sidebar tree) so a deep page resolves its
trail immediately; navigating away no longer leaves the previously-viewed
page's breadcrumb showing until the new one resolves. (#206, #218)
- **Pasted GitHub-style callouts (`> [!NOTE]` …) now convert to real callouts.**
GitHub admonition blocks pasted as Markdown are recognized and rendered as
callout blocks instead of plain block-quotes. (#192)
- **The editor stays read-only until collaboration has synced.** While a page is
connecting, the body is shown as a non-editable static view with a
"Connecting… (read-only)" banner, so edits typed before the document finishes
syncing can no longer be silently dropped. (#218)
- **A shared page now keeps EXACTLY ONE custom address (`/l/:alias`).** Editing a
page's vanity slug previously inserted a second `share_aliases` row instead of
renaming the existing one, leaving the old `/l/<old>` link live forever and
making the share modal's lookup nondeterministic. Slug edits and confirmed
reassigns now rename/retarget the single row, and a new partial unique index on
`(workspace_id, page_id)` enforces the invariant in the database. **Upgrade
note:** the accompanying migration `20260627T120000` IRREVERSIBLY deletes the
orphaned duplicate alias rows the old bug created (keeping the newest per
page), so any previously-live duplicate `/l/<old>` link begins returning the
generic 404 after upgrade — intended, but not undoable by `down()`. (#226,
#227)
- **Typing a custom address already used by another page no longer looks like a
dead end.** The share modal previously flagged such a name with a red "This
address is already in use" error, hiding the fact that saving offers to MOVE
the address to the current page. The field now shows an informational hint —
"This address is in use. Saving will move it to this page." — and keeps Save
enabled, so the existing reassign-confirm flow (`409 ALIAS_REASSIGN_REQUIRED`
"Move custom address?") is discoverable instead of reading as terminal. (#227)
### Security
- **The anonymous public-share page payload is trimmed to an explicit allowlist.**
The `/shares/page-info` route (the only unauthenticated path serializing a
page + its share) now returns only the fields the public renderer needs;
internal metadata — creator/last-updater/contributor ids, space/workspace ids,
AI/source bookkeeping, lock/template flags, parent/position and raw timestamps
— is no longer exposed to anonymous viewers. (#218)
- **A forged or mismatched share id can no longer render a page off its slug
alone.** When the public URL carries a share id/key, the page must be reachable
through that exact share (its own share or an ancestor `includeSubPages`
share); any other value now returns the generic "not found" instead of
serving the page. (#218)
## [0.94.0] - 2026-06-26
This release makes AI chat durable and fast: assistant turns are persisted to

View File

@@ -23,6 +23,11 @@ RUN apt-get update \
WORKDIR /app
# Agent-roles catalog base URL: per-branch default set at build time (CI);
# overridable at runtime via the AI_AGENT_ROLES_CATALOG_URL env var.
ARG AI_AGENT_ROLES_CATALOG_URL=""
ENV AI_AGENT_ROLES_CATALOG_URL=$AI_AGENT_ROLES_CATALOG_URL
# Copy apps
COPY --from=builder /app/apps/server/dist /app/apps/server/dist
COPY --from=builder /app/apps/client/dist /app/apps/client/dist

View File

@@ -104,6 +104,7 @@ community feature, with no enterprise license. Open it from the page header; the
-**Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
-**Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
-**Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
-**Temporary notes** — mark a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview, or the space sidebar, with a "Make permanent" rescue banner on the open note.
### In progress

View File

@@ -105,6 +105,7 @@ real-time-коллаборации Docmost, поэтому запись нико
-**Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
-**AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
-**Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
-**Временные заметки** — пометьте заметку временной, и она автоматически уедет в корзину по истечении настраиваемого срока жизни воркспейса (по умолчанию 24 ч), если её предварительно не сделать постоянной; создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства, а на открытой заметке есть баннер «Сделать постоянной».
### В процессе

View File

@@ -0,0 +1,193 @@
# Agent roles catalog
This directory is **data, not application code**. It holds the content of an
"agent roles catalog": reusable agent role definitions (system prompts plus a
little metadata), grouped into bundles and translated into one or more
languages. A separate server reads these files and serves them; nothing here is
executable application logic except the validation script.
## File layout
```
agent-roles-catalog/
index.json # the catalog manifest: bundles, languages, role versions
bundles/
<bundle-id>/
<lang>.json # one file per declared language (e.g. ru.json, en.json)
scripts/
check.mjs # validates the catalog (no dependencies)
content-hashes.json # check artifact: per-role content-hash lock (NOT served)
package.json # defines the `check` script
README.md
```
Currently shipped bundles:
- `editorial` — the editorial suite (structural-editor, line-editor,
fact-checker, proofreader, narrator), languages `ru`, `en`.
- `research` — a single `researcher` role, languages `ru`, `en`.
## How it's served
The server does not bundle this data; it reads it at request time from a single
configured location, the `AI_AGENT_ROLES_CATALOG_URL` env var
(`EnvironmentService.getAiAgentRolesCatalogSource()`), an `http(s)://` base URL
to the catalog's raw files. The server fetches `<base>/index.json` for the
manifest and `<base>/bundles/<bundle-id>/<lang>.json` for each opened bundle
file (REMOTE only).
That base URL is provided as a per-branch default in the Docker image (set in
CI: a `develop` build points at the `develop` raw URL, a release build at the
`main` raw URL) and can be overridden at runtime via the
`AI_AGENT_ROLES_CATALOG_URL` env var. Local-filesystem sources are no longer
supported; if the value is unset the catalog is unavailable.
The fetched JSON is re-validated server-side (the catalog is treated as
untrusted input). See `.env.example` for the variable and the CHANGELOG for the
rollout.
## `index.json` schema
```jsonc
{
"schemaVersion": 1,
"bundles": [
{
"id": "editorial", // unique bundle id; matches bundles/<id>/
"name": { "ru": "...", "en": "..." }, // localized display name
"description": { "ru": "...", "en": "..." },
"languages": ["ru", "en"], // which <lang>.json files must exist
"roles": [
{ "slug": "structural-editor", "version": 1 }
// ...
]
}
]
}
```
`version` lives **here, in index.json**, per role. Bump it whenever a role's
content (instructions, name, description, etc.) changes, so consumers can detect
updates.
## Bundle (`<lang>.json`) schema
```jsonc
{
"schemaVersion": 1,
"language": "ru",
"roles": [
{
"slug": "structural-editor", // REQUIRED, unique across the whole catalog
"emoji": "🧱",
"name": "...", // REQUIRED, localized
"description": "...", // localized
"instructions": "...", // REQUIRED, the system prompt, localized
"autoStart": true, // whether the role starts working immediately
"launchMessage": "..." // first message sent on launch (or null)
}
]
}
```
Notes:
- `modelConfig` is intentionally absent; the server treats an absent
`modelConfig` as `null`.
- A role's `slug`, `emoji`, and `autoStart` are identical across all language
files of the same bundle. Only `name`, `description`, `instructions`, and
`launchMessage` are translated.
## Slug uniqueness
**Every `slug` must be UNIQUE ACROSS THE WHOLE CATALOG**, not just within a
bundle. A slug appears once per language file of its bundle (same slug in
`ru.json` and `en.json`), but no two different bundles may share a slug.
`scripts/check.mjs` enforces this.
## How to add things
### Add a role to an existing bundle
1. Add an entry to that bundle's `roles[]` in `index.json` with a new unique
`slug` and `version: 1`.
2. Add a role object with the same `slug` to **every** `<lang>.json` of the
bundle, translating `name`, `description`, `instructions`, and
`launchMessage`.
3. Run the check (see below).
### Add a bundle
1. Add a bundle object to `index.json` (`id`, `name`, `description`,
`languages`, `roles`).
2. Create `bundles/<id>/<lang>.json` for each declared language, with one role
object per `roles[]` entry.
3. Run the check.
### Add a language to a bundle
1. Add the language code to that bundle's `languages[]` in `index.json`.
2. Create `bundles/<id>/<lang>.json` containing every role of the bundle,
translated.
3. Run the check.
### Change a role's content
Edit the role in the relevant `<lang>.json` file(s) and **bump that role's
`version`** in `index.json`. Then run `node scripts/check.mjs --update-hashes`
to refresh the content-hash lock (`scripts/content-hashes.json`). `check.mjs`
now **fails if a role's content changed but its `version` was not bumped**, so
this step is mandatory — the lock can only be refreshed after the bump.
## Validating
From this directory:
```sh
node scripts/check.mjs # or: npm run check
```
It fails (exit code 1) if any slug is duplicated across the catalog, if a
bundle's index `roles[]` don't match the slugs present in each language file, if
a declared language file is missing, or if any role is missing a required field
(`slug`, `name`, `instructions`). It prints `OK` on success.
### Content-hash guard
`check.mjs` also guards against changing a role's content without bumping its
`version`. It keeps a lockfile, `scripts/content-hashes.json`, mapping each role
`slug` to `{ version, hash }`, where `hash` is a SHA-256 over the role's
content fields (`emoji`, `autoStart`, `name`, `description`, `instructions`,
`launchMessage`) across all of its language files, in a deterministic canonical
form. This lockfile is a **check artifact only** — the server fetches only
`index.json` and the bundle `<lang>.json` files, never this file, so it has no
effect on the served catalog or its schema.
On a normal run, for every role the check recomputes the hash and compares it
against the lock:
- content unchanged and versions agree → OK;
- content changed but `version` not bumped above the lock → **error** asking you
to bump and refresh;
- content changed and `version` bumped → **error** asking you to record it by
refreshing the lock;
- role missing from the lock, or a lock entry for a role that no longer exists →
**error** asking you to refresh.
Refresh the lock with:
```sh
node scripts/check.mjs --update-hashes # alias: --fix
```
This recomputes the lock from the current catalog, prunes entries for removed
roles, and prints what changed — but it **refuses to write** (exit 1) if any
role's content changed while its `index.json` version was not bumped, so the
version bump is always enforced first. The check also requires every
`index.json` role to carry a finite numeric `version` (the server requires the
same).
Known, accepted limitation: a deliberate prune-then-readd of a slug (remove the
role and run `--update-hashes`, then re-add it with changed content at the same
version) is **not** caught, because a brand-new slug has no lock baseline to
enforce a bump against.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,31 @@
{
"schemaVersion": 1,
"bundles": [
{
"id": "editorial",
"name": { "ru": "Редакторский набор", "en": "Editorial suite" },
"description": {
"ru": "Полный цикл редактуры статьи: структура, стиль, корректура, факты и нарратив.",
"en": "The full article-editing cycle: structure, style, copyediting, facts, and narrative."
},
"languages": ["ru", "en"],
"roles": [
{ "slug": "structural-editor", "version": 2 },
{ "slug": "line-editor", "version": 2 },
{ "slug": "fact-checker", "version": 2 },
{ "slug": "proofreader", "version": 3 },
{ "slug": "narrator", "version": 1 }
]
},
{
"id": "research",
"name": { "ru": "Исследование", "en": "Research" },
"description": {
"ru": "Глубокое исследование темы с подготовкой отчёта.",
"en": "Deep research on a topic with a prepared report."
},
"languages": ["ru", "en"],
"roles": [ { "slug": "researcher", "version": 1 } ]
}
]
}

View File

@@ -0,0 +1,8 @@
{
"name": "agent-roles-catalog",
"private": true,
"type": "module",
"scripts": {
"check": "node scripts/check.mjs"
}
}

View File

@@ -0,0 +1,353 @@
#!/usr/bin/env node
// Validates the agent roles catalog.
// Fails (exit 1) on: duplicate slugs across the whole catalog, mismatches
// between a bundle's index roles[] and the slugs present in each language
// file, a missing declared language file, or a role missing required fields.
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const catalogDir = join(__dirname, "..");
// `--update-hashes` (alias `--fix`) recomputes the content-hash lockfile from
// the current catalog instead of just validating against it.
const updateHashes =
process.argv.includes("--update-hashes") || process.argv.includes("--fix");
// The content-hash lockfile lives under scripts/ and is a CHECK ARTIFACT only:
// the server never fetches it, so it has zero impact on the served schema.
const lockPath = join(__dirname, "content-hashes.json");
const errors = [];
function readJson(path) {
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch (err) {
errors.push(`Cannot read/parse ${path}: ${err.message}`);
return null;
}
}
const indexPath = join(catalogDir, "index.json");
if (!existsSync(indexPath)) {
console.error(`Missing index.json at ${indexPath}`);
process.exit(1);
}
const index = readJson(indexPath);
if (!index) {
for (const e of errors) console.error(e);
process.exit(1);
}
const bundles = Array.isArray(index.bundles) ? index.bundles : [];
if (bundles.length === 0) {
errors.push("index.json has no bundles[]");
}
// Track every slug seen across the whole catalog to detect duplicates.
const slugSeen = new Map(); // slug -> "bundleId/lang"
for (const bundle of bundles) {
const bundleId = bundle.id;
if (!bundleId) {
errors.push("A bundle in index.json is missing an id");
continue;
}
const indexSlugs = (bundle.roles || []).map((r) => r.slug);
// Duplicate slugs inside the bundle index roles[].
const indexSlugSet = new Set(indexSlugs);
if (indexSlugSet.size !== indexSlugs.length) {
errors.push(`Bundle "${bundleId}" index.json roles[] contains duplicate slugs`);
}
// Each index role must carry a finite numeric "version". The server requires
// this (see ai-agent-roles-catalog.provider.ts), and the content-hash guard
// below relies on it for the bump comparison, so enforce it here too.
for (const r of bundle.roles || []) {
if (typeof r.version !== "number" || !Number.isFinite(r.version)) {
errors.push(
`Bundle "${bundleId}" index.json role "${r.slug}" is missing a numeric "version"`
);
}
}
const languages = Array.isArray(bundle.languages) ? bundle.languages : [];
if (languages.length === 0) {
errors.push(`Bundle "${bundleId}" declares no languages`);
}
for (const lang of languages) {
const langPath = join(catalogDir, "bundles", bundleId, `${lang}.json`);
if (!existsSync(langPath)) {
errors.push(`Bundle "${bundleId}" declares language "${lang}" but ${langPath} is missing`);
continue;
}
const langFile = readJson(langPath);
if (!langFile) continue;
const roles = Array.isArray(langFile.roles) ? langFile.roles : [];
const fileSlugs = roles.map((r) => r && r.slug);
// (d) Required fields per role.
for (const role of roles) {
for (const field of ["slug", "name", "instructions"]) {
if (role == null || role[field] == null || role[field] === "") {
errors.push(
`Bundle "${bundleId}/${lang}" has a role missing required field "${field}" (slug=${role && role.slug})`
);
}
}
}
// (b) index roles[] must match the slugs present in each language file.
const fileSlugSet = new Set(fileSlugs);
const missingInFile = indexSlugs.filter((s) => !fileSlugSet.has(s));
const extraInFile = fileSlugs.filter((s) => !indexSlugSet.has(s));
if (missingInFile.length > 0) {
errors.push(
`Bundle "${bundleId}/${lang}" is missing roles declared in index.json: ${missingInFile.join(", ")}`
);
}
if (extraInFile.length > 0) {
errors.push(
`Bundle "${bundleId}/${lang}" has roles not declared in index.json: ${extraInFile.join(", ")}`
);
}
// (a) Duplicate slugs across the whole catalog.
for (const slug of fileSlugs) {
if (!slug) continue;
const where = `${bundleId}/${lang}`;
// Only flag duplicates across DIFFERENT bundles or files; the same slug
// is expected to appear once per language file of the same bundle.
if (slugSeen.has(slug)) {
const prev = slugSeen.get(slug);
const prevBundle = prev.split("/")[0];
if (prevBundle !== bundleId) {
errors.push(
`Slug "${slug}" is duplicated across the catalog: ${prev} and ${where}`
);
}
} else {
slugSeen.set(slug, where);
}
}
}
}
// ---------------------------------------------------------------------------
// Content-hash guard: detect "content changed without a version bump".
//
// check.mjs cannot use git history, so we maintain a lockfile
// (scripts/content-hashes.json) mapping each role slug to its recorded
// { version, hash }. On every run we recompute each role's content hash and
// compare it against the lock; a content change is only allowed once the role's
// version in index.json has been bumped and the lock refreshed.
//
// Known, accepted limitation: a deliberate prune-then-readd of a slug (remove
// the role and run --update-hashes, then re-add it with changed content at the
// same version) is NOT caught, because a brand-new slug has no lock baseline to
// enforce a bump against. We document this rather than building tombstones.
// ---------------------------------------------------------------------------
// Content fields hashed for each role, in a fixed canonical order. `slug` is
// identity (not content) and `version` lives in index.json, so neither is here.
// `modelConfig` (an OPTIONAL role field the server also serves) is intentionally
// EXCLUDED: no shipped role uses it today, and being an object it would need a
// deterministic deep canonicalization (recursive key sort) before hashing —
// otherwise JSON.stringify key-order would make the hash non-deterministic. If a
// role ever gains a `modelConfig`, add it here WITH such canonicalization so a
// change to it is still caught by the bump guard.
const CONTENT_FIELDS = [
"emoji",
"autoStart",
"name",
"description",
"instructions",
"launchMessage",
];
// Build a map of slug -> { version, langRoles: { lang: roleObject } } from the
// current catalog so we can compute hashes and read index versions.
function collectCatalogRoles() {
const out = new Map(); // slug -> { version, langRoles: Map<lang, role> }
for (const bundle of bundles) {
const bundleId = bundle.id;
if (!bundleId) continue;
const languages = Array.isArray(bundle.languages) ? bundle.languages : [];
for (const r of bundle.roles || []) {
if (!r || !r.slug) continue;
if (!out.has(r.slug)) {
out.set(r.slug, { version: r.version, langRoles: new Map() });
} else {
// Same slug declared twice in index.json roles[]; already flagged above.
out.get(r.slug).version = r.version;
}
}
for (const lang of languages) {
const langPath = join(catalogDir, "bundles", bundleId, `${lang}.json`);
if (!existsSync(langPath)) continue;
const langFile = readJson(langPath);
if (!langFile) continue;
const roles = Array.isArray(langFile.roles) ? langFile.roles : [];
for (const role of roles) {
if (!role || !role.slug) continue;
const entry = out.get(role.slug);
if (!entry) continue; // role not declared in index.json; flagged above.
entry.langRoles.set(lang, role);
}
}
}
return out;
}
// Deterministic content hash for a role: languages sorted ascending, each
// language's content fields taken in CONTENT_FIELDS order (null when absent).
function contentHash(langRoles) {
const langs = [...langRoles.keys()].sort();
const canonical = langs.map((lang) => {
const role = langRoles.get(lang);
const fields = {};
for (const field of CONTENT_FIELDS) {
fields[field] = role && role[field] != null ? role[field] : null;
}
return [lang, fields];
});
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
}
// Compute current { version, hash } for every catalog role.
const catalogRoles = collectCatalogRoles();
const current = new Map(); // slug -> { version, hash }
for (const [slug, entry] of catalogRoles) {
current.set(slug, {
version: entry.version,
hash: contentHash(entry.langRoles),
});
}
// Load the existing lock (may be absent on first run).
let lock = {};
if (existsSync(lockPath)) {
const parsed = readJson(lockPath);
if (parsed && typeof parsed === "object") lock = parsed;
}
if (updateHashes) {
// Refresh the lock from the current catalog, but refuse to write if any role's
// content changed without its version being bumped above the existing lock.
const blockers = [];
for (const [slug, cur] of current) {
const prev = lock[slug];
if (!prev) continue; // new role; nothing to enforce a bump against.
if (cur.hash === prev.hash) continue; // content unchanged.
// Defense-in-depth: a non-numeric version must never pass the bump check via
// `undefined <= N` (which is false). The standard checks already flag a
// missing numeric version, but guard here too before comparing.
if (typeof cur.version !== "number" || !Number.isFinite(cur.version)) {
blockers.push(
`role "${slug}" content changed but its index.json "version" is missing or not numeric; set a numeric "version" before refreshing the lock`
);
} else if (cur.version <= prev.version) {
blockers.push(
`role "${slug}" content changed but its version was not bumped (still ${prev.version}); bump "version" in index.json before refreshing the lock`
);
}
}
// Still honor the standard checks before allowing a write.
if (errors.length > 0) {
console.error("Catalog check FAILED:");
for (const e of errors) console.error(` - ${e}`);
process.exit(1);
}
if (blockers.length > 0) {
console.error("Refusing to update content-hash lock:");
for (const b of blockers) console.error(` - ${b}`);
process.exit(1);
}
// Compute the change summary relative to the old lock, pruning removed slugs.
const newLock = {};
const added = [];
const changed = [];
const removed = [];
for (const [slug, cur] of [...current].sort((a, b) => a[0].localeCompare(b[0]))) {
newLock[slug] = { version: cur.version, hash: cur.hash };
const prev = lock[slug];
if (!prev) added.push(slug);
else if (prev.hash !== cur.hash || prev.version !== cur.version) changed.push(slug);
}
for (const slug of Object.keys(lock)) {
if (!current.has(slug)) removed.push(slug);
}
writeFileSync(lockPath, JSON.stringify(newLock, null, 2) + "\n");
console.log(`Wrote ${lockPath}`);
if (added.length) console.log(` added: ${added.join(", ")}`);
if (changed.length) console.log(` updated: ${changed.join(", ")}`);
if (removed.length) console.log(` pruned: ${removed.join(", ")}`);
if (!added.length && !changed.length && !removed.length) {
console.log(" (no changes; lock already up to date)");
}
console.log("OK");
process.exit(0);
}
// Normal run: validate current content against the lock.
for (const [slug, cur] of current) {
const prev = lock[slug];
if (!prev) {
errors.push(
`role "${slug}" is not recorded in the content-hash lock; run: node scripts/check.mjs --update-hashes`
);
continue;
}
if (cur.hash === prev.hash) {
// Content unchanged; the lock version must still agree with index.json.
if (cur.version !== prev.version) {
errors.push(
`role "${slug}" content is unchanged but its index.json version (${cur.version}) differs from the lock (${prev.version}); run: node scripts/check.mjs --update-hashes`
);
}
continue;
}
// Content changed.
// Defense-in-depth: treat a non-numeric version as an error before the `<=`
// comparison, so a missing version can never silently pass the bump check
// (and we avoid a misleading "version bumped to undefined" message).
if (typeof cur.version !== "number" || !Number.isFinite(cur.version)) {
errors.push(
`role "${slug}" content changed but its index.json "version" is missing or not numeric; set a numeric "version", then run: node scripts/check.mjs --update-hashes`
);
} else if (cur.version <= prev.version) {
errors.push(
`role "${slug}" content changed but its version was not bumped (still ${prev.version}); bump "version" in index.json, then run: node scripts/check.mjs --update-hashes`
);
} else {
errors.push(
`role "${slug}" content changed and version bumped to ${cur.version}; record it by running: node scripts/check.mjs --update-hashes`
);
}
}
// Lock entries for slugs that no longer exist in the catalog.
for (const slug of Object.keys(lock)) {
if (!current.has(slug)) {
errors.push(
`content-hash lock has entry for unknown role "${slug}" (no longer in the catalog); run: node scripts/check.mjs --update-hashes`
);
}
}
if (errors.length > 0) {
console.error("Catalog check FAILED:");
for (const e of errors) console.error(` - ${e}`);
process.exit(1);
}
console.log("OK");

View File

@@ -0,0 +1,26 @@
{
"fact-checker": {
"version": 2,
"hash": "d7ad1dae07d6f4321e7d40c5b36259dbf930264d748834809c4fb77294bf72e3"
},
"line-editor": {
"version": 2,
"hash": "cca324110dc6f96d2a8a239a2fb95b0ba09fad5806c9b6090a3c210ea7883ceb"
},
"narrator": {
"version": 1,
"hash": "36b38785fea6ae1c70bf6fb6b29ae5278bb86e389e61f7b9736675a589fa434c"
},
"proofreader": {
"version": 3,
"hash": "a36047c5cab837b2a727f63d4ddafc269b1fc44b90b365e770ecdb8f77e13952"
},
"researcher": {
"version": 1,
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
},
"structural-editor": {
"version": 2,
"hash": "83093baa7262aef8193871a1afcf2b43b11a56fe2d00cade41355cf66d972b74"
}
}

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "KI-unterstützte Suche (KI-Antworten)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Die KI-Suche verwendet Vektor-Einbettungen, um semantische Suchfunktionen in Ihrem Arbeitsbereich bereitzustellen.",
"Toggle AI search": "KI-Suche umschalten",
"Generative AI (Ask AI)": "Generative KI (KI fragen)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Aktivieren Sie die KI-unterstützte Inhaltserstellung im Editor. Ermöglicht Benutzern das Erzeugen, Verbessern, Übersetzen und Transformieren von Text.",
"Toggle generative AI": "Generative KI umschalten",
"Upgrade your plan": "Upgrade Ihres Plans",
"Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz",
"Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.",

View File

@@ -687,9 +687,6 @@
"AI-powered search (AI Answers)": "AI-powered search (AI Answers)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
"Toggle AI search": "Toggle AI search",
"Generative AI (Ask AI)": "Generative AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
"Toggle generative AI": "Toggle generative AI",
"Upgrade your plan": "Upgrade your plan",
"Available with a paid license": "Available with a paid license",
"Upgrade your license tier.": "Upgrade your license tier.",
@@ -1336,6 +1333,7 @@
"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",
"This address is in use. Saving will move it to this page.": "This address is in use. Saving will move it to this page.",
"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?",
@@ -1349,5 +1347,23 @@
"Could not generate a title": "Could not generate a title",
"AI title generation is disabled": "AI title generation is disabled",
"AI is not configured": "AI is not configured",
"Too many requests, please try again later": "Too many requests, please try again later"
"Too many requests, please try again later": "Too many requests, please try again later",
"Import from catalog": "Import from catalog",
"Browse the catalog": "Browse the catalog",
"Role catalog": "Role catalog",
"On name conflict": "On name conflict",
"Skip": "Skip",
"Import": "Import",
"Installed": "Installed",
"v{{from}} → v{{to}}": "v{{from}} → v{{to}}",
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}": "Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}",
"Failed to import {{count}} role(s)": "Failed to import {{count}} role(s)",
"The role catalog is unavailable": "The role catalog is unavailable",
"Please try again later.": "Please try again later.",
"No bundles available": "No bundles available",
"Already up to date": "Already up to date",
"Updated to the latest version": "Updated to the latest version",
"This role is no longer in the catalog": "This role is no longer in the catalog",
"This language is no longer available in the catalog": "This language is no longer available in the catalog",
"Connecting… (read-only)": "Connecting… (read-only)"
}

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Búsqueda impulsada por IA (Respuestas de IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La búsqueda de IA utiliza incrustaciones vectoriales para proporcionar capacidades de búsqueda semántica en todo el contenido de su espacio de trabajo.",
"Toggle AI search": "Alternar búsqueda de IA",
"Generative AI (Ask AI)": "IA generativa (Preguntar a la IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar la generación de contenido impulsada por IA en el editor. Permite a los usuarios generar, mejorar, traducir y transformar texto.",
"Toggle generative AI": "Activar IA generativa",
"Upgrade your plan": "Mejora tu plan",
"Available with a paid license": "Disponible con una licencia de pago",
"Upgrade your license tier.": "Mejora el nivel de tu licencia.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Recherche propulsée par IA (Réponses IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La recherche IA utilise des incorporations vectorielles pour fournir des capacités de recherche sémantique à travers le contenu de votre espace de travail.",
"Toggle AI search": "Basculer la recherche IA",
"Generative AI (Ask AI)": "IA générative (Demandez à l'IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Activer la génération de contenu assistée par IA dans l'éditeur. Permet aux utilisateurs de générer, améliorer, traduire et transformer du texte.",
"Toggle generative AI": "Activer/désactiver l'IA générative",
"Upgrade your plan": "Mettez à niveau votre forfait",
"Available with a paid license": "Disponible avec une licence payante",
"Upgrade your license tier.": "Mettez à niveau votre niveau de licence.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Ricerca con AI (Risposte AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La ricerca AI utilizza embeddings vettoriali per fornire capacità di ricerca semantica nel contenuto della tua area di lavoro.",
"Toggle AI search": "Attiva/disattiva ricerca AI",
"Generative AI (Ask AI)": "AI generativa (Chiedi AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Abilita la generazione di contenuti con AI nell'editor. Consente agli utenti di generare, migliorare, tradurre e trasformare il testo.",
"Toggle generative AI": "Attiva/Disattiva AI generativa",
"Upgrade your plan": "Aggiorna il tuo piano",
"Available with a paid license": "Disponibile con una licenza a pagamento",
"Upgrade your license tier.": "Aggiorna il livello della tua licenza.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "AI搭載検索 (AI回答)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
"Toggle AI search": "AI検索を切り替え",
"Generative AI (Ask AI)": "生成AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "エディターでAIを活用したコンテンツ生成を有効にします。ユーザーがテキストの生成、改善、翻訳、および変換を行うことができます。",
"Toggle generative AI": "生成AIを切り替える",
"Upgrade your plan": "プランをアップグレードする",
"Available with a paid license": "有料ライセンスで利用可能",
"Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "AI 구동 검색 (AI 답변)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
"Toggle AI search": "AI 검색 전환",
"Generative AI (Ask AI)": "생성 AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "편집기에서 AI 구동 콘텐츠 생성을 활성화합니다. 사용자가 텍스트를 생성, 개선, 번역 및 변환할 수 있습니다.",
"Toggle generative AI": "생성 AI 토글",
"Upgrade your plan": "요금제를 업그레이드하세요",
"Available with a paid license": "유료 라이선스에서만 사용 가능합니다",
"Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "AI-gestuurde zoekopdracht (AI Antwoorden)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI-zoekopdracht maakt gebruik van vectorembeddings om semantische zoekmogelijkheden te bieden in uw werkruimte-inhoud.",
"Toggle AI search": "Schakel AI-zoekopdracht in/uit",
"Generative AI (Ask AI)": "Generatieve AI (Vraag het AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Schakel AI-gestuurde inhoudsgeneratie in de editor in. Hiermee kunnen gebruikers tekst genereren, verbeteren, vertalen en transformeren.",
"Toggle generative AI": "Generatieve AI schakelen",
"Upgrade your plan": "Upgrade je abonnement",
"Available with a paid license": "Beschikbaar met een betaalde licentie",
"Upgrade your license tier.": "Upgrade je licentieniveau.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Pesquisa com IA (Respostas de IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "A pesquisa IA usa vetores de incorporação para fornecer capacidades de pesquisa semântica em todo o conteúdo do seu espaço de trabalho.",
"Toggle AI search": "Alternar pesquisa de IA",
"Generative AI (Ask AI)": "IA generativa (Perguntar à IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar geração de conteúdo com IA no editor. Permite aos usuários gerar, melhorar, traduzir e transformar texto.",
"Toggle generative AI": "Alternar IA generativa",
"Upgrade your plan": "Faça upgrade do seu plano",
"Available with a paid license": "Disponível com uma licença paga",
"Upgrade your license tier.": "Faça upgrade do seu nível de licença.",

View File

@@ -749,9 +749,6 @@
"AI-powered search (AI Answers)": "Поиск на базе ИИ (Ответы ИИ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.",
"Toggle AI search": "Переключить поиск ИИ",
"Generative AI (Ask AI)": "Генеративный ИИ (Спросить ИИ)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Включите создание контента на базе ИИ в редакторе. Позволяет пользователям генерировать, улучшать, переводить и преобразовывать текст.",
"Toggle generative AI": "Переключить генеративный ИИ",
"Upgrade your plan": "Обновите свой тарифный план",
"Available with a paid license": "Доступно с платной лицензией",
"Upgrade your license tier.": "Обновите уровень вашей лицензии.",
@@ -1193,6 +1190,7 @@
"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": "Этот адрес уже занят",
"This address is in use. Saving will move it to this page.": "Этот адрес уже используется. При сохранении он будет перемещён на эту страницу.",
"Move custom address?": "Переместить пользовательский адрес?",
"Move here": "Переместить сюда",
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
@@ -1206,5 +1204,24 @@
"Could not generate a title": "Не удалось придумать название",
"AI title generation is disabled": "Генерация названий через AI отключена",
"AI is not configured": "AI не настроен",
"Too many requests, please try again later": "Слишком много запросов, попробуйте позже"
"Too many requests, please try again later": "Слишком много запросов, попробуйте позже",
"Import from catalog": "Импорт из каталога",
"Browse the catalog": "Открыть каталог",
"Role catalog": "Каталог ролей",
"On name conflict": "При конфликте имён",
"Skip": "Пропустить",
"Import": "Импортировать",
"Installed": "Установлено",
"v{{from}} → v{{to}}": "v{{from}} → v{{to}}",
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}": "Импортировано: {{created}}, переименовано: {{renamed}}, пропущено: {{skipped}}",
"Failed to import {{count}} role(s)": "Не удалось импортировать ролей: {{count}}",
"The role catalog is unavailable": "Каталог ролей недоступен",
"Please try again later.": "Попробуйте позже.",
"No bundles available": "Наборы недоступны",
"No roles configured": "Роли не настроены",
"Already up to date": "Уже актуальна",
"Updated to the latest version": "Обновлено до последней версии",
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
"This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге",
"Connecting… (read-only)": "Подключение… (только чтение)"
}

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Пошук на базі ШІ (Відповіді ШІ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.",
"Toggle AI search": "Переключити пошук з ШІ",
"Generative AI (Ask AI)": "Генеративний ШІ (Запитати ШІ)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Увімкнути генерацію контенту за допомогою ШІ в редакторі. Дозволяє користувачам генерувати, покращувати, перекладати та трансформувати текст.",
"Toggle generative AI": "Переключити генеративний ШІ",
"Upgrade your plan": "Оновіть свій тарифний план",
"Available with a paid license": "Доступно за платною ліцензією",
"Upgrade your license tier.": "Оновіть рівень своєї ліцензії.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "AI驱动的搜索 (AI答案)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
"Toggle AI search": "切换AI搜索",
"Generative AI (Ask AI)": "生成型AI (询问AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "在编辑器中启用AI驱动的内容生成。允许用户生成、改进、翻译和转换文本。",
"Toggle generative AI": "切换生成型AI",
"Upgrade your plan": "升级您的方案",
"Available with a paid license": "需付费许可才可用",
"Upgrade your license tier.": "升级您的许可等级。",

View File

@@ -26,16 +26,20 @@ vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
});
import MessageItem from "./message-item";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
const msg = (parts: UIMessage["parts"]): UIMessage =>
({ id: "m1", role: "assistant", parts }) as UIMessage;
// Mirror MessageList: snapshot the signature at (parent) render time and pass it
// as the memo key. The signature must NOT be recomputed inside the memo from the
// live (mutable) message — see message-item.tsx.
const renderRow = (message: UIMessage) =>
render(
<MantineProvider>
<MessageItem message={message} />
<MessageItem message={message} signature={messageSignature(message)} />
</MantineProvider>,
);
@@ -67,7 +71,7 @@ describe("MessageItem markdown memoization", () => {
]);
rerender(
<MantineProvider>
<MessageItem message={next} />
<MessageItem message={next} signature={messageSignature(next)} />
</MantineProvider>,
);
@@ -78,4 +82,35 @@ describe("MessageItem markdown memoization", () => {
expect(callsFor("beta")).toBe(1);
expect(callsFor("gamm")).toBe(1);
});
// REGRESSION (empty-render bug): the AI SDK streams a turn by MUTATING the same
// `parts` IN PLACE and reusing the message object. A row that mounted empty
// (reasoning-first providers render nothing at first) must still stream its text
// in once the parent hands down a fresh signature snapshot. Before the fix the
// memo recomputed the signature from the (mutated) message — identical on both
// sides — and froze the row at its empty render, so the answer never appeared.
it("streams text in after the row mounted empty and parts mutated in place", () => {
renderChatMarkdownSpy.mockClear();
// Reuse ONE message object across renders (as the SDK does).
const message = msg([{ type: "text", text: "" }]);
const { rerender, queryByText } = render(
<MantineProvider>
<MessageItem message={message} signature={messageSignature(message)} />
</MantineProvider>,
);
// Empty text part: nothing visible rendered yet.
expect(queryByText("streamed answer")).toBeNull();
// SDK delta: mutate the SAME part in place, then re-render with a NEW snapshot.
(message.parts[0] as { text: string }).text = "streamed answer";
rerender(
<MantineProvider>
<MessageItem message={message} signature={messageSignature(message)} />
</MantineProvider>,
);
// The grown text now renders (the memo did NOT freeze the empty mount).
expect(callsFor("streamed answer")).toBe(1);
expect(queryByText("streamed answer")).not.toBeNull();
});
});

View File

@@ -10,21 +10,28 @@ vi.mock("react-i18next", () => ({
}));
import { arePropsEqual } from "./message-item";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
/**
* Tests for `arePropsEqual`, the `React.memo` comparator for MessageItem. It must
* return false on any visible prop/content change (so the row re-renders) and
* true when nothing visible changed (so a finalized row is skipped). A FIXED
* message id is used so a content-identical clone yields an equal signature.
* true when nothing visible changed (so a finalized row is skipped). The memo key
* is the `signature` PROP — an immutable snapshot the PARENT (MessageList) takes
* per render via `messageSignature(message)`. A FIXED message id is used so a
* content-identical clone yields an equal signature.
*/
const msg = (parts: UIMessage["parts"]): UIMessage =>
({ id: "m1", role: "assistant", parts }) as UIMessage;
// Build the props the parent would pass, INCLUDING the snapshot signature it
// computes during its own render (the load-bearing part — see message-item.tsx:
// the signature must never be recomputed inside arePropsEqual).
const props = (
message: UIMessage,
over: Record<string, unknown> = {},
) => ({
message,
signature: messageSignature(message),
showCitations: true,
neutralizeInternalLinks: false,
assistantName: "AI",
@@ -53,7 +60,7 @@ describe("arePropsEqual", () => {
).toBe(false);
});
it("returns true on the identity fast path (same message object, equal props)", () => {
it("returns true for equal snapshot + equal props (finalized row skipped)", () => {
const m = msg([{ type: "text", text: "answer" }]);
expect(arePropsEqual(props(m), props(m))).toBe(true);
});
@@ -70,4 +77,36 @@ describe("arePropsEqual", () => {
const b = msg([{ type: "text", text: "answer grown" }]);
expect(arePropsEqual(props(a), props(b))).toBe(false);
});
// REGRESSION (empty-render bug): the AI SDK streams deltas by mutating the SAME
// `parts` in place and handing back a message wrapper that SHARES them. So the
// PREVIOUS and NEXT props can carry the SAME (mutated) message object, and
// recomputing `messageSignature(message)` inside the comparator would read
// identical (latest) content on BOTH sides → always "equal" → the memo skips
// every streamed update and the assistant row freezes at its initial empty
// render. The comparator MUST instead trust the immutable `signature` SNAPSHOT
// the parent captured at each render. This fails against the old implementation
// (a `prev.message === next.message` fast path + a signature recomputed from the
// live objects).
it("re-renders when parts were mutated in place but the snapshot changed", () => {
const message = msg([{ type: "text", text: "" }]); // empty (renders null)
const prevSig = messageSignature(message); // snapshot BEFORE the delta
// SDK streams a delta by mutating the shared part IN PLACE:
(message.parts[0] as { text: string }).text = "hello world";
const nextSig = messageSignature(message); // snapshot AFTER the delta
expect(prevSig).not.toBe(nextSig);
// Same object reference on both sides (the SDK reuses it), differing snapshots.
const base = {
message,
showCitations: true,
neutralizeInternalLinks: false,
assistantName: "AI",
};
expect(
arePropsEqual(
{ ...base, signature: prevSig },
{ ...base, signature: nextSig },
),
).toBe(false);
});
});

View File

@@ -11,12 +11,30 @@ import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/mess
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageItemProps {
message: UIMessage;
/**
* Immutable content signature for `message`, computed by the PARENT
* (MessageList) during its render via `messageSignature(message)`. This is the
* memo key (see `arePropsEqual`): it MUST be a snapshot captured at render time,
* NOT recomputed from `message` inside `arePropsEqual`.
*
* WHY (load-bearing): the AI SDK streams deltas by mutating the SAME `parts`
* array/objects in place and handing back a message wrapper that SHARES those
* mutated parts. So inside `arePropsEqual`, `prev.message` and `next.message`
* both reflect the CURRENT (latest) parts — `messageSignature(prev.message) ===
* messageSignature(next.message)` is therefore ALWAYS true, the memo skips every
* post-mount render, and the assistant row freezes at its initial empty (null)
* render — i.e. the streamed answer + tool cards never appear (reasoning-first
* providers start empty, so NOTHING shows). Snapshotting the signature into this
* immutable string prop in the parent fixes that: `prev.signature` holds the
* value from the previous render (old content) and `next.signature` the new
* content, so they differ as the turn streams in and the row re-renders.
*/
signature: string;
/**
* Forwarded to ToolCallCard: whether tool cards render page citation links.
* Defaults to true (internal chat). The public share passes false.
@@ -88,6 +106,8 @@ function MessageItem({
neutralizeInternalLinks = false,
assistantName,
}: MessageItemProps) {
// `signature` is intentionally not read in the body — it exists solely as the
// memo key (see arePropsEqual). The render reads `message` directly.
const { t } = useTranslation();
const isUser = message.role === "user";
@@ -203,24 +223,30 @@ function MessageItem({
}
/** Skip re-rendering a message whose visible content is unchanged. The streaming
* TAIL message gets a fresh object whose signature changes each delta, so it
* still re-renders and streams in; every FINALIZED message is skipped, turning a
* per-token whole-transcript re-render into a tail-only one. */
* TAIL message gets a fresh `signature` snapshot each delta (computed by the
* parent), so it still re-renders and streams in; every FINALIZED message keeps
* the same signature and is skipped, turning a per-token whole-transcript
* re-render into a tail-only one.
*
* CRITICAL: compare the `signature` PROP (an immutable snapshot the parent took
* at its own render), NEVER `messageSignature(prev.message)` vs
* `messageSignature(next.message)`. The AI SDK mutates the shared `parts` in
* place, so both `prev.message` and `next.message` reflect the latest content
* here — recomputing the signature from them yields equal strings every time and
* freezes the row at its initial empty render (the bug this guards against). See
* the `signature` prop doc. Likewise there is NO `prev.message === next.message`
* fast path: same-reference-but-mutated must still re-render when the snapshot
* signature changed. */
export function arePropsEqual(
prev: MessageItemProps,
next: MessageItemProps,
): boolean {
if (
prev.showCitations !== next.showCitations ||
prev.neutralizeInternalLinks !== next.neutralizeInternalLinks ||
prev.assistantName !== next.assistantName
) {
return false;
}
// Fast path: identical message object (finalized rows keep their identity
// across deltas) — skip without building signatures.
if (prev.message === next.message) return true;
return messageSignature(prev.message) === messageSignature(next.message);
return (
prev.signature === next.signature &&
prev.showCitations === next.showCitations &&
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
prev.assistantName === next.assistantName
);
}
export default memo(MessageItem, arePropsEqual);

View File

@@ -0,0 +1,119 @@
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import type { UIMessage } from "@ai-sdk/react";
// Stub react-i18next (MessageList and TypingIndicator read `useTranslation`).
// Mirrors the t-mock pattern used by the other component tests in this folder
// (reasoning-block.test.tsx, message-item-memo.test.tsx).
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// Spy on `renderChatMarkdown` exactly as message-item-memo.test.tsx does: keep
// every OTHER named export of markdown.ts intact via `importActual`, and override
// only `renderChatMarkdown` with a `vi.fn()` that returns simple HTML. This makes
// assertions synchronous (no async marked + DOMPurify pass) and lets us count
// parses by argument. `vi.hoisted` so the spy exists when the hoisted `vi.mock`
// factory runs.
const { renderChatMarkdownSpy } = vi.hoisted(() => ({
renderChatMarkdownSpy: vi.fn((text: string) => `<p>${text}</p>`),
}));
vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
const actual = await vi.importActual<
typeof import("@/features/ai-chat/utils/markdown.ts")
>("@/features/ai-chat/utils/markdown.ts");
return { ...actual, renderChatMarkdown: renderChatMarkdownSpy };
});
// IMPORTANT: do NOT mock MessageItem and do NOT mock messageSignature — exercising
// the REAL MessageList -> real MessageItem -> real messageSignature wiring is the
// whole point of this file (it closes the parent-side coverage gap left by the
// memo tests, which simulate the parent by hardcoding `signature={...}` in their
// harness). Use the relative import for the component under test, mirroring how
// message-list.tsx itself imports `MessageItem from "./message-item"`.
import MessageList from "./message-list";
// matchMedia / localStorage / sessionStorage (read by MantineProvider and app
// code) are stubbed globally in vitest.setup.ts — do NOT re-stub those here.
//
// MessageList renders Mantine's ScrollArea, which constructs a `ResizeObserver`.
// jsdom does not implement it, so install a minimal no-op stub BEFORE rendering.
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
// One assistant message wrapping the given `parts`. Reused across renders in the
// regression test to model how the AI SDK hands back the SAME message object.
const msg = (parts: UIMessage["parts"]): UIMessage =>
({ id: "m1", role: "assistant", parts }) as UIMessage;
describe("MessageList", () => {
it("wires the real MessageItem and supplies a valid signature end-to-end", () => {
renderChatMarkdownSpy.mockClear();
const { queryByText } = render(
<MantineProvider>
<MessageList
messages={[msg([{ type: "text", text: "hello world" }])]}
isStreaming={false}
/>
</MantineProvider>,
);
// The assistant text renders, which proves MessageList mounted the real
// MessageItem and handed it a valid `signature` prop (computed from the real
// `messageSignature`) — the full parent -> child -> markdown path is live.
expect(queryByText("hello world")).not.toBeNull();
});
// REGRESSION (PR #224, the empty-render freeze). The AI SDK streams a turn by
// MUTATING the same `parts` array IN PLACE and handing back a NEW array each
// delta that REUSES the same message object. The fix moved the content signature
// to the PARENT: MessageList must recompute `messageSignature(message)` FRESH on
// every render and forward it as the immutable `signature` prop, so MessageItem's
// memo (which compares that prop snapshot) sees it change and re-renders the row.
//
// This test exercises the PARENT half that the memo tests only simulate: if
// MessageList ever cached/memoized the signature keyed on the message object's
// identity (which stays stable across deltas while its `parts` mutate in place),
// the snapshot would never change, MessageItem's memo would skip every delta, and
// the row would freeze at its empty mount — exactly the regression class. That
// would make this test fail. See message-item.tsx (`signature` prop +
// `arePropsEqual`) and message-list.tsx (the `signature={messageSignature(...)}`
// snapshot at render time).
it("reflects in-place part mutation of a reused message object across renders", () => {
renderChatMarkdownSpy.mockClear();
// Reuse ONE message object across renders (as the SDK does). The empty text
// part means MessageItem renders nothing visible initially.
const message = msg([{ type: "text", text: "" }]);
const { rerender, queryByText } = render(
<MantineProvider>
<MessageList messages={[message]} isStreaming />
</MantineProvider>,
);
// Nothing streamed yet.
expect(queryByText("streamed answer")).toBeNull();
// SDK delta: mutate the SAME part in place on the SAME message object...
(message.parts[0] as { text: string }).text = "streamed answer";
// ...then re-render with a NEW array literal that still holds the SAME mutated
// message object (this mirrors useChat handing back a fresh array of reused
// message objects on each delta).
rerender(
<MantineProvider>
<MessageList messages={[message]} isStreaming />
</MantineProvider>,
);
// The grown text now renders: MessageList re-snapshotted the signature, so the
// row re-rendered instead of freezing at its empty mount.
expect(queryByText("streamed answer")).not.toBeNull();
expect(
renderChatMarkdownSpy.mock.calls.some((c) => c[0] === "streamed answer"),
).toBe(true);
});
});

View File

@@ -6,6 +6,7 @@ import MessageItem from "@/features/ai-chat/components/message-item.tsx";
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageListProps {
@@ -196,9 +197,16 @@ export default function MessageList({
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
<Stack gap={0} pr="xs">
{messages.map((message) => (
// `signature` is snapshotted HERE (parent render) into an immutable
// string and handed to MessageItem as its memo key. It must NOT be
// recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the
// shared `parts` in place, so prev/next message objects both read the
// latest content there and the memo would skip every streamed update
// (freezing the row at its empty render). See message-item.tsx.
<MessageItem
key={message.id}
message={message}
signature={messageSignature(message)}
showCitations={showCitations}
neutralizeInternalLinks={neutralizeInternalLinks}
assistantName={assistantName}

View File

@@ -13,21 +13,40 @@ import {
deleteAiRole,
getAiChatMessages,
getAiChats,
getAiRoleCatalog,
getAiRoleCatalogBundle,
getAiRoles,
importAiRolesFromCatalog,
renameAiChat,
updateAiRole,
updateAiRoleFromCatalog,
} from "@/features/ai-chat/services/ai-chat-service.ts";
import {
IAiChat,
IAiChatMessageRow,
IAiRole,
IAiRoleCatalog,
IAiRoleCatalogBundle,
IAiRoleCreate,
IAiRoleImportPayload,
IAiRoleImportResult,
IAiRoleUpdate,
IAiRoleUpdateFromCatalogResult,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import { IPagination } from "@/lib/types.ts";
export const AI_CHATS_RQ_KEY = ["ai-chats"];
export const AI_ROLES_RQ_KEY = ["ai-roles"];
// Catalog reads resolve bundle names per language, so the language is part of
// the cache key (a language switch refetches rather than reusing stale names).
export const AI_ROLE_CATALOG_RQ_KEY = (language: string) => [
"ai-role-catalog",
language,
];
export const AI_ROLE_CATALOG_BUNDLE_RQ_KEY = (
bundleId: string,
language: string,
) => ["ai-role-catalog-bundle", bundleId, language];
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
"ai-chat-messages",
chatId,
@@ -223,3 +242,109 @@ export function useDeleteAiRoleMutation() {
},
});
}
/**
* Browse the role catalog for a language. Gated by `enabled` so the (admin-only)
* fetch runs only when the catalog modal is open. The catalog can 502 when the
* curated source is unreachable; callers handle the error state in the UI.
*/
export function useAiRoleCatalogQuery(language: string, enabled: boolean) {
return useQuery<IAiRoleCatalog, Error>({
queryKey: AI_ROLE_CATALOG_RQ_KEY(language),
queryFn: () => getAiRoleCatalog(language),
enabled,
});
}
/**
* Open one catalog bundle (role content + versions). Gated by `enabled` so the
* fetch only runs when a bundle is actually expanded.
*/
export function useAiRoleCatalogBundleQuery(
bundleId: string,
language: string,
enabled: boolean,
) {
return useQuery<IAiRoleCatalogBundle, Error>({
queryKey: AI_ROLE_CATALOG_BUNDLE_RQ_KEY(bundleId, language),
queryFn: () => getAiRoleCatalogBundle(bundleId, language),
enabled,
});
}
export function useImportAiRolesFromCatalogMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IAiRoleImportResult, Error, IAiRoleImportPayload>({
mutationFn: (payload) => importAiRolesFromCatalog(payload),
onSuccess: (result) => {
notifications.show({
message: t("Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}", {
created: result.created,
renamed: result.renamed,
skipped: result.skipped,
}),
});
// Surface partial failures (e.g. unique-name races) as a red warning.
if (result.errors.length > 0) {
notifications.show({
color: "red",
message: t("Failed to import {{count}} role(s)", {
count: result.errors.length,
}),
});
}
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
// Imported roles can appear in the chat picker / badges.
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
},
onError: (error) => {
const message = error["response"]?.data?.message;
notifications.show({
message: message ?? t("Failed to update data"),
color: "red",
});
},
});
}
export function useUpdateAiRoleFromCatalogMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IAiRoleUpdateFromCatalogResult, Error, string>({
mutationFn: (id) => updateAiRoleFromCatalog(id),
onSuccess: (result) => {
// The server returns updated:false with a reason for a no-op (already
// up to date / removed from catalog / language no longer offered). Map
// each reason to a specific message instead of a generic "up to date".
// Narrow the discriminated union via `"reason" in result` (the `updated`
// boolean discriminant does not narrow under this project's
// strictNullChecks:false). Inside the branch, `reason` is the typed literal
// union, so the comparisons below are compiler-checked.
let message: string;
if (!("reason" in result)) {
message = t("Updated to the latest version");
} else if (result.reason === "not-in-catalog") {
message = t("This role is no longer in the catalog");
} else if (result.reason === "language-unavailable") {
message = t("This language is no longer available in the catalog");
} else {
// "up-to-date" (the only remaining reason).
message = t("Already up to date");
}
notifications.show({ message });
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
// The role badge denormalized onto the chat list may have changed.
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
},
onError: (error) => {
const message = error["response"]?.data?.message;
notifications.show({
message: message ?? t("Failed to update data"),
color: "red",
});
},
});
}

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import React from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { IAiRoleImportResult } from "@/features/ai-chat/types/ai-chat.types.ts";
// `useImportAiRolesFromCatalogMutation` always shows an Imported/renamed/skipped
// summary, and ADDITIONALLY a red "Failed to import N role(s)" notification when
// the result carries partial errors. These tests pin both branches via
// renderHook with a mocked service (twin precedent:
// update-from-catalog-message.test.tsx).
const notificationsShowMock = vi.fn();
vi.mock("@mantine/notifications", () => ({
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
}));
// `t` echoes the key with interpolated values so we assert against the exact
// English message strings (mirrors react-i18next's default interpolation).
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, vars?: Record<string, unknown>) =>
vars
? key.replace(/\{\{(\w+)\}\}/g, (_m, name) => String(vars[name]))
: key,
}),
}));
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
importAiRolesFromCatalog: vi.fn(),
// Other named exports referenced by ai-chat-query.ts must exist on the mock so
// the module import resolves; they are unused by these tests.
createAiRole: vi.fn(),
deleteAiChat: vi.fn(),
deleteAiRole: vi.fn(),
getAiChatMessages: vi.fn(),
getAiChats: vi.fn(),
getAiRoleCatalog: vi.fn(),
getAiRoleCatalogBundle: vi.fn(),
getAiRoles: vi.fn(),
renameAiChat: vi.fn(),
updateAiRole: vi.fn(),
updateAiRoleFromCatalog: vi.fn(),
}));
import { importAiRolesFromCatalog } from "@/features/ai-chat/services/ai-chat-service.ts";
import { useImportAiRolesFromCatalogMutation } from "@/features/ai-chat/queries/ai-chat-query.ts";
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
async function runMutation(result: IAiRoleImportResult) {
vi.mocked(importAiRolesFromCatalog).mockResolvedValue(result);
const { result: hook } = renderHook(
() => useImportAiRolesFromCatalogMutation(),
{ wrapper: createWrapper() },
);
hook.current.mutate({
bundleId: "general",
language: "en",
conflict: "rename",
});
await waitFor(() => expect(hook.current.isSuccess).toBe(true));
}
describe("useImportAiRolesFromCatalogMutation — success notifications", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
await runMutation({ created: 3, renamed: 1, skipped: 2, errors: [] });
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "Imported 3, renamed 1, skipped 2",
});
});
it("errors.length > 0 -> summary PLUS the red failure notification", async () => {
await runMutation({
created: 1,
renamed: 0,
skipped: 0,
errors: [
{ slug: "a", message: "name taken" },
{ slug: "b", message: "name taken" },
],
});
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
message: "Imported 1, renamed 0, skipped 0",
});
expect(notificationsShowMock).toHaveBeenNthCalledWith(2, {
color: "red",
message: "Failed to import 2 role(s)",
});
});
});

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import React from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { IAiRoleUpdateFromCatalogResult } from "@/features/ai-chat/types/ai-chat.types.ts";
// `useUpdateAiRoleFromCatalogMutation` maps the server's discriminated result to
// a user-facing notification message. These tests pin each of the four branches
// (updated / not-in-catalog / language-unavailable / up-to-date) via renderHook
// with a mocked service (precedent: share-query.null-normalization.test.tsx).
const notificationsShowMock = vi.fn();
vi.mock("@mantine/notifications", () => ({
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
}));
// `t` echoes the key so we assert against the exact English message strings.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
updateAiRoleFromCatalog: vi.fn(),
// Other named exports referenced by ai-chat-query.ts must exist on the mock so
// the module import resolves; they are unused by these tests.
createAiRole: vi.fn(),
deleteAiChat: vi.fn(),
deleteAiRole: vi.fn(),
getAiChatMessages: vi.fn(),
getAiChats: vi.fn(),
getAiRoleCatalog: vi.fn(),
getAiRoleCatalogBundle: vi.fn(),
getAiRoles: vi.fn(),
importAiRolesFromCatalog: vi.fn(),
renameAiChat: vi.fn(),
updateAiRole: vi.fn(),
}));
import { updateAiRoleFromCatalog } from "@/features/ai-chat/services/ai-chat-service.ts";
import { useUpdateAiRoleFromCatalogMutation } from "@/features/ai-chat/queries/ai-chat-query.ts";
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
async function runMutation(result: IAiRoleUpdateFromCatalogResult) {
vi.mocked(updateAiRoleFromCatalog).mockResolvedValue(result);
const { result: hook } = renderHook(
() => useUpdateAiRoleFromCatalogMutation(),
{ wrapper: createWrapper() },
);
hook.current.mutate("role-1");
await waitFor(() => expect(hook.current.isSuccess).toBe(true));
}
describe("useUpdateAiRoleFromCatalogMutation — reason → message", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("updated:true -> 'Updated to the latest version'", async () => {
await runMutation({
updated: true,
fromVersion: 1,
toVersion: 2,
role: { id: "role-1" } as never,
});
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "Updated to the latest version",
});
});
it("not-in-catalog -> 'This role is no longer in the catalog'", async () => {
await runMutation({ updated: false, reason: "not-in-catalog" });
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "This role is no longer in the catalog",
});
});
it("language-unavailable -> 'This language is no longer available in the catalog'", async () => {
await runMutation({ updated: false, reason: "language-unavailable" });
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "This language is no longer available in the catalog",
});
});
it("up-to-date -> 'Already up to date'", async () => {
await runMutation({ updated: false, reason: "up-to-date" });
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "Already up to date",
});
});
});

View File

@@ -6,8 +6,13 @@ import {
IAiChatMessageRow,
IAiChatMessagesParams,
IAiRole,
IAiRoleCatalog,
IAiRoleCatalogBundle,
IAiRoleCreate,
IAiRoleImportPayload,
IAiRoleImportResult,
IAiRoleUpdate,
IAiRoleUpdateFromCatalogResult,
} from "@/features/ai-chat/types/ai-chat.types.ts";
/**
@@ -123,3 +128,54 @@ export async function deleteAiRole(id: string): Promise<{ success: true }> {
});
return req.data;
}
/**
* Role catalog API (`/ai-chat/roles/*`, admin-only — the server enforces this).
* Browse a curated catalog, import roles/bundles into the workspace, and update
* an imported role when the catalog ships a newer version. Same `{ data }`
* unwrap convention as above.
*/
/** Browse the catalog, optionally localized to `language`. */
export async function getAiRoleCatalog(
language?: string,
): Promise<IAiRoleCatalog> {
const req = await api.post<IAiRoleCatalog>("/ai-chat/roles/catalog", {
language,
});
return req.data;
}
/** Open one catalog bundle in a language (role content + versions). */
export async function getAiRoleCatalogBundle(
bundleId: string,
language: string,
): Promise<IAiRoleCatalogBundle> {
const req = await api.post<IAiRoleCatalogBundle>(
"/ai-chat/roles/catalog/bundle",
{ bundleId, language },
);
return req.data;
}
/** Import roles from a catalog bundle into the workspace (admin). */
export async function importAiRolesFromCatalog(
payload: IAiRoleImportPayload,
): Promise<IAiRoleImportResult> {
const req = await api.post<IAiRoleImportResult>(
"/ai-chat/roles/import",
payload,
);
return req.data;
}
/** Update an already-imported role from its catalog source (admin). */
export async function updateAiRoleFromCatalog(
id: string,
): Promise<IAiRoleUpdateFromCatalogResult> {
const req = await api.post<IAiRoleUpdateFromCatalogResult>(
"/ai-chat/roles/update-from-catalog",
{ id },
);
return req.data;
}

View File

@@ -57,10 +57,79 @@ export interface IAiRole {
autoStart: boolean;
// Custom auto-start text; null/empty => the default launch message is sent.
launchMessage: string | null;
// Catalog origin of an imported role, or null for a manually-created one.
// Admin-only (present only in the admin list view); the picker view omits it.
// The admin UI compares `version` against the catalog to offer an update.
source?: { slug: string; language: string; version: number } | null;
createdAt?: string;
updatedAt?: string;
}
/** One bundle's summary in the catalog index (mirrors `getCatalog().bundles[]`). */
export interface IAiRoleCatalogBundleSummary {
id: string;
name: string;
description: string | null;
languages: string[];
roles: { slug: string; version: number }[];
}
/** The browsable catalog index (mirrors `getCatalog()`). */
export interface IAiRoleCatalog {
languages: string[];
bundles: IAiRoleCatalogBundleSummary[];
}
/** A single role inside an opened catalog bundle (localized content + version). */
export interface IAiRoleCatalogRole {
slug: string;
emoji: string | null;
name: string;
description: string | null;
instructions: string;
autoStart: boolean;
launchMessage: string | null;
version: number;
}
/** An opened catalog bundle (mirrors `getCatalogBundle()`). */
export interface IAiRoleCatalogBundle {
bundleId: string;
language: string;
roles: IAiRoleCatalogRole[];
}
/** Import payload (mirrors the server `ImportFromCatalogDto`). */
export interface IAiRoleImportPayload {
bundleId: string;
language: string;
// Omitted => import the whole bundle; otherwise only these slugs.
slugs?: string[];
conflict: "skip" | "rename";
}
/** Import result counts (mirrors `importFromCatalog()`). */
export interface IAiRoleImportResult {
created: number;
skipped: number;
renamed: number;
errors: { slug: string; message: string }[];
}
/**
* Update-from-catalog result (mirrors the server `updateFromCatalog()`). A
* discriminated union on `updated`: a no-op carries a typed `reason` the UI maps
* to a specific message; a successful update carries the version bump + new role.
* Keeping the union (not a widened `reason?: string`) lets the consumer's literal
* comparisons be compiler-checked.
*/
export type IAiRoleUpdateFromCatalogResult =
| {
updated: false;
reason: "not-in-catalog" | "up-to-date" | "language-unavailable";
}
| { updated: true; fromVersion: number; toVersion: number; role: IAiRole };
/** Admin create payload for a role. */
export interface IAiRoleCreate {
name: string;

View File

@@ -0,0 +1,107 @@
import { describe, it, expect } from "vitest";
import { catalogRoleInstallState } from "./catalog-role-install-state.ts";
import type { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
// Build a workspace role with a catalog source. Fields irrelevant to the
// install-state decision are filled with harmless defaults.
function installedRole(
source: { slug: string; language: string; version: number },
overrides: Partial<IAiRole> = {},
): IAiRole {
return {
id: `role-${source.slug}-${source.language}`,
name: source.slug,
emoji: null,
description: null,
enabled: true,
autoStart: true,
launchMessage: null,
source,
...overrides,
};
}
const catalogRole = { slug: "writer", version: 3 };
// Mirrors the role-launch.ts precedent: the modal's role-state computation is a
// pure function so the import/installed/update decision is testable directly.
describe("catalogRoleInstallState", () => {
it("no matching installed role -> import", () => {
const result = catalogRoleInstallState(catalogRole, [], "en");
expect(result).toEqual({ state: "import" });
});
it("same slug + language, installed version > catalog -> installed", () => {
const installed = installedRole({
slug: "writer",
language: "en",
version: 5,
});
const result = catalogRoleInstallState(catalogRole, [installed], "en");
expect(result).toEqual({ state: "installed", installed });
});
it("same slug + language, installed version == catalog -> installed", () => {
const installed = installedRole({
slug: "writer",
language: "en",
version: 3,
});
const result = catalogRoleInstallState(catalogRole, [installed], "en");
expect(result).toEqual({ state: "installed", installed });
});
it("same slug + language, installed version < catalog -> update (from/to)", () => {
const installed = installedRole({
slug: "writer",
language: "en",
version: 1,
});
const result = catalogRoleInstallState(catalogRole, [installed], "en");
expect(result).toEqual({
state: "update",
installed,
fromVersion: 1,
toVersion: 3,
});
});
it("same slug but DIFFERENT language -> import (a separate install)", () => {
// 'writer' is installed in 'ru'; browsing the 'en' catalog must offer it as a
// fresh import, not treat the ru copy as already installed.
const installed = installedRole({
slug: "writer",
language: "ru",
version: 5,
});
const result = catalogRoleInstallState(catalogRole, [installed], "en");
expect(result).toEqual({ state: "import" });
});
it("matches the right language when the same slug is installed in several", () => {
const ru = installedRole(
{ slug: "writer", language: "ru", version: 5 },
{ id: "ru-role" },
);
const en = installedRole(
{ slug: "writer", language: "en", version: 1 },
{ id: "en-role" },
);
const result = catalogRoleInstallState(catalogRole, [ru, en], "en");
expect(result).toEqual({
state: "update",
installed: en,
fromVersion: 1,
toVersion: 3,
});
});
it("ignores manually-created roles (no source) sharing the name", () => {
const manual = installedRole(
{ slug: "writer", language: "en", version: 9 },
{ source: null },
);
const result = catalogRoleInstallState(catalogRole, [manual], "en");
expect(result).toEqual({ state: "import" });
});
});

View File

@@ -0,0 +1,49 @@
import type {
IAiRole,
IAiRoleCatalogRole,
} from "@/features/ai-chat/types/ai-chat.types.ts";
/**
* The install state of a single catalog role relative to the workspace's
* existing roles. Extracted as a pure function so the catalog modal's role-state
* computation is unit-testable without mounting the component (mirrors the
* `roleLaunchMessage` precedent in role-launch.ts).
*
* A catalog role is matched to an installed role by BOTH `source.slug` and
* `source.language`: the same slug in a different language is a separate install
* (so it shows as "import", not "installed"). When matched, the installed source
* version decides the state:
* - no match -> "import"
* - matched & installed version >= catalog version -> "installed"
* - matched & installed version < catalog version -> "update" (from -> to)
*/
export type CatalogRoleInstallState =
| { state: "import" }
| { state: "installed"; installed: IAiRole }
| {
state: "update";
installed: IAiRole;
fromVersion: number;
toVersion: number;
};
export function catalogRoleInstallState(
role: Pick<IAiRoleCatalogRole, "slug" | "version">,
workspaceRoles: IAiRole[],
language: string,
): CatalogRoleInstallState {
const installed = workspaceRoles.find(
(r) => r.source?.slug === role.slug && r.source?.language === language,
);
if (!installed) return { state: "import" };
const fromVersion = installed.source?.version ?? 0;
if (fromVersion >= role.version) {
return { state: "installed", installed };
}
return {
state: "update",
installed,
fromVersion,
toVersion: role.version,
};
}

View File

@@ -10,8 +10,6 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false);
// Current page's edit mode — initialized from the user's saved preference on

View File

@@ -9,11 +9,10 @@ import {
IconStrikethrough,
IconUnderline,
IconMessage,
IconSparkles,
} from "@tabler/icons-react";
import clsx from "clsx";
import classes from "./bubble-menu.module.css";
import { ActionIcon, Button, rem, Tooltip } from "@mantine/core";
import { ActionIcon, rem, Tooltip } from "@mantine/core";
import { ColorSelector } from "./color-selector";
import { NodeSelector } from "./node-selector";
import { TextAlignmentSelector } from "./text-alignment-selector";
@@ -26,8 +25,8 @@ import { v7 as uuid7 } from "uuid";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next";
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { userAtom } from "@/features/user/atoms/current-user-atom";
export interface BubbleMenuItem {
name: string;
@@ -44,16 +43,12 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const { templateMode = false } = props;
const { t } = useTranslation();
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
const user = useAtomValue(userAtom);
const editorToolbarEnabled =
user?.settings?.preferences?.editorToolbar ?? false;
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const showCommentPopupRef = useRef(showCommentPopup);
const showAiMenuRef = useRef(showAiMenu);
const [showLinkMenu] = useAtom(showLinkMenuAtom);
const showLinkMenuRef = useRef(showLinkMenu);
@@ -61,10 +56,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
showCommentPopupRef.current = showCommentPopup;
}, [showCommentPopup]);
useEffect(() => {
showAiMenuRef.current = showAiMenu;
}, [showAiMenu]);
useEffect(() => {
showLinkMenuRef.current = showLinkMenu;
}, [showLinkMenu]);
@@ -145,7 +136,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
empty ||
isNodeSelection(selection) ||
isCellSelection(selection) ||
showAiMenuRef.current ||
showLinkMenuRef.current ||
showCommentPopupRef?.current
) {
@@ -168,8 +158,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
// Hide the bubble menu immediately when AI menu is shown
if (showAiMenu || showLinkMenu) return;
// Hide the bubble menu immediately when the link menu is shown
if (showLinkMenu) return;
return (
<BubbleMenu
@@ -177,22 +167,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
style={{ zIndex: 199, position: "relative" }}
>
<div className={classes.bubbleMenu}>
{isGenerativeAiEnabled && (
<>
<Button
variant="default"
className={clsx(classes.buttonRoot)}
radius="0"
leftSection={<IconSparkles size={16} />}
onClick={() => {
setShowAiMenu(true);
}}
>
{t("Ask AI")}
</Button>
<div className={classes.divider} />
</>
)}
{!editorToolbarEnabled && (
<>
<NodeSelector

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
sortFrequentlyUsedEmoji,
getFrequentlyUsedEmoji,
LOCAL_STORAGE_FREQUENT_KEY,
} from "./utils";
describe("sortFrequentlyUsedEmoji", () => {
it("orders known emoji by descending usage count", async () => {
const result = await sortFrequentlyUsedEmoji({
rocket: 1,
joy: 9,
heart_eyes: 5,
});
expect(result.map((e) => e.id)).toEqual(["joy", "heart_eyes", "rocket"]);
});
it("caps the result at the top 5 most frequent", async () => {
const result = await sortFrequentlyUsedEmoji({
rocket: 1,
joy: 2,
heart_eyes: 3,
grinning: 4,
laughing: 5,
scream: 6,
sweat_smile: 7,
});
expect(result).toHaveLength(5);
// Highest counts retained, lowest (rocket:1, joy:2) dropped.
expect(result.map((e) => e.id)).toEqual([
"sweat_smile",
"scream",
"laughing",
"grinning",
"heart_eyes",
]);
});
it("drops ids that have no matching emoji in the index", async () => {
const result = await sortFrequentlyUsedEmoji({
__definitely_not_a_real_emoji_id__: 100,
rocket: 1,
});
expect(result.map((e) => e.id)).toEqual(["rocket"]);
});
it("maps each entry to its native glyph and a command", async () => {
const [entry] = await sortFrequentlyUsedEmoji({ rocket: 5 });
expect(entry.id).toBe("rocket");
expect(typeof entry.emoji).toBe("string");
expect(entry.emoji.length).toBeGreaterThan(0);
expect(typeof entry.command).toBe("function");
});
it("returns an empty list for empty input", async () => {
expect(await sortFrequentlyUsedEmoji({})).toEqual([]);
});
});
describe("getFrequentlyUsedEmoji", () => {
beforeEach(() => {
localStorage.clear();
});
it("falls back to the default map when nothing is stored", () => {
const result = getFrequentlyUsedEmoji();
expect(result["+1"]).toBe(10);
expect(result["rocket"]).toBe(1);
});
it("parses a valid stored JSON map", () => {
localStorage.setItem(
LOCAL_STORAGE_FREQUENT_KEY,
JSON.stringify({ rocket: 42 }),
);
expect(getFrequentlyUsedEmoji()).toEqual({ rocket: 42 });
});
// BUG (issue #204, Phase 2): getFrequentlyUsedEmoji() does an unprotected
// JSON.parse() of the raw localStorage value. A corrupt value (e.g. truncated
// by a crash, or written by another tab/extension) makes the emoji menu throw
// on open instead of degrading gracefully to the default set.
//
// Documented with it.fails: this asserts the DESIRED behavior (return a sane
// default, never throw). It currently FAILS because the function throws —
// flip to `it()` once utils.ts guards the JSON.parse.
it.fails(
"should degrade to a sane default on corrupt localStorage (currently throws)",
() => {
localStorage.setItem(LOCAL_STORAGE_FREQUENT_KEY, "{not valid json");
let result: Record<string, number> | undefined;
expect(() => {
result = getFrequentlyUsedEmoji();
}).not.toThrow();
// Should hand back a usable, non-empty map rather than nothing.
expect(result).toBeTruthy();
expect(Object.keys(result ?? {}).length).toBeGreaterThan(0);
},
);
});

View File

@@ -12,8 +12,6 @@ import { MediaGroup } from "./groups/media-group";
import { QuickInsertsGroup } from "./groups/quick-inserts-group";
import { MoreInsertsGroup } from "./groups/more-inserts-group";
import { HistoryGroup } from "./groups/history-group";
import { AskAiGroup } from "./groups/ask-ai-group";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import classes from "./fixed-toolbar.module.css";
type FixedToolbarProps = {
@@ -28,8 +26,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
const editorFromAtom = useAtomValue(pageEditorAtom);
const editor = editorProp ?? editorFromAtom;
const state = useToolbarState(editor);
const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
if (!editor || !state) return null;
@@ -43,12 +39,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
onMouseDown={(e) => e.preventDefault()}
>
<div className={classes.inner}>
{/* {isGenerativeAiEnabled && (
<>
<AskAiGroup />
<div className={classes.divider} />
</>
)} */}
<BlockTypeGroup editor={editor} />
<div className={classes.divider} />
<InlineMarksGroup editor={editor} state={state} />

View File

@@ -1,23 +0,0 @@
import { FC } from "react";
import { Button } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useSetAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
export const AskAiGroup: FC = () => {
const { t } = useTranslation();
const setShowAiMenu = useSetAtom(showAiMenuAtom);
return (
<Button
variant="subtle"
color="dark"
size="xs"
leftSection={<IconSparkles size={14} />}
onClick={() => setShowAiMenu(true)}
>
{t("Ask AI")}
</Button>
);
};

View File

@@ -13,7 +13,7 @@ interface Props {
/**
* AI "generate title" button (#199). Reads the live editor content and applies a
* model-suggested title immediately. Rendered in the page byline, only in edit
* mode and when the workspace's generative AI flag is on.
* mode and when the workspace's AI chat flag is on.
*/
export const GenerateTitleGroup: FC<Props> = ({
pageId,

View File

@@ -0,0 +1,163 @@
import { describe, it, expect } from "vitest";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import {
isHeaderCell,
sortItems,
weaveItems,
type SortableItem,
} from "./sort-cells";
// isHeaderCell only reads node.type.name and node.attrs?.header, so a minimal
// duck-typed node is sufficient (no real ProseMirror schema needed).
function fakeNode(typeName: string, attrs: Record<string, unknown> = {}) {
return { type: { name: typeName }, attrs } as unknown as ProseMirrorNode;
}
function item<T>(
payload: T,
text: string,
originalOrder: number,
opts: { isHeader?: boolean; isEmpty?: boolean } = {},
): SortableItem<T> {
return {
payload,
text,
originalOrder,
isHeader: opts.isHeader ?? false,
isEmpty: opts.isEmpty ?? text.trim() === "",
};
}
describe("isHeaderCell", () => {
it("recognizes the tableHeader node type", () => {
expect(isHeaderCell(fakeNode("tableHeader"))).toBe(true);
});
it("recognizes the snake_case table_header node type", () => {
expect(isHeaderCell(fakeNode("table_header"))).toBe(true);
});
it("treats a plain cell with header:true attr as a header", () => {
expect(isHeaderCell(fakeNode("tableCell", { header: true }))).toBe(true);
});
it("returns false for a regular body cell", () => {
expect(isHeaderCell(fakeNode("tableCell", { header: false }))).toBe(false);
expect(isHeaderCell(fakeNode("tableCell"))).toBe(false);
});
});
describe("sortItems", () => {
it("sorts non-empty rows ascending using a base/numeric collator", () => {
const data = [
item("c", "cherry", 0),
item("a", "Apple", 1),
item("b", "banana", 2),
];
expect(sortItems(data, "asc").map((i) => i.payload)).toEqual([
"a",
"b",
"c",
]);
});
it("sorts descending when direction is desc", () => {
const data = [
item("a", "apple", 0),
item("b", "banana", 1),
item("c", "cherry", 2),
];
expect(sortItems(data, "desc").map((i) => i.payload)).toEqual([
"c",
"b",
"a",
]);
});
it("orders numerically, not lexically (numeric collator)", () => {
const data = [
item("ten", "10", 0),
item("two", "2", 1),
item("one", "1", 2),
];
expect(sortItems(data, "asc").map((i) => i.payload)).toEqual([
"one",
"two",
"ten",
]);
});
it("always pushes empty cells to the bottom regardless of direction", () => {
const data = [
item("empty", "", 0, { isEmpty: true }),
item("b", "banana", 1),
item("a", "apple", 2),
];
const asc = sortItems(data, "asc");
expect(asc.map((i) => i.payload)).toEqual(["a", "b", "empty"]);
const desc = sortItems(data, "desc");
// Empty stays last even when the rest is reversed.
expect(desc[desc.length - 1].payload).toBe("empty");
});
it("keeps empty cells in their original relative order (stable)", () => {
const data = [
item("e1", "", 5, { isEmpty: true }),
item("e2", "", 2, { isEmpty: true }),
item("a", "apple", 9),
];
const sorted = sortItems(data, "asc");
// e2 (originalOrder 2) before e1 (originalOrder 5).
expect(sorted.map((i) => i.payload)).toEqual(["a", "e2", "e1"]);
});
it("does not mutate the input array", () => {
const data = [item("b", "banana", 0), item("a", "apple", 1)];
const snapshot = data.map((i) => i.payload);
sortItems(data, "asc");
expect(data.map((i) => i.payload)).toEqual(snapshot);
});
});
describe("weaveItems", () => {
it("keeps header rows pinned in place and fills body slots from sorted data", () => {
const header = item("H", "Name", 0, { isHeader: true });
const all = [
header,
item("orig-b", "b", 1),
item("orig-a", "a", 2),
];
const sortedBody = [item("orig-a", "a", 2), item("orig-b", "b", 1)];
const woven = weaveItems(all, sortedBody);
// Header never moves out of row 0...
expect(woven[0]).toBe(header);
// ...and the body positions are filled in sorted order.
expect(woven.slice(1).map((i) => i.payload)).toEqual(["orig-a", "orig-b"]);
});
it("does not consume body data for header positions (header stays at top)", () => {
const header = item("H", "head", 0, { isHeader: true });
const all = [header, item("x", "x", 1), item("y", "y", 2)];
const sortedBody = [item("y", "y", 2), item("x", "x", 1)];
const woven = weaveItems(all, sortedBody);
expect(woven[0].isHeader).toBe(true);
expect(woven.filter((i) => !i.isHeader).map((i) => i.payload)).toEqual([
"y",
"x",
]);
});
it("interleaves correctly when a header sits between body rows", () => {
const header = item("H", "head", 1, { isHeader: true });
const all = [
item("b1", "b1", 0),
header,
item("b2", "b2", 2),
];
const sortedBody = [item("b2", "b2", 2), item("b1", "b1", 0)];
const woven = weaveItems(all, sortedBody);
expect(woven.map((i) => i.payload)).toEqual(["b2", "H", "b1"]);
expect(woven[1]).toBe(header);
});
});

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from "vitest";
import { WebSocketStatus } from "@hocuspocus/provider";
import { isCollabSynced, isBodyEditable } from "./editor-sync-state";
describe("isCollabSynced", () => {
it("is true only when Connected and synced", () => {
expect(isCollabSynced(WebSocketStatus.Connected, true)).toBe(true);
});
it("is false while connecting or not yet synced", () => {
expect(isCollabSynced(WebSocketStatus.Connecting, true)).toBe(false);
expect(isCollabSynced(WebSocketStatus.Connected, false)).toBe(false);
expect(isCollabSynced(WebSocketStatus.Disconnected, true)).toBe(false);
});
});
describe("isBodyEditable (pre-sync data-loss gate, #218)", () => {
const base = { editable: true, inEditMode: true, showStatic: false };
it("allows editing only after the static (pre-sync) phase ends", () => {
expect(isBodyEditable(base)).toBe(true);
});
it("never editable while the static read-only editor is shown", () => {
expect(isBodyEditable({ ...base, showStatic: true })).toBe(false);
});
it("honors read-only and view mode", () => {
expect(isBodyEditable({ ...base, editable: false })).toBe(false);
expect(isBodyEditable({ ...base, inEditMode: false })).toBe(false);
});
});

View File

@@ -0,0 +1,32 @@
import { WebSocketStatus } from "@hocuspocus/provider";
/**
* The collab document is usable only once the provider is Connected AND has
* synced (both the local IndexedDB replica and the remote room). Until then the
* in-browser Y.Doc is empty/stale, so edits would either be dropped or clobber
* the server's authoritative doc when it finally arrives.
*/
export function isCollabSynced(
status: WebSocketStatus | string,
isSynced: boolean,
): boolean {
return status === WebSocketStatus.Connected && isSynced;
}
/**
* Whether the page BODY editor may accept edits.
*
* `showStatic` is true during the pre-sync window (a read-only static editor is
* shown). Gating editability on `!showStatic` guarantees the body never becomes
* editable before the collab doc is synced, so early keystrokes on a freshly
* created page can't land only in local ProseMirror and then be lost when the
* server's initial empty doc syncs in (#218). Read-only and view modes are
* still honored via `editable`/`inEditMode`.
*/
export function isBodyEditable(opts: {
editable: boolean;
inEditMode: boolean;
showStatic: boolean;
}): boolean {
return opts.editable && opts.inEditMode && !opts.showStatic;
}

View File

@@ -0,0 +1,126 @@
import { describe, it, expect } from "vitest";
import { normalizeTableColumnWidths } from "./markdown-clipboard";
// normalizeTableColumnWidths mutates a DOM subtree (jsdom provides document).
function root(html: string): HTMLElement {
const div = document.createElement("div");
div.innerHTML = html;
return div;
}
function firstRowColWidths(container: HTMLElement): (string | null)[] {
const row = container.querySelector("tr");
return Array.from(row?.children ?? []).map((c) =>
c.getAttribute("colwidth"),
);
}
describe("normalizeTableColumnWidths", () => {
// The core "squash столбцов вставленной таблицы" concern: markdown has no
// widths, so every pasted table would otherwise render at table-layout:fixed
// / 100% and squash columns. This stamps an explicit per-column px width.
it("stamps the default px width on every column when no widths are present", () => {
const container = root(
"<table><tbody><tr><td>a</td><td>b</td><td>c</td></tr></tbody></table>",
);
normalizeTableColumnWidths(container);
expect(firstRowColWidths(container)).toEqual(["150", "150", "150"]);
});
it("derives column widths from a colgroup", () => {
const container = root(
"<table>" +
'<colgroup><col style="width:200px"><col style="width:80px"></colgroup>' +
"<tbody><tr><td>a</td><td>b</td></tr></tbody>" +
"</table>",
);
normalizeTableColumnWidths(container);
expect(firstRowColWidths(container)).toEqual(["200", "80"]);
});
it("derives column widths from per-cell width attributes", () => {
const container = root(
'<table><tbody><tr><td width="120">a</td><td width="90">b</td></tr></tbody></table>',
);
normalizeTableColumnWidths(container);
expect(firstRowColWidths(container)).toEqual(["120", "90"]);
});
it("derives column widths from a cell style:width:px", () => {
const container = root(
'<table><tbody><tr><td style="width:140px">a</td><td>b</td></tr></tbody></table>',
);
normalizeTableColumnWidths(container);
// First cell width parsed; a fully-unmeasured column is left untouched
// (the 100 fallback only fills in NULL gaps inside an otherwise-measured
// multi-column slice, e.g. a colspan).
expect(firstRowColWidths(container)).toEqual(["140", null]);
});
it("fills a null gap inside a measured colspanned slice with 100", () => {
// colgroup gives [200, null]; the single colspan=2 cell spans both, so its
// slice is [200, null] -> the null is backfilled to 100 => "200,100".
const container = root(
"<table>" +
'<colgroup><col style="width:200px"><col></colgroup>' +
'<tbody><tr><td colspan="2">merged</td></tr></tbody>' +
"</table>",
);
normalizeTableColumnWidths(container);
expect(firstRowColWidths(container)).toEqual(["200,100"]);
});
it("splits a measured width across a colspanned cell", () => {
const container = root(
'<table><tbody><tr><td colspan="2" width="300">merged</td><td width="100">x</td></tr></tbody></table>',
);
normalizeTableColumnWidths(container);
// 300 / colspan(2) = 150 per underlying column => "150,150" on the merged cell.
expect(firstRowColWidths(container)).toEqual(["150,150", "100"]);
});
it("falls back to the default width per spanned column when nothing is measurable", () => {
const container = root(
'<table><tbody><tr><td colspan="2">merged</td><td>x</td></tr></tbody></table>',
);
normalizeTableColumnWidths(container);
expect(firstRowColWidths(container)).toEqual(["150,150", "150"]);
});
it("leaves cells that already have a colwidth untouched", () => {
const container = root(
'<table><tbody><tr><td colwidth="42">a</td><td>b</td></tr></tbody></table>',
);
normalizeTableColumnWidths(container);
expect(firstRowColWidths(container)).toEqual(["42", "150"]);
});
it("normalizes every table in the subtree", () => {
const container = root(
"<table><tbody><tr><td>a</td></tr></tbody></table>" +
"<table><tbody><tr><td>b</td><td>c</td></tr></tbody></table>",
);
normalizeTableColumnWidths(container);
const tables = container.querySelectorAll("table");
const widths = Array.from(tables).map((t) =>
Array.from(t.querySelector("tr")!.children).map((c) =>
c.getAttribute("colwidth"),
),
);
expect(widths).toEqual([["150"], ["150", "150"]]);
});
it("only annotates the first row (column widths are defined once)", () => {
const container = root(
"<table><tbody>" +
"<tr><td>a</td><td>b</td></tr>" +
"<tr><td>c</td><td>d</td></tr>" +
"</tbody></table>",
);
normalizeTableColumnWidths(container);
const rows = container.querySelectorAll("tr");
expect(
Array.from(rows[1].children).map((c) => c.getAttribute("colwidth")),
).toEqual([null, null]);
});
});

View File

@@ -77,9 +77,9 @@ export function FullEditor({
const [user] = useAtom(userAtom);
const workspace = useAtomValue(workspaceAtom);
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
// AI title generation reuses the generative AI flag (same gate as the on-page
// generative menu); the server enforces it too (#199).
const isTitleGenEnabled = workspace?.settings?.ai?.generative === true;
// AI title generation is gated by the general AI chat flag (the same toggle
// that enables the chat agent); the server enforces it too (#199).
const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
const editorToolbarEnabled =
user.settings?.preferences?.editorToolbar ?? false;
@@ -254,7 +254,7 @@ function PageByline({
{showDictation && editor && (
<DictationGroup editor={editor} color="gray" iconSize={20} />
)}
{/* Shown only in edit mode when the workspace's generative AI flag is on,
{/* Shown only in edit mode when the workspace's AI chat flag is on,
so AI title generation stays reachable from the byline (#199). */}
{showTitleGen && (
<GenerateTitleGroup pageId={pageId} color="gray" iconSize={20} />

View File

@@ -84,6 +84,10 @@ import { PageEmbedLookupProvider } from "@/features/editor/components/page-embed
import { PageEmbedAncestryProvider } from "@/features/editor/components/page-embed/page-embed-ancestry-context";
import PageEmbedPicker from "@/features/editor/components/page-embed/page-embed-picker";
import { useTranslation } from "react-i18next";
import {
isBodyEditable,
isCollabSynced,
} from "@/features/editor/editor-sync-state";
interface PageEditorProps {
pageId: string;
@@ -440,6 +444,9 @@ export default function PageEditor({
const isSynced = isLocalSynced && isRemoteSynced;
const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true);
useEffect(() => {
const timeout = setTimeout(() => {
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
@@ -451,17 +458,21 @@ export default function PageEditor({
}, [yjsConnectionStatus, isSynced]);
useEffect(() => {
if (!editor) return;
editor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
}, [currentPageEditMode, editor, editable]);
const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true);
// Keep the body read-only until the collab doc has synced (showStatic), so
// early keystrokes on a freshly created page can't be lost (#218).
editor.setEditable(
isBodyEditable({
editable,
inEditMode: currentPageEditMode === PageEditMode.Edit,
showStatic,
}),
);
}, [currentPageEditMode, editor, editable, showStatic]);
useEffect(() => {
if (
!hasConnectedOnceRef.current &&
yjsConnectionStatus === WebSocketStatus.Connected &&
isSynced
isCollabSynced(yjsConnectionStatus, isSynced)
) {
hasConnectedOnceRef.current = true;
setShowStatic(false);
@@ -473,17 +484,43 @@ export default function PageEditor({
<PageEmbedLookupProvider>
<PageEmbedAncestryProvider hostPageId={pageId}>
{showStatic ? (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": t("Page content"),
},
}}
/>
<div style={{ position: "relative" }}>
{/* Surface the pre-sync read-only window so edits typed before the
collab provider connects aren't silently swallowed (#218). Shown
only when the user is otherwise allowed to edit. */}
{editable && currentPageEditMode === PageEditMode.Edit && (
<div
role="status"
aria-live="polite"
className="print-hide"
style={{
position: "absolute",
top: 0,
right: 0,
zIndex: 2,
padding: "2px 8px",
fontSize: "12px",
borderRadius: "4px",
background: "var(--mantine-color-gray-light)",
color: "var(--mantine-color-dimmed)",
pointerEvents: "none",
}}
>
{t("Connecting… (read-only)")}
</div>
)}
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": t("Page content"),
},
}}
/>
</div>
) : (
<div className="editor-container" style={{ position: "relative" }}>
<div ref={menuContainerRef}>

View File

@@ -1,5 +1,6 @@
import { Button, Menu, Text } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { Button, Menu, Stack, Text } from "@mantine/core";
import { IconHourglass, IconPlus } from "@tabler/icons-react";
import { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
@@ -10,24 +11,38 @@ import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { canCreatePage } from "./can-create-page.ts";
// Prominent home-screen action to create a new note (page). Because the home
// screen has no active space, the target space is resolved from the user's
// writable spaces: created directly when there is one, picked from a dropdown
// when there are several.
export default function NewNoteButton() {
// A single create-note action, parametrized by `temporary`. Self-contained: it
// owns its own create mutation so the regular and temporary buttons show
// independent loading state, while the list of writable spaces is resolved once
// by the parent and passed in. With exactly one writable space it creates
// directly; with several it shows a target-space picker.
function CreateNoteButton({
writableSpaces,
temporary,
label,
icon,
color,
}: {
writableSpaces: ISpace[];
temporary: boolean;
label: string;
icon: ReactNode;
// Mantine color token; lets the temporary action tint toward the warm
// orange/amber used by the clock marker + banner while "New note" stays neutral.
color: string;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const createPageMutation = useCreatePageMutation();
const { data } = useGetSpacesQuery({ limit: 100 });
const writableSpaces = (data?.items ?? []).filter(canCreatePage);
const createNote = async (space: ISpace) => {
try {
// `spaceId` is accepted by the create-page endpoint but is not part of
// the shared `IPageInput` type; cast to satisfy the mutation signature.
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
// not part of the shared `IPageInput` type; cast to satisfy the mutation
// signature.
const createdPage = await createPageMutation.mutateAsync({
spaceId: space.id,
...(temporary ? { temporary: true } : {}),
} as any);
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
} catch {
@@ -35,24 +50,21 @@ export default function NewNoteButton() {
}
};
// No writable space → nothing to create in; render nothing.
if (writableSpaces.length === 0) return null;
const isPending = createPageMutation.isPending;
// Exactly one writable space → create directly, no picker needed.
if (writableSpaces.length === 1) {
return (
<Button
fullWidth
size="md"
variant="light"
color="gray"
leftSection={<IconPlus size={18} />}
color={color}
fullWidth
leftSection={icon}
loading={isPending}
onClick={() => createNote(writableSpaces[0])}
>
{t("New note")}
{label}
</Button>
);
}
@@ -62,14 +74,14 @@ export default function NewNoteButton() {
<Menu shadow="md" width="target" position="bottom-start">
<Menu.Target>
<Button
fullWidth
size="md"
variant="light"
color="gray"
leftSection={<IconPlus size={18} />}
color={color}
fullWidth
leftSection={icon}
loading={isPending}
>
{t("New note")}
{label}
</Button>
</Menu.Target>
<Menu.Dropdown>
@@ -99,3 +111,39 @@ export default function NewNoteButton() {
</Menu>
);
}
// Prominent home-screen actions to create a new note (page). Because the home
// screen has no active space, the target space is resolved from the user's
// writable spaces: created directly when there is one, picked from a dropdown
// when there are several. Renders two full-width, vertically stacked buttons: a
// neutral regular note and an orange-tinted temporary note (which auto-moves to
// Trash after the workspace lifetime). Stacking full-width keeps the longer
// "New temporary note" label from clipping on narrow mobile widths.
export default function NewNoteButton() {
const { t } = useTranslation();
const { data } = useGetSpacesQuery({ limit: 100 });
const writableSpaces = (data?.items ?? []).filter(canCreatePage);
// No writable space → nothing to create in; render nothing.
if (writableSpaces.length === 0) return null;
return (
<Stack gap="sm">
<CreateNoteButton
writableSpaces={writableSpaces}
temporary={false}
label={t("New note")}
icon={<IconPlus size={18} />}
color="gray"
/>
<CreateNoteButton
writableSpaces={writableSpaces}
temporary={true}
label={t("New temporary note")}
icon={<IconHourglass size={18} />}
color="orange"
/>
</Stack>
);
}

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getDefaultStore } from "jotai";
// Mock the app entry so importing the query module doesn't boot the whole app
// (it only needs queryClient's cache methods, which we stub here). The spies are
// declared via vi.hoisted so they exist before the hoisted vi.mock factory runs.
const { setQueryData, getQueryData, invalidateQueries } = vi.hoisted(() => ({
setQueryData: vi.fn(),
getQueryData: vi.fn(() => undefined as unknown),
invalidateQueries: vi.fn(),
}));
vi.mock("@/main.tsx", () => ({
queryClient: { setQueryData, getQueryData, invalidateQueries },
}));
import { syncTemporaryExpiresInCache } from "./page-embed-query";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
const mkNode = (id: string, slugId: string): SpaceTreeNode =>
({
id,
slugId,
name: id,
position: "a0",
spaceId: "space-1",
parentPageId: null,
hasChildren: false,
children: [],
}) as unknown as SpaceTreeNode;
describe("syncTemporaryExpiresInCache — treeDataAtom patch", () => {
beforeEach(() => {
vi.clearAllMocks();
getQueryData.mockReturnValue(undefined);
});
it("patches the in-tree node's temporaryExpiresAt (sidebar marker updates without reload)", () => {
const store = getDefaultStore();
const tree = [mkNode("p1", "slug-1"), mkNode("p2", "slug-2")];
store.set(treeDataAtom, tree);
const deadline = "2026-07-01T00:00:00.000Z";
syncTemporaryExpiresInCache({ id: "p1", slugId: "slug-1" }, deadline);
const next = store.get(treeDataAtom);
// A new atom value was written...
expect(next).not.toBe(tree);
// ...the matching node gained the deadline...
expect(next.find((n) => n.id === "p1")?.temporaryExpiresAt).toBe(deadline);
// ...and the untouched sibling is unchanged.
expect(next.find((n) => n.id === "p2")?.temporaryExpiresAt).toBeUndefined();
});
it("leaves the atom value at the SAME reference when the id is absent from the tree (no write)", () => {
const store = getDefaultStore();
const tree = [mkNode("p1", "slug-1")];
store.set(treeDataAtom, tree);
syncTemporaryExpiresInCache(
{ id: "not-in-tree", slugId: "missing" },
"2026-07-01T00:00:00.000Z",
);
// treeModel.update is a no-op (same reference) for an unknown id, so the
// guard skips the store write entirely — same reference back.
expect(store.get(treeDataAtom)).toBe(tree);
});
});

View File

@@ -1,5 +1,6 @@
import { useMutation } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { getDefaultStore } from "jotai";
import {
toggleTemplate,
toggleTemporary,
@@ -9,6 +10,9 @@ import type {
ToggleTemporaryResponse,
} from "@/features/page-embed/types/page-embed.types";
import { queryClient } from "@/main.tsx";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
/**
* After toggling a note's temporary state, mirror the new deadline into the
@@ -30,6 +34,19 @@ export function syncTemporaryExpiresInCache(
});
}
}
// Patch the in-memory sidebar tree node so its temporary clock marker
// appears/disappears immediately — WITHOUT a reload. The page cache update
// above only drives the in-page banner/menu; the sidebar reads
// `temporaryExpiresAt` straight off the `treeDataAtom` node. The app uses
// jotai's default store (no <Provider>), so `getDefaultStore()` is the same
// store the sidebar's hooks read from. `treeModel.update` returns the same
// reference (a no-op) when the page isn't in the currently loaded tree.
const store = getDefaultStore();
const prevTree = store.get(treeDataAtom);
const nextTree = treeModel.update(prevTree, page.id, {
temporaryExpiresAt,
} as Partial<SpaceTreeNode>);
if (nextTree !== prevTree) store.set(treeDataAtom, nextTree);
queryClient.invalidateQueries({
predicate: (item) =>
["sidebar-pages"].includes(item.queryKey[0] as string),

View File

@@ -1,7 +1,7 @@
import { useAtomValue } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import React, { useCallback, useEffect, useState } from "react";
import { findBreadcrumbPath } from "@/features/page/tree/utils";
import { computeBreadcrumbState } from "./breadcrumb.utils";
import {
Button,
Anchor,
@@ -15,8 +15,12 @@ import { IconCornerDownRightDouble, IconDots } from "@tabler/icons-react";
import { Link, useParams } from "react-router-dom";
import classes from "./breadcrumb.module.css";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import {
usePageQuery,
usePageBreadcrumbsQuery,
} from "@/features/page/queries/page-query.ts";
import { extractPageSlugId } from "@/lib";
import { useMediaQuery } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
@@ -38,14 +42,29 @@ export default function Breadcrumb() {
const { data: currentPage } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
// The page's own ancestor chain, fetched independently of the lazily-built
// sidebar tree so a deep page doesn't render a blank breadcrumb for seconds
// while the tree backfills (#218).
const { data: ancestors } = usePageBreadcrumbsQuery(currentPage?.id);
const isMobile = useMediaQuery("(max-width: 48em)");
useEffect(() => {
if (treeData?.length > 0 && currentPage) {
const breadcrumb = findBreadcrumbPath(treeData, currentPage.id);
setBreadcrumbNodes(breadcrumb || null);
}
}, [currentPage?.id, treeData]);
if (!currentPage) return;
// Selection/mapping + stale-clearing live in a pure, unit-tested helper
// (#218). It resolves the correct chain when possible and, on a transient
// miss, clears a chain left over from a previously-viewed page instead of
// showing the wrong trail — while keeping a chain already resolved for THIS
// page to avoid a blank flash.
setBreadcrumbNodes((previous) =>
computeBreadcrumbState(
treeData,
ancestors as IPage[] | undefined,
currentPage.id,
previous,
),
);
}, [currentPage?.id, treeData, ancestors]);
const HiddenNodesTooltipContent = () =>
breadcrumbNodes?.slice(1, -1).map((node) => (

View File

@@ -0,0 +1,114 @@
import { describe, it, expect } from "vitest";
import {
computeBreadcrumbState,
resolveBreadcrumbNodes,
} from "./breadcrumb.utils";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { IPage } from "@/features/page/types/page.types.ts";
// Pure selection/mapping behind the breadcrumb (#218): tree-hit prefers the live
// sidebar tree, tree-miss maps the page's own ancestors, and "no data" returns
// null so the component keeps its prior state.
function treeNode(id: string, over?: Partial<SpaceTreeNode>): SpaceTreeNode {
return {
id,
slugId: `slug-${id}`,
name: `node-${id}`,
icon: null,
position: "a",
hasChildren: false,
spaceId: "space-1",
parentPageId: null,
children: [],
...over,
} as SpaceTreeNode;
}
function ancestorPage(id: string, over?: Partial<IPage>): IPage {
return {
id,
slugId: `slug-${id}`,
title: `title-${id}`,
icon: "📄",
position: "m",
spaceId: "space-1",
parentPageId: null,
hasChildren: true,
...over,
} as IPage;
}
describe("resolveBreadcrumbNodes", () => {
it("tree-hit: returns the path found in the live sidebar tree", () => {
const child = treeNode("child");
const root = treeNode("root", { hasChildren: true, children: [child] });
// findBreadcrumbPath walks the tree; the chain ends at the target page.
const result = resolveBreadcrumbNodes([root], [ancestorPage("child")], "child");
expect(result).not.toBeNull();
expect(result!.map((n) => n.id)).toEqual(["root", "child"]);
// Came from the tree, NOT the ancestor mapping (icon stays the tree's null).
expect(result![result!.length - 1].icon).toBeNull();
});
it("tree-miss: maps the page's own ancestors (title->name, hasChildren default)", () => {
// Tree has no node for the target page -> findBreadcrumbPath misses.
const unrelated = treeNode("unrelated");
const ancestors = [
ancestorPage("a", { hasChildren: true }),
ancestorPage("b", { hasChildren: undefined as any }),
];
const result = resolveBreadcrumbNodes([unrelated], ancestors, "missing-page");
expect(result).not.toBeNull();
expect(result!.map((n) => n.id)).toEqual(["a", "b"]);
// Non-trivial field transform: title -> name.
expect(result![0].name).toBe("title-a");
// hasChildren defaults to false when the ancestor row omits it.
expect(result![1].hasChildren).toBe(false);
expect(result![0].hasChildren).toBe(true);
});
it("falls back to ancestors when the tree is empty", () => {
const result = resolveBreadcrumbNodes([], [ancestorPage("a")], "a");
expect(result!.map((n) => n.id)).toEqual(["a"]);
});
it("returns null when there is no tree hit and no ancestor data", () => {
expect(resolveBreadcrumbNodes([], [], "x")).toBeNull();
expect(resolveBreadcrumbNodes(undefined, undefined, "x")).toBeNull();
expect(resolveBreadcrumbNodes(null, null, "x")).toBeNull();
});
});
describe("computeBreadcrumbState (stale-chain clearing on navigation)", () => {
it("uses a freshly resolved chain when available", () => {
const child = treeNode("B");
const root = treeNode("root", { hasChildren: true, children: [child] });
const next = computeBreadcrumbState([root], null, "B", null);
expect(next!.map((n) => n.id)).toEqual(["root", "B"]);
});
it("navigating A->B to a page absent from treeData clears the previous A chain (no stale trail)", () => {
// Previous chain ends at page A; we are now on page B, which is not yet in
// the lazily-built tree and whose ancestors have not loaded.
const previous = [treeNode("rootA"), treeNode("A")];
const next = computeBreadcrumbState([treeNode("unrelated")], undefined, "B", previous);
// Must NOT keep showing A's (clickable) chain.
expect(next).toBeNull();
});
it("keeps a chain that already ends at the current page through a transient miss", () => {
// We already resolved B once (chain ends at B); a transient miss must not
// blank it.
const previous = [treeNode("rootB"), treeNode("B")];
const next = computeBreadcrumbState([], undefined, "B", previous);
expect(next).toBe(previous);
});
it("returns null when nothing resolves and there is no previous chain", () => {
expect(computeBreadcrumbState([], undefined, "B", null)).toBeNull();
});
});

View File

@@ -0,0 +1,61 @@
import { IPage } from "@/features/page/types/page.types.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { findBreadcrumbPath, pageToTreeNode } from "@/features/page/tree/utils";
/**
* Pure selection/mapping for the breadcrumb nodes (#218). Three branches:
* 1. tree-hit — the lazily-built sidebar tree already contains this page's
* ancestor chain, so prefer it (stays live with sidebar renames/moves).
* 2. tree-miss — fall back to the page's own ancestor data so a deep page
* resolves immediately instead of rendering a blank breadcrumb for seconds
* while the tree backfills. Mapped through the canonical `pageToTreeNode`
* (title -> name, hasChildren defaulted to false).
* 3. neither — no data yet, return null (the caller decides whether to keep
* a prior chain via computeBreadcrumbState).
*/
export function resolveBreadcrumbNodes(
treeData: SpaceTreeNode[] | null | undefined,
ancestors: IPage[] | null | undefined,
pageId: string,
): SpaceTreeNode[] | null {
if (treeData && treeData.length > 0) {
const breadcrumb = findBreadcrumbPath(treeData, pageId);
if (breadcrumb) {
return breadcrumb;
}
}
if (ancestors && ancestors.length > 0) {
return ancestors.map((page) =>
pageToTreeNode(page, { hasChildren: page.hasChildren ?? false }),
);
}
return null;
}
/**
* Decide the next breadcrumb state, given the previous one. When a chain
* resolves (#218) it always wins. When nothing resolves yet, a stale chain from
* a previously-viewed page must be CLEARED rather than left showing the wrong,
* clickable trail (the reverse regression of the original blank-breadcrumb fix
* when navigating A -> B to a deep page not yet in the lazily-built tree). The
* one chain we keep through a transient miss is one that already ends at the
* current page — that means we already resolved THIS page, so keeping it avoids
* a needless blank flash without ever showing the previous page's chain.
*/
export function computeBreadcrumbState(
treeData: SpaceTreeNode[] | null | undefined,
ancestors: IPage[] | null | undefined,
pageId: string,
previous: SpaceTreeNode[] | null,
): SpaceTreeNode[] | null {
const resolved = resolveBreadcrumbNodes(treeData, ancestors, pageId);
if (resolved) {
return resolved;
}
const previousEndsAtCurrentPage =
previous != null && previous[previous.length - 1]?.id === pageId;
return previousEndsAtCurrentPage ? previous : null;
}

View File

@@ -176,8 +176,8 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
pageId: page.id,
temporary: next,
});
// Reflect the new deadline in the page cache so the menu label flips and
// any banner updates. The sidebar icon refreshes via its own query.
// Reflect the new deadline in the page cache (menu label + banner) AND in
// the sidebar tree node so its clock marker updates immediately, no reload.
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
notifications.show({
message: next

View File

@@ -32,7 +32,7 @@ import {
import { notifications } from "@mantine/notifications";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { queryClient } from "@/main.tsx";
import { buildTree } from "@/features/page/tree/utils";
import { buildTree, pageToTreeNode } from "@/features/page/tree/utils";
import { useEffect } from "react";
import { validate as isValidUuid } from "uuid";
import { useTranslation } from "react-i18next";
@@ -210,18 +210,15 @@ export function useRestorePageMutation() {
// Check if the page already exists in the tree (it shouldn't)
if (!treeModel.find(currentTree, restoredPage.id)) {
// Create the tree node data with hasChildren from backend
const nodeData: SpaceTreeNode = {
id: restoredPage.id,
slugId: restoredPage.slugId,
// Create the tree node data with hasChildren from backend. Routed
// through the canonical mapper so the field copy stays in lockstep with
// buildTree. The server NULLS `temporaryExpiresAt` on restore (a restored
// page is made permanent), so the mapper carries that null through and
// the node correctly shows no clock marker.
const nodeData: SpaceTreeNode = pageToTreeNode(restoredPage, {
name: restoredPage.title || "Untitled",
icon: restoredPage.icon,
position: restoredPage.position,
spaceId: restoredPage.spaceId,
parentPageId: restoredPage.parentPageId,
hasChildren: restoredPage.hasChildren || false,
children: [],
};
});
// Determine the parent and index
const parentId = restoredPage.parentPageId || null;
@@ -410,6 +407,11 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
slugId: data.slugId,
spaceId: data.spaceId,
title: data.title,
// Carry the death-timer deadline so a note created as temporary keeps its
// sidebar clock marker when the tree is rebuilt from this cached entry
// (buildTree → mergeRootTrees). Omitting it overwrote the optimistic/socket
// node's marker with `undefined`, hiding it until a reload.
temporaryExpiresAt: data.temporaryExpiresAt,
};
let queryKey: QueryKey = null;

View File

@@ -37,6 +37,7 @@ import {
} from "@/features/page-embed/queries/page-embed-query";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { pageToTreeNode } from "@/features/page/tree/utils";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
import classes from "@/features/page/tree/styles/tree.module.css";
@@ -130,18 +131,14 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
const currentIndex = siblings?.index ?? 0;
const newIndex = currentIndex + 1;
const treeNodeData: SpaceTreeNode = {
id: duplicatedPage.id,
slugId: duplicatedPage.slugId,
name: duplicatedPage.title,
position: duplicatedPage.position,
spaceId: duplicatedPage.spaceId,
parentPageId: duplicatedPage.parentPageId,
icon: duplicatedPage.icon,
hasChildren: duplicatedPage.hasChildren,
// Routed through the canonical mapper so the field copy stays in lockstep
// with buildTree. The server does NOT arm a death timer on duplicate (the
// copy's `temporaryExpiresAt` defaults to null = permanent), so the mapper
// carries that null through and the duplicated node correctly shows no
// clock marker — matching the server without a reload.
const treeNodeData: SpaceTreeNode = pageToTreeNode(duplicatedPage, {
canEdit: true,
children: [],
};
});
setData((prev) =>
treeModel.insert(prev, parentId, treeNodeData, newIndex),

View File

@@ -9,6 +9,7 @@ import { treeModel } from "@/features/page/tree/model/tree-model";
import type { DropOp } from "@/features/page/tree/model/tree-model.types";
import { dropOpToMovePayload } from "./drop-op-to-move-payload";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { pageToTreeNode } from "@/features/page/tree/utils";
import { IPage } from "@/features/page/types/page.types.ts";
import {
useCreatePageMutation,
@@ -139,18 +140,15 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
throw new Error("Failed to create page");
}
const newNode: SpaceTreeNode = {
id: createdPage.id,
slugId: createdPage.slugId,
// Route through the canonical mapper so the field copy (esp.
// `temporaryExpiresAt`, which shows the temporary-note clock marker on
// optimistic insert) can't drift from buildTree. `name: ""` because a
// freshly created page is untitled; `hasChildren: false` because it has no
// children yet.
const newNode: SpaceTreeNode = pageToTreeNode(createdPage, {
name: "",
position: createdPage.position,
spaceId: createdPage.spaceId,
parentPageId: createdPage.parentPageId,
hasChildren: false,
// Show the temporary-note icon immediately on optimistic insert.
temporaryExpiresAt: createdPage.temporaryExpiresAt,
children: [],
};
});
// Read latest tree at call time. Without this, callers that mutate the
// tree (e.g. lazy-load children on expand) immediately before calling
@@ -173,7 +171,22 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
// optimistic node's id IS the real created page id (createdPage.id), so
// the ids match exactly regardless of which path runs first.
setData((prev) => {
if (treeModel.find(prev, newNode.id)) return prev;
const existing = treeModel.find(prev, newNode.id);
if (existing) {
// The server `addTreeNode` broadcast won the race and already inserted
// this node. Older broadcasts could omit `temporaryExpiresAt`, leaving
// a temporary note WITHOUT its clock marker until reload; patch it on
// from the authoritative create response so the marker shows now.
if (
newNode.temporaryExpiresAt &&
!(existing as SpaceTreeNode).temporaryExpiresAt
) {
return treeModel.update(prev, newNode.id, {
temporaryExpiresAt: newNode.temporaryExpiresAt,
} as Partial<SpaceTreeNode>);
}
return prev;
}
return treeModel.insert(prev, parentId, newNode, lastIndex);
});

View File

@@ -393,6 +393,101 @@ describe("handleCreate optimistic-insert idempotency (find-then-skip)", () => {
});
});
// handleCreate race-guard temporaryExpiresAt patch: when the server's
// addTreeNode broadcast wins the race and inserts the node BEFORE the optimistic
// updater runs, the updater must not re-insert. Two sub-branches:
// (a) the node the broadcast inserted carries NO deadline (an older broadcast
// omitted it) while the authoritative create response DOES → patch the
// deadline on so the clock marker shows now, without a reload.
// (b) the existing node ALREADY has a deadline → do NOT overwrite it; return
// `prev` by reference (a no-op write).
describe("handleCreate race-guard temporaryExpiresAt patch", () => {
type TN = TreeNode<{ name: string; temporaryExpiresAt?: string | null }>;
// Mirrors the setData updater in use-tree-mutation handleCreate.
const applyOptimisticInsert = (
tree: TN[],
parentId: string | null,
node: TN,
index: number,
): TN[] => {
const existing = treeModel.find(tree, node.id) as TN | null;
if (existing) {
if (node.temporaryExpiresAt && !existing.temporaryExpiresAt) {
return treeModel.update(tree, node.id, {
temporaryExpiresAt: node.temporaryExpiresAt,
});
}
return tree;
}
return treeModel.insert(tree, parentId, node, index);
};
const fixtureTN: TN[] = [
{ id: "a", name: "A" },
{ id: "b", name: "B" },
];
const deadline = "2026-07-01T00:00:00.000Z";
it("(a) patches temporaryExpiresAt when the existing node has none + the response carries a deadline", () => {
// Server broadcast won the race and inserted the node WITHOUT a deadline.
const afterServer = treeModel.insert(fixtureTN, null, {
id: "new",
name: "",
});
expect((treeModel.find(afterServer, "new") as TN).temporaryExpiresAt).toBe(
undefined,
);
// The authoritative create response carries the deadline.
const created: TN = { id: "new", name: "", temporaryExpiresAt: deadline };
const patched = applyOptimisticInsert(
afterServer,
null,
created,
afterServer.length,
);
// A new reference (the patch wrote) and the node now has the deadline...
expect(patched).not.toBe(afterServer);
expect((treeModel.find(patched, "new") as TN).temporaryExpiresAt).toBe(
deadline,
);
// ...and still exactly one node (no duplicate re-insert).
expect(patched.filter((n) => n.id === "new")).toHaveLength(1);
});
it("(b) does NOT overwrite an existing deadline; returns prev by reference", () => {
const existingDeadline = deadline;
// The node already exists WITH a deadline (the broadcast carried it).
const afterServer = treeModel.insert(fixtureTN, null, {
id: "new",
name: "",
temporaryExpiresAt: existingDeadline,
});
// The create response carries a DIFFERENT deadline; the guard must ignore it.
const created: TN = {
id: "new",
name: "",
temporaryExpiresAt: "2099-01-01T00:00:00.000Z",
};
const after = applyOptimisticInsert(
afterServer,
null,
created,
afterServer.length,
);
// prev returned by reference (no write) and the original deadline is kept.
expect(after).toBe(afterServer);
expect((treeModel.find(after, "new") as TN).temporaryExpiresAt).toBe(
existingDeadline,
);
});
});
// moveTreeNode socket-handler semantics: the receiver must place the moved node
// by `position` (NOT index 0) and apply the `pageData` the payload carries so a
// moved node's title/icon/chevron stay correct. This mirrors the reducer in

View File

@@ -9,26 +9,45 @@ export function sortPositionKeys(keys: any[]) {
});
}
/**
* Single canonical `IPage -> SpaceTreeNode` field mapper. Every place that
* materialises a tree node from a page (buildTree, the optimistic insert in
* handleCreate, restore, duplicate) routes through here so the field copy —
* crucially `temporaryExpiresAt` — can never silently drift between sites. The
* `overrides` cover the small per-site differences (e.g. `name: ""` for an
* optimistic create, `name: title || "Untitled"` for restore, `canEdit: true`
* for duplicate). The default `temporaryExpiresAt` comes straight off the page,
* so restore (which the server nulls) stays permanent and a temporary create
* keeps its clock marker without a reload.
*/
export function pageToTreeNode(
page: IPage,
overrides?: Partial<SpaceTreeNode>,
): SpaceTreeNode {
return {
id: page.id,
slugId: page.slugId,
name: page.title,
icon: page.icon,
position: page.position,
hasChildren: page.hasChildren,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
canEdit: page.canEdit ?? page.permissions?.canEdit,
isTemplate: page.isTemplate,
temporaryExpiresAt: page.temporaryExpiresAt,
children: [],
...overrides,
};
}
export function buildTree(pages: IPage[]): SpaceTreeNode[] {
const pageMap: Record<string, SpaceTreeNode> = {};
const tree: SpaceTreeNode[] = [];
pages.forEach((page) => {
pageMap[page.id] = {
id: page.id,
slugId: page.slugId,
name: page.title,
icon: page.icon,
position: page.position,
hasChildren: page.hasChildren,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
canEdit: page.canEdit ?? page.permissions?.canEdit,
isTemplate: page.isTemplate,
temporaryExpiresAt: page.temporaryExpiresAt,
children: [],
};
pageMap[page.id] = pageToTreeNode(page);
});
// Defense-in-depth: a duplicate id in `pages` would push two references to the

View File

@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import type { IShareAlias } from "@/features/share/types/share.types";
// matchMedia / storage are stubbed globally in vitest.setup.ts.
// The mutation + query hooks reach react-query/network; the availability probe
// hits the API. Stub them so the section renders in isolation and we can drive
// the exact branches (taken name -> hint, 409 -> reassign modal).
const setMutateAsync = vi.fn();
let currentAlias: IShareAlias | null = null;
let availabilityResult: {
valid: boolean;
available: boolean;
currentPageId: string | null;
} = { valid: true, available: true, currentPageId: null };
vi.mock("@/features/share/queries/share-query.ts", () => ({
useShareAliasForPageQuery: () => ({ data: currentAlias }),
useSetShareAliasMutation: () => ({
mutateAsync: setMutateAsync,
isPending: false,
}),
useRemoveShareAliasMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}));
vi.mock("@/features/share/services/share-service.ts", () => ({
checkShareAliasAvailability: vi.fn(async () => availabilityResult),
}));
import ShareAliasSection from "./share-alias-section";
const aliasRow = (alias: string, pageId: string): IShareAlias => ({
id: `alias-${alias}`,
workspaceId: "ws-1",
alias,
pageId,
creatorId: "user-1",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
function renderSection(pageId = "page-Y") {
return render(
<MantineProvider>
<ShareAliasSection pageId={pageId} readOnly={false} />
</MantineProvider>,
);
}
describe("ShareAliasSection — taken-name handling is never a dead end", () => {
beforeEach(() => {
setMutateAsync.mockReset();
currentAlias = null;
availabilityResult = { valid: true, available: true, currentPageId: null };
});
it("shows a 'will move it here' HINT (not a terminal error) when the name belongs to another page, and keeps Save enabled", async () => {
// Page Y already owns "bee"; the user retypes a name owned by page X.
currentAlias = aliasRow("bee", "page-Y");
availabilityResult = {
valid: true,
available: false,
currentPageId: "page-X",
};
renderSection("page-Y");
const input = screen.getByPlaceholderText("my-page") as HTMLInputElement;
fireEvent.change(input, { target: { value: "test2" } });
// The reassign hint replaces the old dead-end red error.
await waitFor(
() =>
expect(
screen.getByText(
"This address is in use. Saving will move it to this page.",
),
).toBeDefined(),
{ timeout: 2000 },
);
// The old terminal "already in use" error must NOT be shown.
expect(screen.queryByText("This address is already in use")).toBeNull();
// Save stays enabled so the confirm-reassign flow can run.
const saveBtn = screen.getByRole("button", {
name: "Save",
}) as HTMLButtonElement;
expect(saveBtn.disabled).toBe(false);
});
it("opens the reassign-confirm modal on a 409 ALIAS_REASSIGN_REQUIRED (path forward, not a dead end)", async () => {
currentAlias = aliasRow("bee", "page-Y");
availabilityResult = {
valid: true,
available: false,
currentPageId: "page-X",
};
// The server rejects the un-confirmed save asking the client to confirm.
setMutateAsync.mockRejectedValueOnce({
status: 409,
response: {
status: 409,
data: {
code: "ALIAS_REASSIGN_REQUIRED",
currentPageId: "page-X",
currentPageTitle: "Alias Test Page X",
},
},
});
renderSection("page-Y");
const input = screen.getByPlaceholderText("my-page") as HTMLInputElement;
fireEvent.change(input, { target: { value: "test2" } });
const saveBtn = screen.getByRole("button", {
name: "Save",
}) as HTMLButtonElement;
await waitFor(() => expect(saveBtn.disabled).toBe(false), {
timeout: 2000,
});
fireEvent.click(saveBtn);
// First save sent WITHOUT confirmReassign.
await waitFor(() =>
expect(setMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ alias: "test2", confirmReassign: false }),
),
);
// The "Move custom address?" confirm modal must appear (the path forward).
await waitFor(() =>
expect(screen.getByText("Move custom address?")).toBeDefined(),
);
expect(screen.getByRole("button", { name: "Move here" })).toBeDefined();
// Confirming retries WITH confirmReassign: true.
setMutateAsync.mockResolvedValueOnce(aliasRow("test2", "page-Y"));
fireEvent.click(screen.getByRole("button", { name: "Move here" }));
await waitFor(() =>
expect(setMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ alias: "test2", confirmReassign: true }),
),
);
});
});

View File

@@ -1,5 +1,6 @@
import {
ActionIcon,
Box,
Button,
Group,
Modal,
@@ -7,7 +8,7 @@ import {
TextInput,
} from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl } from "@/lib/config.ts";
@@ -119,15 +120,33 @@ export default function ShareAliasSection({
};
const showInvalid = normalized.length > 0 && !isValid;
const showTaken =
isValid && !unchanged && availability && !availability.available;
// The typed name is already in use by ANOTHER page. This is NOT a dead end:
// hitting Save triggers the server's 409 `ALIAS_REASSIGN_REQUIRED` and opens
// the "Move custom address?" confirm modal that retargets the address here.
// So surface it as an informational hint (not a terminal red error) and keep
// Save enabled, instead of looking like the address is unusable.
const reassignable =
isValid && !unchanged && !!availability && !availability.available;
// The slug prefix (e.g. "docs.example.com/l/") is static for the session.
const prefixLabel = aliasPrefixLabel();
const prefixRef = useRef<HTMLDivElement>(null);
const [prefixWidth, setPrefixWidth] = useState(0);
// Measure the real rendered width of the prefix so the slug input sits flush
// next to it, instead of after an over-estimated character-counted gap.
useLayoutEffect(() => {
if (prefixRef.current) {
setPrefixWidth(Math.ceil(prefixRef.current.scrollWidth) + 1);
}
}, [prefixLabel]);
return (
<>
<Text size="sm" fw={500} mt="md">
{t("Custom address")}
</Text>
<Text size="xs" c="dimmed" mb={4}>
<Text size="xs" c="dimmed" mb={6}>
{t("A short, memorable link you can point at any shared page.")}
</Text>
@@ -159,23 +178,41 @@ export default function ShareAliasSection({
// visibly to what gets stored.
onBlur={() => setValue(normalized)}
leftSection={
<Text size="xs" c="dimmed" pl={4} style={{ whiteSpace: "nowrap" }}>
{aliasPrefixLabel()}
</Text>
<Box
ref={prefixRef}
style={{
display: "flex",
alignItems: "center",
width: "100%",
height: "100%",
paddingInline: "var(--mantine-spacing-xs)",
whiteSpace: "nowrap",
fontSize: "var(--mantine-font-size-xs)",
color: "var(--mantine-color-dimmed)",
backgroundColor: "var(--mantine-color-default-hover)",
borderTopLeftRadius: "var(--input-radius)",
borderBottomLeftRadius: "var(--input-radius)",
}}
>
{prefixLabel}
</Box>
}
leftSectionWidth={Math.min(aliasPrefixLabel().length * 7 + 12, 180)}
leftSectionWidth={prefixWidth || undefined}
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
: undefined
}
description={
reassignable
? t("This address is in use. Saving will move it to this page.")
: undefined
}
/>
<Group mt="xs" gap="xs">
<Group mt="sm" gap="xs">
<Button
size="compact-sm"
onClick={() => handleSave(false)}

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { MemoryRouter } from "react-router-dom";
// matchMedia / storage are stubbed globally in vitest.setup.ts.
// Enabling a public share must NOT silently expose the whole sub-tree (#216):
// the create call defaults includeSubPages to false. This was a one-literal,
// security-relevant default with no test — lock it.
const createMutateAsync = vi.fn(async () => ({}));
const deleteMutateAsync = vi.fn(async () => ({}));
// No existing share for this page (toggle starts OFF).
let shareData: any = undefined;
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@/features/share/queries/share-query.ts", () => ({
useCreateShareMutation: () => ({ mutateAsync: createMutateAsync }),
useDeleteShareMutation: () => ({ mutateAsync: deleteMutateAsync }),
useUpdateShareMutation: () => ({ mutateAsync: vi.fn() }),
useShareForPageQuery: () => ({ data: shareData }),
}));
vi.mock("@/features/page/queries/page-query.ts", () => ({
usePageQuery: () => ({ data: { id: "page-1", title: "Doc" } }),
}));
vi.mock("@/features/space/queries/space-query.ts", () => ({
useSpaceQuery: () => ({ data: { settings: {} } }),
}));
import ShareModal from "./share-modal";
function renderModal() {
return render(
<MemoryRouter>
<MantineProvider>
<ShareModal readOnly={false} />
</MantineProvider>
</MemoryRouter>,
);
}
describe("ShareModal — enabling a share defaults includeSubPages to false (#216)", () => {
beforeEach(() => {
createMutateAsync.mockClear();
deleteMutateAsync.mockClear();
shareData = undefined;
});
it("creates the share with includeSubPages: false when the user turns it on", async () => {
renderModal();
// Open the share popover.
fireEvent.click(screen.getByRole("button", { name: "Share" }));
// The "Share to web" toggle is the only switch in the not-yet-shared state.
const toggle = await screen.findByRole("switch");
fireEvent.click(toggle);
await waitFor(() => expect(createMutateAsync).toHaveBeenCalledTimes(1));
expect(createMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
pageId: "page-1",
includeSubPages: false,
}),
);
});
});

View File

@@ -73,7 +73,10 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
if (value) {
await createShareMutation.mutateAsync({
pageId: pageId,
includeSubPages: true,
// Opt-in: enabling a share must NOT silently expose the whole
// sub-tree (#216). Sub-pages are shared only when the user turns on
// the dedicated "Include sub-pages" toggle.
includeSubPages: false,
searchIndexing: false,
});
} else if (share && share.id) {

View File

@@ -35,9 +35,17 @@ export interface ISharedItem extends IShare {
};
}
export interface ISharedPage extends IShare {
page: IPage;
share: IShare & {
// The `/shares/page-info` (anonymous) response. Mirrors the server-side
// PublicSharePayload allowlist (#218): the server trims `page`/`share` to these
// fields exactly, so the client type must not over-declare internal metadata it
// will never receive. Keep this in sync with share-public-payload.ts.
export interface ISharedPage {
page: Pick<IPage, "id" | "slugId" | "title" | "icon" | "content">;
share: {
id: string;
key: string;
includeSubPages: boolean;
searchIndexing: boolean;
level: number;
sharedPage: { id: string; slugId: string; title: string; icon: string };
};
@@ -73,6 +81,10 @@ export type IUpdateShare = ICreateShare & { shareId: string; pageId?: string };
export interface IShareInfoInput {
pageId: string;
// The share id/key from the `/share/:shareId/p/:slug` URL. When present the
// server binds content access to this exact share (#218): a forged/mismatched
// shareId 404s instead of rendering the page off its slug alone.
shareId?: string;
}
// Vanity /l/:alias pointer.

View File

@@ -0,0 +1,79 @@
import { useState } from "react";
import { Button, Stack } from "@mantine/core";
import { IconHourglass, IconPlus } from "@tabler/icons-react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
// Space-overview quick actions: create a regular note or a temporary note
// (which auto-moves to Trash after the workspace lifetime) directly in the
// current space and open it. Mirrors the sidebar's create buttons but lives on
// the space overview screen, reusing `useTreeMutation.handleCreate` so the new
// page is optimistically inserted into the sidebar tree and navigated to.
export default function SpaceCreateNoteButtons() {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
// `handleCreate` is read unconditionally to keep hook order stable; it is
// only invoked after the permission guard below confirms a loaded space.
const { handleCreate } = useTreeMutation(space?.id ?? "");
// Which create action is in flight: drives the per-button spinner and the
// shared disabled state so a slow create round-trip cannot be double-fired.
const [pending, setPending] = useState<"regular" | "temporary" | null>(null);
// Render nothing until the space loads, or when the user cannot manage pages.
if (!space) return null;
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
return null;
}
const createNote = (temporary: boolean) => {
if (pending) return;
setPending(temporary ? "temporary" : "regular");
// handleCreate creates the page then navigates away (unmounting this
// component); the create mutation already shows a red notification on
// failure, so swallow the rejection and just clear the pending flag.
handleCreate(null, temporary ? { temporary: true } : undefined)
.catch(() => {})
.finally(() => setPending(null));
};
// Two full-width, vertically stacked buttons: a neutral regular note and an
// orange-tinted temporary note. Stacking full-width keeps the longer "New
// temporary note" label from clipping on narrow mobile widths.
return (
<Stack gap="sm">
<Button
size="md"
variant="light"
color="gray"
fullWidth
leftSection={<IconPlus size={18} />}
loading={pending === "regular"}
disabled={pending !== null}
onClick={() => createNote(false)}
>
{t("New note")}
</Button>
<Button
size="md"
variant="light"
color="orange"
fullWidth
leftSection={<IconHourglass size={18} />}
loading={pending === "temporary"}
disabled={pending !== null}
onClick={() => createNote(true)}
>
{t("New temporary note")}
</Button>
</Stack>
);
}

View File

@@ -323,4 +323,18 @@ describe("applyAddTreeNode", () => {
"child",
]);
});
it("carries temporaryExpiresAt onto the inserted node so the clock marker shows on create (no reload)", () => {
// A note created as temporary broadcasts addTreeNode with the death-timer
// deadline in its payload; the receiver's inserted node must keep it so
// space-tree-row renders the orange clock marker immediately.
const tree = roots();
const expiresAt = "2026-06-27T21:00:00.000Z";
const next = applyAddTreeNode(tree, {
parentId: null as unknown as string,
index: 0,
data: node("temp", { position: "a3", temporaryExpiresAt: expiresAt }),
});
expect(treeModel.find(next, "temp")?.temporaryExpiresAt).toBe(expiresAt);
});
});

View File

@@ -0,0 +1,407 @@
import { useEffect, useMemo, useState } from "react";
import {
Accordion,
Alert,
Badge,
Button,
Center,
Checkbox,
Group,
Loader,
Modal,
Radio,
Select,
Stack,
Text,
} from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useAiRoleCatalogBundleQuery,
useAiRoleCatalogQuery,
useImportAiRolesFromCatalogMutation,
useUpdateAiRoleFromCatalogMutation,
} from "@/features/ai-chat/queries/ai-chat-query.ts";
import {
IAiRole,
IAiRoleCatalogBundleSummary,
IAiRoleCatalogRole,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import { catalogRoleInstallState } from "@/features/ai-chat/utils/catalog-role-install-state.ts";
interface AiAgentRolesCatalogModalProps {
opened: boolean;
onClose: () => void;
// The current admin role list (full view, including `source`). Used to compute
// each catalog role's install state (import / installed / update available).
roles: IAiRole[];
}
/** How a name collision with an existing role is handled on import. */
type Conflict = "skip" | "rename";
/**
* Admin modal: browse the curated role catalog, import roles, and update an
* imported role when the catalog ships a newer version.
*
* Import is per-bundle (the endpoint takes a single bundleId). Each bundle's
* Accordion panel has its own "Import" button that imports only that bundle's
* checked roles — the simplest mapping to the one-bundle-per-call API and the
* clearest UX. Selection state is tracked per bundle.
*/
export default function AiAgentRolesCatalogModal({
opened,
onClose,
roles,
}: AiAgentRolesCatalogModalProps) {
const { t, i18n } = useTranslation();
// The user's i18n base subtag (e.g. "ru-RU" => "ru"); the preferred catalog
// language both when seeding and when reconciling against offered languages.
const baseLang = (i18n.language || "en").split("-")[0].toLowerCase();
// Fetch the catalog only while the modal is open. `language` drives both the
// catalog query (bundle names) and bundle reads (role content). Seed it
// synchronously from the base subtag so the first fetch already uses the
// user's language; the effect below still reconciles against the catalog's
// offered languages once they load.
const [language, setLanguage] = useState<string>(() => baseLang);
const catalogQuery = useAiRoleCatalogQuery(language || "en", opened);
// On name conflict: Skip (default) or Rename to a free " (N)" name.
const [conflict, setConflict] = useState<Conflict>("skip");
// The currently expanded bundle id (Accordion is single-open: one bundle's
// roles are fetched at a time).
const [expanded, setExpanded] = useState<string | null>(null);
// Per-bundle selected slugs (import-state roles checked for import).
const [selected, setSelected] = useState<Record<string, Set<string>>>({});
const languages = catalogQuery.data?.languages;
// Pick a sensible default language from the catalog once it loads: the i18n
// base subtag (e.g. "ru-RU" => "ru") if offered, else "en", else the first.
useEffect(() => {
if (!languages || languages.length === 0) return;
if (language && languages.includes(language)) return;
const preferred = languages.includes(baseLang)
? baseLang
: languages.includes("en")
? "en"
: languages[0];
setLanguage(preferred);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [languages]);
// Reset per-language UI state when the language changes (the bundle content,
// hence the install computations, are language-specific).
useEffect(() => {
setExpanded(null);
setSelected({});
}, [language]);
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Role catalog")}
size="lg"
>
<Stack>
<Select
label={t("Language")}
data={languages ?? []}
value={language || null}
onChange={(value) => value && setLanguage(value)}
allowDeselect={false}
disabled={!languages || languages.length === 0}
comboboxProps={{ withinPortal: true }}
/>
<Radio.Group
label={t("On name conflict")}
value={conflict}
onChange={(value) => setConflict(value as Conflict)}
>
<Group mt="xs">
<Radio value="skip" label={t("Skip")} />
<Radio value="rename" label={t("Rename")} />
</Group>
</Radio.Group>
{catalogQuery.isLoading && (
<Center py="lg">
<Loader size="sm" />
</Center>
)}
{catalogQuery.isError && (
<Alert
color="red"
icon={<IconAlertTriangle size={16} />}
title={t("The role catalog is unavailable")}
>
{t("Please try again later.")}
</Alert>
)}
{catalogQuery.data && catalogQuery.data.bundles.length === 0 && (
<Text size="sm" c="dimmed">
{t("No bundles available")}
</Text>
)}
{catalogQuery.data && catalogQuery.data.bundles.length > 0 && (
<Accordion
variant="separated"
value={expanded}
onChange={setExpanded}
>
{catalogQuery.data.bundles.map((bundle) => (
<BundlePanel
key={bundle.id}
bundle={bundle}
language={language}
expanded={expanded === bundle.id}
roles={roles}
conflict={conflict}
selected={selected[bundle.id]}
onToggleSlug={(slug, checked) =>
setSelected((prev) => {
const next = new Set(prev[bundle.id] ?? []);
if (checked) next.add(slug);
else next.delete(slug);
return { ...prev, [bundle.id]: next };
})
}
onSetSelected={(slugs) =>
setSelected((prev) => ({
...prev,
[bundle.id]: new Set(slugs),
}))
}
/>
))}
</Accordion>
)}
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose}>
{t("Close")}
</Button>
</Group>
</Stack>
</Modal>
);
}
interface BundlePanelProps {
bundle: IAiRoleCatalogBundleSummary;
language: string;
expanded: boolean;
roles: IAiRole[];
conflict: Conflict;
selected: Set<string> | undefined;
onToggleSlug: (slug: string, checked: boolean) => void;
onSetSelected: (slugs: string[]) => void;
}
/** One catalog bundle: its roles (fetched when expanded) + a per-bundle import. */
function BundlePanel({
bundle,
language,
expanded,
roles,
conflict,
selected,
onToggleSlug,
onSetSelected,
}: BundlePanelProps) {
const { t } = useTranslation();
// Only fetch this bundle's roles once it is actually expanded.
const bundleQuery = useAiRoleCatalogBundleQuery(
bundle.id,
language,
expanded && !!language,
);
const importMutation = useImportAiRolesFromCatalogMutation();
const updateMutation = useUpdateAiRoleFromCatalogMutation();
// Compute each catalog role's install state against the current workspace
// roles (matched by source.slug + source.language). The decision lives in the
// pure `catalogRoleInstallState` helper so it is unit-tested directly.
const computed = useMemo(() => {
const list = bundleQuery.data?.roles ?? [];
return list.map((role) => ({
role,
...catalogRoleInstallState(role, roles, language),
}));
}, [bundleQuery.data, roles, language]);
// Default-check every importable role once the bundle content arrives (unless
// the user already touched the selection for this bundle).
useEffect(() => {
if (!bundleQuery.data || selected !== undefined) return;
onSetSelected(
computed.filter((c) => c.state === "import").map((c) => c.role.slug),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bundleQuery.data]);
const importableSlugs = computed
.filter((c) => c.state === "import")
.map((c) => c.role.slug);
const checkedSlugs = importableSlugs.filter((slug) => selected?.has(slug));
function handleImport() {
importMutation.mutate({
bundleId: bundle.id,
language,
slugs: checkedSlugs,
conflict,
});
}
return (
<Accordion.Item value={bundle.id}>
<Accordion.Control>
<Stack gap={2}>
<Text fw={500}>{bundle.name}</Text>
{bundle.description && (
<Text size="xs" c="dimmed">
{bundle.description}
</Text>
)}
</Stack>
</Accordion.Control>
<Accordion.Panel>
{bundleQuery.isLoading && (
<Center py="md">
<Loader size="sm" />
</Center>
)}
{bundleQuery.isError && (
<Alert
color="red"
icon={<IconAlertTriangle size={16} />}
title={t("The role catalog is unavailable")}
>
{t("Please try again later.")}
</Alert>
)}
{bundleQuery.data && (
<Stack gap="xs">
{computed.map((entry) => (
<CatalogRoleRow
key={entry.role.slug}
role={entry.role}
state={entry.state}
checked={
entry.state === "import"
? !!selected?.has(entry.role.slug)
: false
}
onToggle={(checked) => onToggleSlug(entry.role.slug, checked)}
fromVersion={
entry.state === "update" ? entry.fromVersion : undefined
}
onUpdate={
entry.state === "update"
? () => updateMutation.mutate(entry.installed.id)
: undefined
}
updating={updateMutation.isPending}
/>
))}
<Group justify="flex-end" mt="xs">
<Button
size="xs"
onClick={handleImport}
loading={importMutation.isPending}
disabled={checkedSlugs.length === 0}
>
{t("Import")}
</Button>
</Group>
</Stack>
)}
</Accordion.Panel>
</Accordion.Item>
);
}
interface CatalogRoleRowProps {
role: IAiRoleCatalogRole;
state: "import" | "installed" | "update";
checked: boolean;
onToggle: (checked: boolean) => void;
// The installed role's current source version (only set in the "update" state).
fromVersion?: number;
onUpdate?: () => void;
updating: boolean;
}
/** A single catalog role row with its install-state affordance. */
function CatalogRoleRow({
role,
state,
checked,
onToggle,
fromVersion,
onUpdate,
updating,
}: CatalogRoleRowProps) {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap="xs" wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
{state === "import" && (
<Checkbox
checked={checked}
onChange={(event) => onToggle(event.currentTarget.checked)}
aria-label={role.name}
/>
)}
<Stack gap={2} style={{ minWidth: 0 }}>
<Text fw={500} truncate>
{role.emoji ? `${role.emoji} ` : ""}
{role.name}
</Text>
{role.description && (
<Text size="xs" c="dimmed">
{role.description}
</Text>
)}
</Stack>
</Group>
<Group gap="xs" wrap="nowrap" style={{ flex: "none" }}>
{state === "installed" && (
<Badge size="sm" variant="light" color="gray">
{t("Installed")}
</Badge>
)}
{state === "update" && (
<>
<Badge size="sm" variant="light" color="blue">
{t("v{{from}} → v{{to}}", {
from: fromVersion ?? 0,
to: role.version,
})}
</Badge>
<Button size="xs" variant="light" onClick={onUpdate} loading={updating}>
{t("Update")}
</Button>
</>
)}
</Group>
</Group>
);
}

View File

@@ -13,7 +13,12 @@ import {
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { modals } from "@mantine/modals";
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
import {
IconPackageImport,
IconPencil,
IconPlus,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role.tsx";
import {
@@ -23,6 +28,7 @@ import {
} from "@/features/ai-chat/queries/ai-chat-query.ts";
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
import AiAgentRoleForm from "./ai-agent-role-form.tsx";
import AiAgentRolesCatalogModal from "./ai-agent-roles-catalog-modal.tsx";
/**
* Admin section: list / add / edit / delete reusable agent roles. A role
@@ -39,6 +45,9 @@ export default function AiAgentRoles() {
const deleteMutation = useDeleteAiRoleMutation();
const [opened, { open, close }] = useDisclosure(false);
// Separate disclosure for the catalog (import/update) modal.
const [catalogOpened, { open: openCatalog, close: closeCatalog }] =
useDisclosure(false);
// The role being edited; undefined => the modal is in "create" mode.
const [editing, setEditing] = useState<IAiRole | undefined>(undefined);
@@ -86,14 +95,24 @@ export default function AiAgentRoles() {
/>
<Text fw={600}>{t("Agent roles")}</Text>
</Group>
<Button
leftSection={<IconPlus size={16} />}
variant="default"
size="xs"
onClick={openCreate}
>
{t("Add role")}
</Button>
<Group gap="xs" wrap="nowrap">
<Button
leftSection={<IconPackageImport size={16} />}
variant="default"
size="xs"
onClick={openCatalog}
>
{t("Import from catalog")}
</Button>
<Button
leftSection={<IconPlus size={16} />}
variant="default"
size="xs"
onClick={openCreate}
>
{t("Add role")}
</Button>
</Group>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{t(
@@ -102,9 +121,19 @@ export default function AiAgentRoles() {
</Text>
{!isLoading && (!roles || roles.length === 0) && (
<Text size="sm" c="dimmed" mt="sm">
{t("No roles configured")}
</Text>
<Group gap="sm" mt="sm" align="center">
<Text size="sm" c="dimmed">
{t("No roles configured")}
</Text>
<Button
leftSection={<IconPackageImport size={16} />}
variant="light"
size="xs"
onClick={openCatalog}
>
{t("Browse the catalog")}
</Button>
</Group>
)}
<Stack gap="xs" mt="sm">
@@ -170,6 +199,12 @@ export default function AiAgentRoles() {
{/* Remount the form per target so its internal state re-hydrates. */}
<AiAgentRoleForm key={editing?.id ?? "new"} role={editing} onClose={close} />
</Modal>
<AiAgentRolesCatalogModal
opened={catalogOpened}
onClose={closeCatalog}
roles={roles ?? []}
/>
</Paper>
);
}

View File

@@ -20,7 +20,6 @@ export interface IWorkspace {
plan?: string;
enforceMfa?: boolean;
aiSearch?: boolean;
generativeAi?: boolean;
disablePublicSharing?: boolean;
mcpEnabled?: boolean;
aiChat?: boolean;
@@ -61,7 +60,6 @@ export interface IWorkspaceApiSettings {
export interface IWorkspaceAiSettings {
search?: boolean;
generative?: boolean;
mcp?: boolean;
chat?: boolean;
dictation?: boolean;

View File

@@ -24,6 +24,9 @@ export default function SharedPage() {
const { data, isLoading, isError, error } = useSharePageQuery({
pageId: extractPageSlugId(pageSlug),
// Forward the URL's shareId so the server binds content to this share
// (#218): a forged shareId 404s instead of rendering the page off its slug.
shareId,
});
const sharedTreeData = useAtomValue(sharedTreeDataAtom);

View File

@@ -1,5 +1,6 @@
import {Container} from "@mantine/core";
import {Container, Space} from "@mantine/core";
import SpaceHomeTabs from "@/features/space/components/space-home-tabs.tsx";
import SpaceCreateNoteButtons from "@/features/space/components/space-create-note-buttons.tsx";
import {useParams} from "react-router-dom";
import {useGetSpaceBySlugQuery} from "@/features/space/queries/space-query.ts";
import {getAppName} from "@/lib/config.ts";
@@ -15,7 +16,13 @@ export default function SpaceHome() {
<title>{space?.name || 'Overview'} - {getAppName()}</title>
</Helmet>
<Container size={"900"} pt="xl">
{space && <SpaceHomeTabs/>}
{space && (
<>
<SpaceCreateNoteButtons/>
<Space h="md"/>
<SpaceHomeTabs/>
</>
)}
</Container>
</>
);

View File

@@ -205,6 +205,32 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
expect(historyQueue.add).toHaveBeenCalledTimes(1);
});
// #206 persist-6 — RED (it.failing): a momentarily-empty live Y.Doc must not
// overwrite non-empty persisted content. `onStoreDocument` empty-guards the
// LOAD path but not the STORE path, so today an empty doc (a client/agent
// glitch, a bad merge, an emptying transclusion) is written straight over the
// page and the content is wiped silently. A store-side empty-guard is a real
// behaviour change (a deliberate "select-all + delete" is also empty), so it
// is left UNFIXED pending a product decision; this documents the data-loss
// path and flips to a normal passing test the moment the guard lands.
it.failing(
'does NOT overwrite non-empty content with a momentarily-empty live doc (persist-6)',
async () => {
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
const document = ydocFor(emptyDoc);
pageRepo.findById.mockResolvedValue({
...persistedHumanPage('IGNORED'),
content: doc('IMPORTANT RICH CONTENT'),
});
await ext.onStoreDocument(buildData(document, 'user') as any);
// Desired contract: the empty incoming doc is rejected and the rich page
// survives. Today updatePage is called with the empty content (data loss).
expect(pageRepo.updatePage).not.toHaveBeenCalled();
},
);
// persist-1 — when every attempt fails the hook must NOT report a phantom
// success: no "page.updated" badge broadcast and no history snapshot for
// content that was never written.

View File

@@ -342,8 +342,8 @@ export class AiChatController {
/**
* Generate a page title from supplied note content (#199). One-shot,
* non-streaming. Gated by the workspace AI flag (reusing settings.ai.generative,
* the same flag that gates the on-page generative AI menu); returns { title }.
* non-streaming. Gated by the AI chat flag (settings.ai.chat, the same toggle
* that enables the chat agent); returns { title }.
* The endpoint NEVER writes the page — the client applies the title via the
* existing /pages/update route (which enforces edit permission), so access
* checks are not duplicated here. Throttled per user via AI_CHAT_THROTTLER.
@@ -357,9 +357,9 @@ export class AiChatController {
@AuthWorkspace() workspace: Workspace,
): Promise<{ title: string }> {
const settings = (workspace.settings ?? {}) as {
ai?: { generative?: boolean };
ai?: { chat?: boolean };
};
if (settings.ai?.generative !== true) {
if (settings.ai?.chat !== true) {
throw new ForbiddenException('AI title generation is disabled');
}
try {

View File

@@ -42,7 +42,7 @@ describe('cleanGeneratedTitle', () => {
/**
* Wiring spec for the #199 `POST /ai-chat/generate-page-title` endpoint. It must:
* gate on settings.ai.generative (403 when off), delegate to the service when on,
* gate on settings.ai.chat (403 when off), delegate to the service when on,
* rethrow HttpExceptions verbatim (e.g. AiNotConfiguredException -> 503), and map
* any other provider/transport fault to a 503. Exercised by instantiating the
* controller with hand-rolled mocks — no Nest graph, no DB.
@@ -50,7 +50,7 @@ describe('cleanGeneratedTitle', () => {
describe('AiChatController.generatePageTitle', () => {
const enabledWorkspace = {
id: 'ws1',
settings: { ai: { generative: true } },
settings: { ai: { chat: true } },
} as unknown as Workspace;
function makeController(generate: jest.Mock) {
@@ -64,7 +64,7 @@ describe('AiChatController.generatePageTitle', () => {
return { controller, aiChatService };
}
it('forbids when the generative AI flag is off', async () => {
it('forbids when the AI chat flag is off', async () => {
const generate = jest.fn();
const { controller } = makeController(generate);
const disabled = { id: 'ws1', settings: {} } as unknown as Workspace;
@@ -74,12 +74,12 @@ describe('AiChatController.generatePageTitle', () => {
expect(generate).not.toHaveBeenCalled();
});
it('forbids when settings.ai.generative is anything but exactly true', async () => {
it('forbids when settings.ai.chat is anything but exactly true', async () => {
const generate = jest.fn();
const { controller } = makeController(generate);
const ws = {
id: 'ws1',
settings: { ai: { generative: 'yes' } },
settings: { ai: { chat: 'yes' } },
} as unknown as Workspace;
await expect(
controller.generatePageTitle({ content: 'body' }, ws),

View File

@@ -0,0 +1,157 @@
import { McpClientsService } from './mcp-clients.service';
/**
* #204 (Phase 1, highest-value MCP gap) — external MCP client lease / refcount /
* eviction lifecycle.
*
* `toolsFor` hands the streaming turn a release handle; the real transports must
* be closed EXACTLY once and only when (a) the cache entry has been evicted AND
* (b) no turn still leases it. The bugs this guards against:
* - leak: an evicted entry whose clients are never closed (refCount stuck > 0);
* - premature close: a TTL/CRUD eviction closing a client a turn is still
* executing tool calls against;
* - double close: a release handle closing the same client more than once.
*
* The private `buildEntry` is stubbed so no real network/MCP connection happens;
* we drive only the lease bookkeeping in `toolsFor` / `release` / `evict` /
* `invalidate`, which is the untested surface.
*/
describe('McpClientsService lease/refcount/eviction', () => {
type FakeClient = { tools: () => Promise<any>; close: jest.Mock };
function fakeClient(): FakeClient {
return {
tools: async () => ({}),
close: jest.fn().mockResolvedValue(undefined),
};
}
// Minimal CacheEntry the service's lease logic operates on.
function makeEntry(clients: FakeClient[]) {
const timer = setTimeout(() => {}, 60_000);
timer.unref?.();
return {
tools: {},
clients,
outcomes: [],
instructions: [],
expiresAt: Date.now() + 60_000,
refCount: 0,
evicted: false,
closed: false,
timer,
} as any;
}
let service: McpClientsService;
beforeEach(() => {
service = new McpClientsService({} as any, {} as any);
});
function stubBuild(entry: any) {
jest.spyOn(service as any, 'buildEntry').mockResolvedValue(entry);
}
it('leases on toolsFor and keeps the client warm (no close) on release', async () => {
const client = fakeClient();
const entry = makeEntry([client]);
stubBuild(entry);
const lease = await service.toolsFor('ws-1');
expect(entry.refCount).toBe(1);
await lease.clients[0].close();
// Released but NOT evicted: the cached entry stays warm for reuse, so the
// transport must NOT be closed yet.
expect(entry.refCount).toBe(0);
expect(client.close).not.toHaveBeenCalled();
});
it('defers close when an entry is evicted while still leased, then closes once on release', async () => {
const client = fakeClient();
const entry = makeEntry([client]);
stubBuild(entry);
const lease = await service.toolsFor('ws-2');
(service as any).evict(entry);
// Evicted under an active lease: close is deferred to the last release.
expect(entry.evicted).toBe(true);
expect(client.close).not.toHaveBeenCalled();
await lease.clients[0].close();
expect(client.close).toHaveBeenCalledTimes(1);
expect(entry.closed).toBe(true);
});
it('shares one entry across concurrent leases; closes only after the LAST release', async () => {
const client = fakeClient();
const entry = makeEntry([client]);
stubBuild(entry);
const lease1 = await service.toolsFor('ws-3');
const lease2 = await service.toolsFor('ws-3');
expect(entry.refCount).toBe(2);
(service as any).evict(entry);
await lease1.clients[0].close();
// One lease remains: a stream could still be running — must stay open.
expect(entry.refCount).toBe(1);
expect(client.close).not.toHaveBeenCalled();
await lease2.clients[0].close();
expect(entry.refCount).toBe(0);
expect(client.close).toHaveBeenCalledTimes(1);
});
it('release is idempotent: closing the same handle twice decrements once and closes once', async () => {
const client = fakeClient();
const entry = makeEntry([client]);
stubBuild(entry);
const lease = await service.toolsFor('ws-4');
(service as any).evict(entry);
await lease.clients[0].close();
await lease.clients[0].close();
expect(entry.refCount).toBe(0); // not -1
expect(client.close).toHaveBeenCalledTimes(1);
});
it('evicting an unleased entry closes its clients immediately', async () => {
const client = fakeClient();
const entry = makeEntry([client]);
stubBuild(entry);
const built = await (service as any).getOrBuildEntry('ws-5');
expect(built.refCount).toBe(0);
(service as any).evict(entry);
expect(client.close).toHaveBeenCalledTimes(1);
expect(entry.closed).toBe(true);
});
it('invalidate (TTL/CRUD) does NOT close a client that a turn still leases', async () => {
const client = fakeClient();
const entry = makeEntry([client]);
stubBuild(entry);
const lease = await service.toolsFor('ws-6');
expect(entry.refCount).toBe(1);
service.invalidate('ws-6');
// invalidate evicts asynchronously once the build promise resolves.
await Promise.resolve();
await Promise.resolve();
expect(entry.evicted).toBe(true);
// Still leased: the mid-turn eviction must not pull the transport.
expect(client.close).not.toHaveBeenCalled();
await lease.clients[0].close();
expect(client.close).toHaveBeenCalledTimes(1);
});
});

View File

@@ -39,6 +39,10 @@ describe('AiAgentRolesController admin gate', () => {
create: jest.fn().mockResolvedValue({ id: 'r1' }),
update: jest.fn().mockResolvedValue({ id: 'r1' }),
remove: jest.fn().mockResolvedValue({ success: true }),
getCatalog: jest.fn().mockResolvedValue({ languages: [], bundles: [] }),
getCatalogBundle: jest.fn().mockResolvedValue({ roles: [] }),
importFromCatalog: jest.fn().mockResolvedValue({ created: 0 }),
updateFromCatalog: jest.fn().mockResolvedValue({ updated: false }),
};
const controller = new AiAgentRolesController(
rolesService as never,
@@ -109,6 +113,90 @@ describe('AiAgentRolesController admin gate', () => {
});
});
// Catalog routes (browse + import) are ALL admin-only: a non-admin caller must
// get ForbiddenException with the service untouched; an admin delegates with
// the right arguments (import/update-from-catalog carry workspace.id).
describe('catalog routes admin gate', () => {
const catalogDto = { language: 'en' } as never;
const bundleDto = { bundleId: 'general', language: 'en' } as never;
const importDto = {
bundleId: 'general',
language: 'en',
conflict: 'skip',
} as never;
const updateDto = { id: 'r1' } as never;
describe('non-admin is rejected and the service is NOT called', () => {
it('catalog', async () => {
const { controller, rolesService } = makeController(false);
await expect(
controller.catalog(catalogDto, user, workspace),
).rejects.toBeInstanceOf(ForbiddenException);
expect(rolesService.getCatalog).not.toHaveBeenCalled();
});
it('catalog/bundle', async () => {
const { controller, rolesService } = makeController(false);
await expect(
controller.catalogBundle(bundleDto, user, workspace),
).rejects.toBeInstanceOf(ForbiddenException);
expect(rolesService.getCatalogBundle).not.toHaveBeenCalled();
});
it('import', async () => {
const { controller, rolesService } = makeController(false);
await expect(
controller.import(importDto, user, workspace),
).rejects.toBeInstanceOf(ForbiddenException);
expect(rolesService.importFromCatalog).not.toHaveBeenCalled();
});
it('update-from-catalog', async () => {
const { controller, rolesService } = makeController(false);
await expect(
controller.updateFromCatalog(updateDto, user, workspace),
).rejects.toBeInstanceOf(ForbiddenException);
expect(rolesService.updateFromCatalog).not.toHaveBeenCalled();
});
});
describe('admin delegates to the service', () => {
it('catalog passes the requested language', async () => {
const { controller, rolesService } = makeController(true);
await controller.catalog(catalogDto, user, workspace);
expect(rolesService.getCatalog).toHaveBeenCalledWith('en');
});
it('catalog/bundle passes bundleId + language', async () => {
const { controller, rolesService } = makeController(true);
await controller.catalogBundle(bundleDto, user, workspace);
expect(rolesService.getCatalogBundle).toHaveBeenCalledWith(
'general',
'en',
);
});
it('import passes workspace.id + user.id + dto', async () => {
const { controller, rolesService } = makeController(true);
await controller.import(importDto, user, workspace);
expect(rolesService.importFromCatalog).toHaveBeenCalledWith(
'ws-1',
'u1',
importDto,
);
});
it('update-from-catalog passes workspace.id + dto', async () => {
const { controller, rolesService } = makeController(true);
await controller.updateFromCatalog(updateDto, user, workspace);
expect(rolesService.updateFromCatalog).toHaveBeenCalledWith(
'ws-1',
updateDto,
);
});
});
});
describe('list (member-reachable)', () => {
it('non-admin reaches list and the service is asked for the picker view (isAdmin=false)', async () => {
const { controller, rolesService } = makeController(false);

View File

@@ -22,6 +22,12 @@ import {
CreateAgentRoleDto,
UpdateAgentRoleDto,
} from './dto/agent-role.dto';
import {
CatalogBundleDto,
CatalogQueryDto,
ImportFromCatalogDto,
UpdateFromCatalogDto,
} from './dto/agent-role-catalog.dto';
/** Path/body param for the per-role routes (update/delete). */
class AgentRoleIdDto {
@@ -113,4 +119,54 @@ export class AiAgentRolesController {
this.assertAdmin(user, workspace);
return this.rolesService.remove(workspace.id, idDto.id);
}
// --- Catalog (admin-only): browse + import + update imported roles. ---
/** Browse the curated catalog (localized to dto.language). */
@HttpCode(HttpStatus.OK)
@Post('catalog')
async catalog(
@Body() dto: CatalogQueryDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.rolesService.getCatalog(dto.language);
}
/** Open one catalog bundle in a language (role content + versions). */
@HttpCode(HttpStatus.OK)
@Post('catalog/bundle')
async catalogBundle(
@Body() dto: CatalogBundleDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.rolesService.getCatalogBundle(dto.bundleId, dto.language);
}
/** Import roles from a catalog bundle into the workspace. */
@HttpCode(HttpStatus.OK)
@Post('import')
async import(
@Body() dto: ImportFromCatalogDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.rolesService.importFromCatalog(workspace.id, user.id, dto);
}
/** Update an already-imported role from its catalog source. */
@HttpCode(HttpStatus.OK)
@Post('update-from-catalog')
async updateFromCatalog(
@Body() dto: UpdateFromCatalogDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.rolesService.updateFromCatalog(workspace.id, dto);
}
}

View File

@@ -1,16 +1,19 @@
import { Module } from '@nestjs/common';
import { AiAgentRolesController } from './ai-agent-roles.controller';
import { AiAgentRolesService } from './ai-agent-roles.service';
import { AiAgentRolesCatalogProvider } from './catalog/ai-agent-roles-catalog.provider';
/**
* Agent roles unit (v1). Admin CRUD + member-visible listing for the chat
* role picker. AiAgentRoleRepo (DatabaseModule, global) and
* WorkspaceAbilityFactory (CaslModule, global) are resolved without explicit
* imports. The stream-time role resolution + model override live in
* AiChatService / AiService; this module only hosts the management API.
* role picker, plus the admin catalog (browse/import/update). AiAgentRoleRepo
* (DatabaseModule, global), WorkspaceAbilityFactory (CaslModule, global) and
* EnvironmentService (EnvironmentModule, global — used by the catalog provider)
* are resolved without explicit imports. The stream-time role resolution +
* model override live in AiChatService / AiService; this module only hosts the
* management API.
*/
@Module({
controllers: [AiAgentRolesController],
providers: [AiAgentRolesService],
providers: [AiAgentRolesService, AiAgentRolesCatalogProvider],
})
export class AiAgentRolesModule {}

View File

@@ -1,4 +1,9 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import {
BadGatewayException,
BadRequestException,
ConflictException,
Logger,
} from '@nestjs/common';
import { AiAgentRolesService } from './ai-agent-roles.service';
import type { AiAgentRole } from '@docmost/db/types/entity.types';
import type {
@@ -27,12 +32,22 @@ describe('AiAgentRolesService guards', () => {
enabled: true,
autoStart: true,
launchMessage: null,
source: null,
createdAt: new Date(),
updatedAt: new Date(),
...over,
} as AiAgentRole;
}
// A stubbed catalog provider; the CRUD tests never reach it (they exercise
// create/update/remove/list only), so the methods just reject if hit.
function makeCatalog() {
return {
fetchIndex: jest.fn(),
fetchBundle: jest.fn(),
};
}
function makeService(opts: { existing?: AiAgentRole | undefined } = {}) {
const repo = {
findById: jest.fn().mockResolvedValue(opts.existing),
@@ -41,8 +56,9 @@ describe('AiAgentRolesService guards', () => {
softDelete: jest.fn().mockResolvedValue(undefined),
listByWorkspace: jest.fn().mockResolvedValue([]),
};
const service = new AiAgentRolesService(repo as never);
return { service, repo };
const catalog = makeCatalog();
const service = new AiAgentRolesService(repo as never, catalog as never);
return { service, repo, catalog };
}
describe('update', () => {
@@ -163,6 +179,7 @@ describe('AiAgentRolesService guards', () => {
enabled: false,
autoStart: true,
launchMessage: null,
source: null,
createdAt,
updatedAt,
});
@@ -397,7 +414,7 @@ describe('AiAgentRolesService guards', () => {
softDelete: jest.fn(),
listByWorkspace: jest.fn().mockResolvedValue(rows),
};
const service = new AiAgentRolesService(repo as never);
const service = new AiAgentRolesService(repo as never, makeCatalog() as never);
return { service, repo };
}
@@ -461,4 +478,630 @@ describe('AiAgentRolesService guards', () => {
).rejects.toBeInstanceOf(ConflictException);
});
});
// ---------------------------------------------------------------------------
// Catalog: import (skip / rename / already-installed) and update reconciliation
// against a MOCKED catalog provider + mocked repo (mirrors the CRUD style).
// ---------------------------------------------------------------------------
describe('importFromCatalog', () => {
function catalogRole(over: Record<string, unknown> = {}) {
return {
slug: 'researcher',
name: 'Researcher',
instructions: 'be a researcher',
...over,
};
}
function makeImportService(opts: {
indexRoles?: { slug: string; version: number }[];
bundleRoles?: Record<string, unknown>[];
existing?: AiAgentRole[];
}) {
const index = {
schemaVersion: 1,
bundles: [
{
id: 'general',
name: { en: 'General' },
languages: ['en'],
roles: opts.indexRoles ?? [{ slug: 'researcher', version: 3 }],
},
],
};
const bundle = {
schemaVersion: 1,
language: 'en',
roles: opts.bundleRoles ?? [catalogRole()],
};
const repo = {
findById: jest.fn(),
insert: jest.fn().mockImplementation((v) => Promise.resolve(makeRow(v))),
update: jest.fn().mockResolvedValue(undefined),
softDelete: jest.fn(),
listByWorkspace: jest.fn().mockResolvedValue(opts.existing ?? []),
};
const catalog = {
fetchIndex: jest.fn().mockResolvedValue(index),
fetchBundle: jest.fn().mockResolvedValue(bundle),
};
const service = new AiAgentRolesService(repo as never, catalog as never);
return { service, repo, catalog };
}
const dto = (over: Record<string, unknown> = {}) =>
({
bundleId: 'general',
language: 'en',
conflict: 'skip',
...over,
}) as never;
it('inserts a new role with source { slug, language, version } from the index', async () => {
const { service, repo } = makeImportService({});
const res = await service.importFromCatalog('ws-1', 'u1', dto());
expect(res).toMatchObject({ created: 1, skipped: 0, renamed: 0 });
expect(res.errors).toEqual([]);
const values = repo.insert.mock.calls[0][0];
expect(values.source).toEqual({
slug: 'researcher',
language: 'en',
version: 3,
});
expect(values.enabled).toBe(true);
});
it('already-installed catalog slug => skipped (no insert)', async () => {
const existing = [
makeRow({
id: 'r-existing',
name: 'Old researcher',
source: { slug: 'researcher', language: 'en', version: 1 } as never,
}),
];
const { service, repo } = makeImportService({ existing });
const res = await service.importFromCatalog('ws-1', 'u1', dto());
expect(res).toMatchObject({ created: 0, skipped: 1, renamed: 0 });
expect(repo.insert).not.toHaveBeenCalled();
});
it('same slug installed in a DIFFERENT language => NOT skipped (separate install)', async () => {
// Installed as `ru`; importing the `en` variant of the same slug must
// still import (dedup key is slug+language, matching the client UI).
const existing = [
makeRow({
id: 'r-ru',
name: 'Исследователь',
source: { slug: 'researcher', language: 'ru', version: 1 } as never,
}),
];
const { service, repo } = makeImportService({ existing });
const res = await service.importFromCatalog('ws-1', 'u1', dto());
expect(res).toMatchObject({ created: 1, skipped: 0, renamed: 0 });
expect(repo.insert).toHaveBeenCalledTimes(1);
expect(repo.insert.mock.calls[0][0].source).toEqual({
slug: 'researcher',
language: 'en',
version: 3,
});
});
it('name collision + conflict:skip => skipped (no insert)', async () => {
const existing = [makeRow({ id: 'r-x', name: 'Researcher' })];
const { service, repo } = makeImportService({ existing });
const res = await service.importFromCatalog(
'ws-1',
'u1',
dto({ conflict: 'skip' }),
);
expect(res).toMatchObject({ created: 0, skipped: 1, renamed: 0 });
expect(repo.insert).not.toHaveBeenCalled();
});
it('name collision + conflict:rename => inserts under " (2)"', async () => {
const existing = [makeRow({ id: 'r-x', name: 'Researcher' })];
const { service, repo } = makeImportService({ existing });
const res = await service.importFromCatalog(
'ws-1',
'u1',
dto({ conflict: 'rename' }),
);
expect(res).toMatchObject({ created: 1, skipped: 0, renamed: 1 });
expect(repo.insert.mock.calls[0][0].name).toBe('Researcher (2)');
});
it('dto.slugs filters; an unknown slug becomes an error entry', async () => {
const { service, repo } = makeImportService({
bundleRoles: [catalogRole()],
});
const res = await service.importFromCatalog(
'ws-1',
'u1',
dto({ slugs: ['researcher', 'ghost'] }),
);
expect(res.created).toBe(1);
expect(res.errors).toEqual([
{ slug: 'ghost', message: 'Role not found in catalog bundle' },
]);
expect(repo.insert).toHaveBeenCalledTimes(1);
});
it('insert unique-violation (23505) is recorded as an error, import continues', async () => {
const { service, repo } = makeImportService({
bundleRoles: [
catalogRole({ slug: 'a', name: 'A' }),
catalogRole({ slug: 'b', name: 'B' }),
],
indexRoles: [
{ slug: 'a', version: 1 },
{ slug: 'b', version: 1 },
],
});
repo.insert
.mockRejectedValueOnce({ code: '23505' })
.mockImplementationOnce((v) => Promise.resolve(makeRow(v)));
const res = await service.importFromCatalog('ws-1', 'u1', dto());
expect(res.created).toBe(1);
expect(res.errors).toEqual([
{ slug: 'a', message: 'A role with this name already exists' },
]);
});
it('source-uniqueness 23505 (concurrent import of same slug+language) => skipped, NOT an error, batch continues', async () => {
// Two parallel imports of the same bundle each build installedKeys from a
// stale snapshot, so both reach the insert for slug 'a'. The DB partial
// unique index on (workspace, source->>slug, source->>language) rejects the
// loser with a 23505 carrying the source-index constraint name. That must
// be treated as "already installed" (skip), not a per-role error, and the
// rest of the batch (slug 'b') must still import.
const { service, repo } = makeImportService({
bundleRoles: [
catalogRole({ slug: 'a', name: 'A' }),
catalogRole({ slug: 'b', name: 'B' }),
],
indexRoles: [
{ slug: 'a', version: 1 },
{ slug: 'b', version: 1 },
],
});
// The kysely-postgres-js driver surfaces the violated constraint on
// `constraint_name` (not node-postgres' `.constraint`), matching prod.
const sourceRace = Object.assign(new Error('duplicate key'), {
code: '23505',
constraint_name: 'ai_agent_roles_workspace_source_unique',
});
repo.insert
.mockRejectedValueOnce(sourceRace)
.mockImplementationOnce((v) => Promise.resolve(makeRow(v)));
const res = await service.importFromCatalog('ws-1', 'u1', dto());
// 'a' converged on the concurrent install (skip); 'b' imported; no errors.
expect(res).toMatchObject({ created: 1, skipped: 1, renamed: 0 });
expect(res.errors).toEqual([]);
// Both inserts were attempted (the batch did not abort on the 23505).
expect(repo.insert).toHaveBeenCalledTimes(2);
});
it('non-unique insert error => generic message, root cause logged, import continues', async () => {
const logSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
try {
const { service, repo } = makeImportService({
bundleRoles: [
catalogRole({ slug: 'a', name: 'A' }),
catalogRole({ slug: 'b', name: 'B' }),
],
indexRoles: [
{ slug: 'a', version: 1 },
{ slug: 'b', version: 1 },
],
});
// A non-23505 failure (e.g. a not-null violation) on the first insert.
const boom = Object.assign(new Error('null value in column'), {
code: '23502',
});
repo.insert
.mockRejectedValueOnce(boom)
.mockImplementationOnce((v) => Promise.resolve(makeRow(v)));
const res = await service.importFromCatalog('ws-1', 'u1', dto());
// The generic (non-409) user-facing message; the second role still imports.
expect(res.created).toBe(1);
expect(res.errors).toEqual([
{ slug: 'a', message: 'Failed to import role' },
]);
// The root cause was logged with the slug for diagnosis.
expect(logSpy).toHaveBeenCalledTimes(1);
expect(String(logSpy.mock.calls[0][0])).toContain('slug=a');
} finally {
logSpy.mockRestore();
}
});
it('bundleId absent from the index => BadGateway (no insert)', async () => {
// The requested bundle is not listed in the fetched index (a stale client
// or an index/bundle drift); the import must surface a 502 rather than
// silently doing nothing or dereferencing a missing meta.
const { service, repo } = makeImportService({});
await expect(
service.importFromCatalog('ws-1', 'u1', dto({ bundleId: 'missing' })),
).rejects.toBeInstanceOf(BadGatewayException);
expect(repo.insert).not.toHaveBeenCalled();
});
});
describe('updateFromCatalog', () => {
function makeUpdateService(opts: {
role?: AiAgentRole;
indexBundles?: unknown[];
bundleRoles?: Record<string, unknown>[];
others?: AiAgentRole[];
}) {
const index = {
schemaVersion: 1,
bundles: opts.indexBundles ?? [
{
id: 'general',
name: { en: 'General' },
languages: ['en'],
roles: [{ slug: 'researcher', version: 5 }],
},
],
};
const bundle = {
schemaVersion: 1,
language: 'en',
roles: opts.bundleRoles ?? [
{ slug: 'researcher', name: 'Researcher v5', instructions: 'new' },
],
};
const repo = {
findById: jest.fn().mockResolvedValue(opts.role),
insert: jest.fn(),
update: jest.fn().mockResolvedValue(undefined),
softDelete: jest.fn(),
listByWorkspace: jest.fn().mockResolvedValue(opts.others ?? []),
};
const catalog = {
fetchIndex: jest.fn().mockResolvedValue(index),
fetchBundle: jest.fn().mockResolvedValue(bundle),
};
const service = new AiAgentRolesService(repo as never, catalog as never);
return { service, repo, catalog };
}
const imported = (version: number, over: Partial<AiAgentRole> = {}) =>
makeRow({
id: 'r1',
name: 'Researcher',
source: { slug: 'researcher', language: 'en', version } as never,
...over,
});
it('role not imported from catalog (source null) => BadRequest', async () => {
const { service } = makeUpdateService({ role: makeRow({ source: null }) });
await expect(
service.updateFromCatalog('ws-1', { id: 'r1' } as never),
).rejects.toBeInstanceOf(BadRequestException);
});
it('role not found => BadRequest', async () => {
const { service } = makeUpdateService({ role: undefined });
await expect(
service.updateFromCatalog('ws-1', { id: 'r1' } as never),
).rejects.toBeInstanceOf(BadRequestException);
});
it('catalog version <= source.version => up-to-date (no update)', async () => {
const { service, repo } = makeUpdateService({ role: imported(5) });
const res = await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
expect(res).toEqual({ updated: false, reason: 'up-to-date' });
expect(repo.update).not.toHaveBeenCalled();
});
it('slug no longer listed in any bundle => not-in-catalog', async () => {
const { service, repo } = makeUpdateService({
role: imported(1),
indexBundles: [
{
id: 'general',
name: { en: 'General' },
languages: ['en'],
roles: [{ slug: 'other', version: 9 }],
},
],
});
const res = await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
expect(res).toEqual({ updated: false, reason: 'not-in-catalog' });
expect(repo.update).not.toHaveBeenCalled();
});
it('source.language no longer offered by the bundle => language-unavailable', async () => {
const { service, repo } = makeUpdateService({
role: imported(1, {
source: { slug: 'researcher', language: 'ru', version: 1 } as never,
}),
indexBundles: [
{
id: 'general',
name: { en: 'General' },
languages: ['en'],
roles: [{ slug: 'researcher', version: 5 }],
},
],
});
const res = await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
expect(res).toEqual({ updated: false, reason: 'language-unavailable' });
expect(repo.update).not.toHaveBeenCalled();
});
it('newer version => updates content + bumps source.version, returns versions', async () => {
const role = imported(1);
const { service, repo } = makeUpdateService({ role });
// The post-update re-fetch returns the bumped row.
repo.findById
.mockResolvedValueOnce(role)
.mockResolvedValueOnce(
imported(5, { name: 'Researcher v5', instructions: 'new' }),
);
const res = await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
expect(res).toMatchObject({
updated: true,
fromVersion: 1,
toVersion: 5,
});
const patch = repo.update.mock.calls[0][2];
expect(patch.source).toEqual({
slug: 'researcher',
language: 'en',
version: 5,
});
expect(patch.name).toBe('Researcher v5');
// enabled is never touched by an update-from-catalog.
expect('enabled' in patch).toBe(false);
});
it('slug listed in the index but missing from the bundle file => not-in-catalog', async () => {
// Index/bundle drift: the index still advertises a newer `researcher`
// (v5 > installed v1) in an offered language, but the fetched bundle file
// no longer contains that slug. The update must no-op as not-in-catalog,
// not throw or write a half-resolved role.
const { service, repo } = makeUpdateService({
role: imported(1),
bundleRoles: [
{ slug: 'someone-else', name: 'Other', instructions: 'x' },
],
});
const res = await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
expect(res).toEqual({ updated: false, reason: 'not-in-catalog' });
expect(repo.update).not.toHaveBeenCalled();
});
it('new catalog name collides with another live role => keeps current name', async () => {
const role = imported(1);
const other = makeRow({ id: 'r2', name: 'Researcher v5' });
const { service, repo } = makeUpdateService({ role, others: [role, other] });
repo.findById
.mockResolvedValueOnce(role)
.mockResolvedValueOnce(imported(5));
await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
// The colliding catalog name is dropped; the current name is kept.
expect(repo.update.mock.calls[0][2].name).toBe('Researcher');
});
});
// ---------------------------------------------------------------------------
// Catalog browse (getCatalog / getCatalogBundle) against a MOCKED provider.
// Covers the localized() three-tier fallback (requested lang -> en -> first ->
// null), the sorted union of bundle languages, the missing-bundle BadGateway,
// and the role-version default.
// ---------------------------------------------------------------------------
describe('getCatalog', () => {
function makeBrowseService(index: unknown) {
const repo = {
findById: jest.fn(),
insert: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
listByWorkspace: jest.fn(),
};
const catalog = {
fetchIndex: jest.fn().mockResolvedValue(index),
fetchBundle: jest.fn(),
};
const service = new AiAgentRolesService(repo as never, catalog as never);
return { service, catalog };
}
it('returns the sorted union of every bundle language', async () => {
const { service } = makeBrowseService({
schemaVersion: 1,
bundles: [
{
id: 'a',
name: { en: 'A' },
languages: ['ru', 'en'],
roles: [],
},
{
id: 'b',
name: { en: 'B' },
languages: ['en', 'de'],
roles: [],
},
],
});
const res = await service.getCatalog('en');
expect(res.languages).toEqual(['de', 'en', 'ru']);
});
it('localized name uses the requested language when present', async () => {
const { service } = makeBrowseService({
schemaVersion: 1,
bundles: [
{
id: 'a',
name: { en: 'General', ru: 'Общие' },
description: { en: 'desc-en', ru: 'desc-ru' },
languages: ['en', 'ru'],
roles: [{ slug: 'researcher', version: 2 }],
},
],
});
const res = await service.getCatalog('ru');
expect(res.bundles[0]).toMatchObject({
id: 'a',
name: 'Общие',
description: 'desc-ru',
languages: ['en', 'ru'],
roles: [{ slug: 'researcher', version: 2 }],
});
});
it('localized name falls back to en when the requested language is missing', async () => {
const { service } = makeBrowseService({
schemaVersion: 1,
bundles: [
{
id: 'a',
name: { en: 'General', ru: 'Общие' },
languages: ['en', 'ru'],
roles: [],
},
],
});
const res = await service.getCatalog('fr');
expect(res.bundles[0].name).toBe('General');
});
it('localized name falls back to the first available locale when en is absent', async () => {
const { service } = makeBrowseService({
schemaVersion: 1,
bundles: [
{
id: 'a',
name: { ru: 'Общие', de: 'Allgemein' },
languages: ['ru', 'de'],
roles: [],
},
],
});
const res = await service.getCatalog('fr');
// Neither 'fr' nor 'en' is present -> first available value.
expect(res.bundles[0].name).toBe('Общие');
});
it('empty name map => falls back to the bundle id; absent description => null', async () => {
const { service } = makeBrowseService({
schemaVersion: 1,
bundles: [
{
id: 'a',
name: {},
languages: ['en'],
roles: [],
},
],
});
const res = await service.getCatalog('en');
expect(res.bundles[0].name).toBe('a');
expect(res.bundles[0].description).toBeNull();
});
});
describe('getCatalogBundle', () => {
function makeBundleService(opts: {
index: unknown;
bundle: unknown;
}) {
const repo = {
findById: jest.fn(),
insert: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
listByWorkspace: jest.fn(),
};
const catalog = {
fetchIndex: jest.fn().mockResolvedValue(opts.index),
fetchBundle: jest.fn().mockResolvedValue(opts.bundle),
};
const service = new AiAgentRolesService(repo as never, catalog as never);
return { service, catalog };
}
const index = {
schemaVersion: 1,
bundles: [
{
id: 'general',
name: { en: 'General' },
languages: ['en'],
roles: [{ slug: 'researcher', version: 4 }],
},
],
};
it('missing bundle in the index => BadGateway', async () => {
const { service, catalog } = makeBundleService({
index,
bundle: { schemaVersion: 1, language: 'en', roles: [] },
});
await expect(
service.getCatalogBundle('ghost', 'en'),
).rejects.toBeInstanceOf(BadGatewayException);
expect(catalog.fetchBundle).not.toHaveBeenCalled();
});
it('maps role content with the version taken from the index', async () => {
const { service } = makeBundleService({
index,
bundle: {
schemaVersion: 1,
language: 'en',
roles: [
{
slug: 'researcher',
name: 'Researcher',
instructions: 'be a researcher',
emoji: '🔬',
autoStart: false,
launchMessage: 'go',
},
],
},
});
const res = await service.getCatalogBundle('general', 'en');
expect(res).toMatchObject({ bundleId: 'general', language: 'en' });
expect(res.roles[0]).toEqual({
slug: 'researcher',
emoji: '🔬',
name: 'Researcher',
description: null,
instructions: 'be a researcher',
autoStart: false,
launchMessage: 'go',
version: 4,
});
});
it('role absent from the index meta => version defaults to 1; autoStart defaults to true', async () => {
const { service } = makeBundleService({
index,
bundle: {
schemaVersion: 1,
language: 'en',
roles: [
{ slug: 'newcomer', name: 'Newcomer', instructions: 'hi' },
],
},
});
const res = await service.getCatalogBundle('general', 'en');
expect(res.roles[0]).toMatchObject({
slug: 'newcomer',
version: 1,
autoStart: true,
emoji: null,
launchMessage: null,
});
});
});
});

View File

@@ -1,12 +1,24 @@
import {
BadGatewayException,
BadRequestException,
ConflictException,
Injectable,
Logger,
} from '@nestjs/common';
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
import { AiAgentRole } from '@docmost/db/types/entity.types';
import {
AiAgentRoleRepo,
parseSource,
} from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
import { AiAgentRole, RoleSource } from '@docmost/db/types/entity.types';
import { CreateAgentRoleDto, UpdateAgentRoleDto } from './dto/agent-role.dto';
import { ImportFromCatalogDto, UpdateFromCatalogDto } from './dto/agent-role-catalog.dto';
import { RoleModelConfig } from './role-model-config';
import { AiAgentRolesCatalogProvider } from './catalog/ai-agent-roles-catalog.provider';
import {
CatalogBundleFile,
CatalogBundleMeta,
CatalogRole,
} from './catalog/catalog-types';
/**
* Full (admin) view of an agent role. There are no secret columns on this table
@@ -24,6 +36,10 @@ export interface AgentRoleView {
enabled: boolean;
autoStart: boolean;
launchMessage: string | null;
// Catalog origin of an imported role, or null for a manually-created one. The
// admin UI uses `version` to offer an UPDATE when the catalog ships a newer
// revision. Admin-only (deliberately absent from AgentRolePickerView).
source: RoleSource | null;
createdAt: Date;
updatedAt: Date;
}
@@ -56,7 +72,12 @@ export interface AgentRolePickerView {
*/
@Injectable()
export class AiAgentRolesService {
constructor(private readonly repo: AiAgentRoleRepo) {}
private readonly logger = new Logger(AiAgentRolesService.name);
constructor(
private readonly repo: AiAgentRoleRepo,
private readonly catalog: AiAgentRolesCatalogProvider,
) {}
/**
* List the workspace's roles. Admins get the full view (the settings page needs
@@ -165,6 +186,316 @@ export class AiAgentRolesService {
return { success: true };
}
// -------------------------------------------------------------------------
// Catalog (admin-only). The catalog is curated, untrusted JSON fetched +
// validated by AiAgentRolesCatalogProvider; this layer resolves localized
// text and reconciles a bundle against the workspace's existing roles.
// -------------------------------------------------------------------------
/**
* Browse the catalog. Returns the union of every bundle's languages (sorted)
* plus per-bundle metadata with `name` / `description` resolved to the
* requested `language` (fallback: 'en', then the first available locale).
*/
async getCatalog(language?: string): Promise<{
languages: string[];
bundles: {
id: string;
name: string;
description: string | null;
languages: string[];
roles: { slug: string; version: number }[];
}[];
}> {
const index = await this.catalog.fetchIndex();
const languages = Array.from(
new Set(index.bundles.flatMap((b) => b.languages)),
).sort();
const bundles = index.bundles.map((b) => ({
id: b.id,
name: localized(b.name, language) ?? b.id,
description: b.description ? localized(b.description, language) : null,
languages: b.languages,
roles: b.roles.map((r) => ({ slug: r.slug, version: r.version })),
}));
return { languages, bundles };
}
/**
* Shared read prefix for the two bundle-by-id catalog paths (getCatalogBundle /
* importFromCatalog): fetch the index, resolve the requested bundle's meta
* (502 if the index does not list it), fetch its per-language file, and build
* the slug->version map from the meta. The callers keep their own response /
* write logic; only this duplicated read is factored out here.
*/
private async loadBundleById(
bundleId: string,
language: string,
): Promise<{
meta: CatalogBundleMeta;
file: CatalogBundleFile;
versions: Map<string, number>;
}> {
const index = await this.catalog.fetchIndex();
const meta = index.bundles.find((b) => b.id === bundleId);
if (!meta) {
throw new BadGatewayException('Catalog bundle not found');
}
const file = await this.catalog.fetchBundle(bundleId, language);
return { meta, file, versions: versionMap(meta) };
}
/**
* Open one bundle in a language: returns each role's content plus the version
* taken from the index (so the client can compare against an imported role's
* source.version). A missing bundle/language => BadGateway (catalog issue).
*/
async getCatalogBundle(
bundleId: string,
language: string,
): Promise<{
bundleId: string;
language: string;
roles: {
slug: string;
emoji: string | null;
name: string;
description: string | null;
instructions: string;
autoStart: boolean;
launchMessage: string | null;
version: number;
}[];
}> {
const { file, versions } = await this.loadBundleById(bundleId, language);
return {
bundleId,
language,
roles: file.roles.map((r) => ({
slug: r.slug,
emoji: r.emoji ?? null,
name: r.name,
description: r.description ?? null,
instructions: r.instructions,
autoStart: r.autoStart ?? true,
launchMessage: r.launchMessage ?? null,
version: versions.get(r.slug) ?? 1,
})),
};
}
/**
* Import a bundle's roles into the workspace. A role is "already installed"
* (and thus skipped — updates are a separate action) only when an existing
* role matches BOTH its `source.slug` AND `source.language`: this is a
* multilingual catalog, so a different language of the same slug (e.g. the
* `ru` variant of a slug already installed as `en`) is a SEPARATE install and
* still imports. A name collision with an existing role is either skipped or
* imported under a free " (N)" name, per `dto.conflict`. Inserts run
* sequentially (the repo exposes no batch insert and the volume is tiny); a
* unique-name race still surfaces as an error entry rather than aborting the
* whole import.
*/
async importFromCatalog(
workspaceId: string,
creatorId: string,
dto: ImportFromCatalogDto,
): Promise<{
created: number;
skipped: number;
renamed: number;
errors: { slug: string; message: string }[];
}> {
const { file, versions } = await this.loadBundleById(
dto.bundleId,
dto.language,
);
const errors: { slug: string; message: string }[] = [];
// Resolve the selected catalog roles (honor dto.slugs; flag unknown ones).
let selected = file.roles;
if (dto.slugs && dto.slugs.length > 0) {
const wanted = new Set(dto.slugs);
const present = new Set(file.roles.map((r) => r.slug));
for (const slug of dto.slugs) {
if (!present.has(slug)) {
errors.push({ slug, message: 'Role not found in catalog bundle' });
}
}
selected = file.roles.filter((r) => wanted.has(r.slug));
}
const existingRoles = await this.repo.listByWorkspace(workspaceId);
// Catalog roles already installed in this workspace, keyed by slug+language
// (skip; never duplicate). The key MUST match the client install-state and
// updateFromCatalog (both match by source.slug AND source.language): the
// `ru` variant of a slug already installed as `en` is a separate install.
const installedKeys = new Set(
existingRoles
.map((r) => parseSource(r.source))
.filter((s): s is RoleSource => s !== null)
.map((s) => `${s.slug}:${s.language}`),
);
// Live role names (lowercased) for collision detection. Mutated as we
// insert so two imported roles cannot both grab the same name.
const takenNames = new Set(
existingRoles.map((r) => r.name.trim().toLowerCase()),
);
let created = 0;
let skipped = 0;
let renamed = 0;
for (const role of selected) {
// Already installed from the catalog in THIS language => skip (use
// update-from-catalog). A different language of the same slug still imports.
const installKey = `${role.slug}:${dto.language}`;
if (installedKeys.has(installKey)) {
skipped++;
continue;
}
let name = role.name.trim();
let didRename = false;
if (takenNames.has(name.toLowerCase())) {
if (dto.conflict === 'skip') {
skipped++;
continue;
}
// conflict === 'rename': find a free " (N)" suffix.
name = freeName(name, takenNames);
didRename = true;
}
const version = versions.get(role.slug) ?? 1;
try {
await this.repo.insert({
workspaceId,
creatorId,
name,
...catalogRoleContentFields(role),
enabled: true,
source: { slug: role.slug, language: dto.language, version },
});
created++;
if (didRename) renamed++;
takenNames.add(name.toLowerCase());
installedKeys.add(installKey);
} catch (err) {
// A 23505 from the source-uniqueness index means a CONCURRENT import
// already installed this exact slug+language between our snapshot
// (installedKeys) and this insert: the in-process snapshot cannot see a
// sibling request's writes, so the partial unique index is the backstop.
// Outcome is identical to the snapshot-based skip above — count it as
// skipped (already installed) and continue; do NOT abort or error.
if (isSourceUniqueViolation(err)) {
skipped++;
installedKeys.add(installKey);
continue;
}
// Otherwise: a unique-NAME race (23505 on the name index) is expected and
// self-explanatory (it becomes a friendly per-role error). Any OTHER
// insert failure is unexpected, so log the root cause with enough context
// to diagnose it — the user-facing message is deliberately generic.
if (!isUniqueViolation(err)) {
this.logger.error(
`Failed to import catalog role (workspaceId=${workspaceId} bundleId=${dto.bundleId} slug=${role.slug}): ${err instanceof Error ? err.stack ?? err.message : String(err)}`,
);
}
errors.push({ slug: role.slug, message: importErrorMessage(err) });
}
}
return { created, skipped, renamed, errors };
}
/**
* Update an already-imported role from its catalog source when the catalog
* ships a newer version. Returns a discriminated result so the UI can explain
* a no-op (up-to-date / removed from catalog / language no longer offered).
* Never touches `enabled`; keeps the current name if the catalog's new name
* would collide with another role (avoiding the unique-name 409).
*/
async updateFromCatalog(
workspaceId: string,
dto: UpdateFromCatalogDto,
): Promise<
| { updated: false; reason: 'not-in-catalog' | 'up-to-date' | 'language-unavailable' }
| { updated: true; fromVersion: number; toVersion: number; role: AgentRoleView }
> {
const role = await this.repo.findById(dto.id, workspaceId);
if (!role) throw new BadRequestException('Role not found');
const source = parseSource(role.source);
if (!source || !source.slug) {
throw new BadRequestException('Role was not imported from the catalog');
}
const index = await this.catalog.fetchIndex();
// Find the bundle whose meta lists this slug, and its catalog version.
let meta: CatalogBundleMeta | undefined;
let currentVersion: number | undefined;
for (const b of index.bundles) {
const m = b.roles.find((r) => r.slug === source.slug);
if (m) {
meta = b;
currentVersion = m.version;
break;
}
}
if (!meta || currentVersion === undefined) {
return { updated: false, reason: 'not-in-catalog' };
}
if (currentVersion <= source.version) {
return { updated: false, reason: 'up-to-date' };
}
if (!meta.languages.includes(source.language)) {
return { updated: false, reason: 'language-unavailable' };
}
const file = await this.catalog.fetchBundle(meta.id, source.language);
const fresh = file.roles.find((r) => r.slug === source.slug);
if (!fresh) {
return { updated: false, reason: 'not-in-catalog' };
}
// Keep the current name when the catalog's new name would collide with
// another live role (avoids the unique-name 409). Same-name (case-insensitive)
// means "no rename needed".
const newName = fresh.name.trim();
let name = newName;
if (newName.toLowerCase() !== role.name.trim().toLowerCase()) {
const others = await this.repo.listByWorkspace(workspaceId);
const collision = others.some(
(r) =>
r.id !== role.id &&
r.name.trim().toLowerCase() === newName.toLowerCase(),
);
if (collision) name = role.name;
}
await this.repo.update(dto.id, workspaceId, {
name,
...catalogRoleContentFields(fresh),
// enabled is deliberately NOT changed.
source: {
slug: source.slug,
language: source.language,
version: currentVersion,
},
});
const updated = await this.repo.findById(dto.id, workspaceId);
if (!updated) throw new BadRequestException('Role not found');
return {
updated: true,
fromVersion: source.version,
toVersion: currentVersion,
role: this.toView(updated),
};
}
private toView(row: AiAgentRole): AgentRoleView {
return {
id: row.id,
@@ -176,6 +507,9 @@ export class AiAgentRolesService {
enabled: row.enabled,
autoStart: row.autoStart,
launchMessage: row.launchMessage ?? null,
// parseSource yields a fully-valid RoleSource | null (the row is already
// normalized; this also keeps the field type honest without a cast).
source: parseSource(row.source),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
@@ -205,11 +539,7 @@ export class AiAgentRolesService {
* failures keep surfacing as 500s.
*/
function rethrowDuplicateName(err: unknown, name: string): never {
if (
err &&
typeof err === 'object' &&
(err as { code?: unknown }).code === '23505'
) {
if (isUniqueViolation(err)) {
throw new ConflictException(
`A role named "${name}" already exists in this workspace.`,
);
@@ -217,13 +547,120 @@ function rethrowDuplicateName(err: unknown, name: string): never {
throw err;
}
/** '' / whitespace-only / undefined => null; otherwise the trimmed value. */
function emptyToNull(value: string | undefined): string | null {
if (value === undefined) return null;
/** Whether `err` is a Postgres unique-violation (SQLSTATE 23505). */
function isUniqueViolation(err: unknown): boolean {
return (
!!err &&
typeof err === 'object' &&
(err as { code?: unknown }).code === '23505'
);
}
/**
* The partial unique index name from the
* 20260626T160000-ai-agent-roles-catalog-source-unique migration: unique on
* (workspace_id, source->>'slug', source->>'language') for catalog-imported,
* non-deleted rows. A 23505 carrying this constraint name is a source-collision
* (concurrent import of the same slug+language), distinct from a name-collision.
*/
const SOURCE_UNIQUE_CONSTRAINT = 'ai_agent_roles_workspace_source_unique';
/**
* Whether `err` is the 23505 raised by the SOURCE-uniqueness index specifically
* (vs the name-uniqueness index). The active driver (`kysely-postgres-js` over
* `postgres@3.4.8`) exposes the violated constraint name on `constraint_name`,
* so we key off that (accepting the node-postgres-style `.constraint` as a
* fallback for other drivers) — that way a source race is skipped while a name
* race still surfaces as a friendly per-role error. A 23505 with no constraint
* name (e.g. a wrapped/test error) is NOT treated as a source collision,
* preserving the existing name-race behavior.
*/
function isSourceUniqueViolation(err: unknown): boolean {
if (!isUniqueViolation(err)) return false;
const e = err as { constraint_name?: unknown; constraint?: unknown };
return (
e.constraint_name === SOURCE_UNIQUE_CONSTRAINT ||
e.constraint === SOURCE_UNIQUE_CONSTRAINT
);
}
/**
* The role-content fields shared by import (insert) and update (patch) of a
* catalog role: emoji/description/launchMessage normalized to null, model config
* normalized, autoStart defaulted. The caller adds the write-specific fields
* (`name`, `source`, and on insert `workspaceId`/`creatorId`/`enabled`).
*/
function catalogRoleContentFields(role: CatalogRole): {
emoji: string | null;
description: string | null;
instructions: string;
modelConfig: Record<string, unknown> | null;
autoStart: boolean;
launchMessage: string | null;
} {
return {
emoji: emptyToNull(role.emoji),
description: emptyToNull(role.description),
instructions: role.instructions,
modelConfig: normalizeModelConfig(role.modelConfig) as
| Record<string, unknown>
| null,
autoStart: role.autoStart ?? true,
launchMessage: emptyToNull(role.launchMessage ?? undefined),
};
}
/** '' / whitespace-only / undefined / null => null; otherwise the trimmed value. */
function emptyToNull(value: string | null | undefined): string | null {
if (value === undefined || value === null) return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
/** slug -> version map from a bundle's index metadata. */
function versionMap(meta: CatalogBundleMeta): Map<string, number> {
return new Map(meta.roles.map((r) => [r.slug, r.version]));
}
/**
* Resolve a localized value `{ en, ru, ... }` to `language`, falling back to
* 'en', then the first available locale. Returns null only for an empty map.
*/
function localized(
map: Record<string, string>,
language?: string,
): string | null {
if (language && typeof map[language] === 'string') return map[language];
if (typeof map.en === 'string') return map.en;
const first = Object.values(map)[0];
return typeof first === 'string' ? first : null;
}
/**
* Find a free display name by appending " (2)", " (3)", ... when `base` is
* already taken (case-insensitive against `taken`). Caller adds the result to
* `taken` after a successful insert.
*/
function freeName(base: string, taken: Set<string>): string {
// `taken` is finite, so within `taken.size + 2` iterations a candidate index
// is guaranteed free; the 1000 cap is a defensive upper bound far above any
// realistic per-name collision count. The throw below is therefore
// unreachable in practice and only satisfies the return-type checker.
for (let n = 2; n < 1000; n++) {
const candidate = `${base} (${n})`;
if (!taken.has(candidate.toLowerCase())) return candidate;
}
throw new BadRequestException(`Too many roles named "${base}"`);
}
/** A short, safe message for an import insert failure (409 vs other). */
function importErrorMessage(err: unknown): string {
if (isUniqueViolation(err)) {
return 'A role with this name already exists';
}
return 'Failed to import role';
}
/**
* Normalize an incoming modelConfig DTO to the persisted shape, or null when
* there is no usable override (no driver and no chatModel). The DTO's @IsIn

View File

@@ -0,0 +1,307 @@
import { BadGatewayException, BadRequestException } from '@nestjs/common';
import { AiAgentRolesCatalogProvider } from './ai-agent-roles-catalog.provider';
/**
* Provider tests against a mocked remote source (no network). They cover the
* happy read path (fetchIndex / fetchBundle), the malformed-shape rejection,
* rejection of non-http(s) sources (local sources are gone), and — most
* importantly — the `^[a-z0-9-]+$` path-traversal guard that runs BEFORE any
* path/URL is built.
*/
describe('AiAgentRolesCatalogProvider', () => {
function makeProvider(source: string) {
const env = {
getAiAgentRolesCatalogSource: () => source,
};
return new AiAgentRolesCatalogProvider(env as never);
}
it('non-http(s) source => BadGateway (local sources removed)', async () => {
for (const source of ['', '/var/lib/agent-roles-catalog', './agent-roles-catalog']) {
const provider = makeProvider(source);
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
BadGatewayException,
);
}
});
describe('remote fetch streaming size cap', () => {
const realFetch = global.fetch;
afterEach(() => {
global.fetch = realFetch;
});
/** A web ReadableStream that yields `chunks` (each a Uint8Array). */
function streamOf(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
let i = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (i < chunks.length) controller.enqueue(chunks[i++]);
else controller.close();
},
// The provider cancels the reader on the too-large path; no-op here.
cancel() {},
});
}
/** A ReadableStream whose first read rejects (e.g. a mid-body AbortError). */
function errorStream(err: Error): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
pull() {
throw err;
},
cancel() {},
});
}
function mockResponse(opts: {
ok?: boolean;
status?: number;
headers?: Record<string, string>;
body: ReadableStream<Uint8Array> | null;
text?: string;
}): Response {
return {
ok: opts.ok ?? true,
status: opts.status ?? 200,
headers: { get: (k: string) => opts.headers?.[k.toLowerCase()] ?? null },
body: opts.body,
text: async () => opts.text ?? 'unused',
} as unknown as Response;
}
it('fetchBundle remote happy path => parses + validates', async () => {
const json = JSON.stringify({
schemaVersion: 1,
language: 'en',
roles: [
{
slug: 'researcher',
name: 'Researcher',
instructions: 'be a researcher',
},
],
});
const body = streamOf([new TextEncoder().encode(json)]);
global.fetch = jest
.fn()
.mockResolvedValue(mockResponse({ body })) as never;
const provider = makeProvider('https://catalog.example.com');
const bundle = await provider.fetchBundle('general', 'en');
expect(bundle.roles[0].slug).toBe('researcher');
});
it('fetchBundle remote malformed (role missing instructions) => BadGateway', async () => {
const json = JSON.stringify({
schemaVersion: 1,
language: 'fr',
roles: [{ slug: 'researcher', name: 'Chercheur' }],
});
const body = streamOf([new TextEncoder().encode(json)]);
global.fetch = jest
.fn()
.mockResolvedValue(mockResponse({ body })) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchBundle('general', 'fr')).rejects.toBeInstanceOf(
BadGatewayException,
);
});
it('declared Content-Length over the cap => BadGateway before reading the body', async () => {
global.fetch = jest.fn().mockResolvedValue(
mockResponse({
headers: { 'content-length': String(2_000_000) },
body: streamOf([new Uint8Array(10)]),
}),
) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
BadGatewayException,
);
});
it('streamed body exceeding the cap (no/under-reported Content-Length) => BadGateway', async () => {
// 1.5 MB streamed in 256 KB chunks, with no Content-Length header.
const chunks = Array.from(
{ length: 6 },
() => new Uint8Array(256 * 1024),
);
global.fetch = jest
.fn()
.mockResolvedValue(mockResponse({ body: streamOf(chunks) })) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
BadGatewayException,
);
});
it('fetch rejects (network failure) => BadGateway (unavailable)', async () => {
global.fetch = jest
.fn()
.mockRejectedValue(new Error('ECONNREFUSED')) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
BadGatewayException,
);
});
it('passes redirect:"error" to fetch (redirect-SSRF hardening)', async () => {
const fetchMock = jest
.fn()
.mockResolvedValue(
mockResponse({ body: streamOf([new Uint8Array(0)]) }),
);
global.fetch = fetchMock as never;
const provider = makeProvider('https://catalog.example.com');
// Body shape is irrelevant; an empty stream parses to invalid JSON and
// throws, but the fetch call (with its init) still happened.
await expect(provider.fetchIndex()).rejects.toBeDefined();
expect(fetchMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ redirect: 'error' }),
);
});
it('redirect response rejects (redirect:"error") => BadGateway', async () => {
// With redirect:"error", the platform fetch rejects on a 3xx instead of
// following it. Simulate that: the mock rejects when asked not to follow.
global.fetch = jest.fn().mockImplementation((_url, init) => {
if (init?.redirect === 'error') {
return Promise.reject(
new TypeError('fetch failed: unexpected redirect'),
);
}
return Promise.resolve(
mockResponse({ status: 302, body: null }),
);
}) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
BadGatewayException,
);
});
it('non-ok response (503) => BadGateway carrying the status', async () => {
global.fetch = jest.fn().mockResolvedValue(
mockResponse({ ok: false, status: 503, body: null }),
) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchIndex()).rejects.toThrow(/503/);
});
it('small streamed body parses normally (cap not hit)', async () => {
const json = JSON.stringify({
schemaVersion: 1,
bundles: [
{
id: 'general',
name: { en: 'General' },
languages: ['en'],
roles: [{ slug: 'researcher', version: 2 }],
},
],
});
const body = streamOf([new TextEncoder().encode(json)]);
global.fetch = jest
.fn()
.mockResolvedValue(mockResponse({ body })) as never;
const provider = makeProvider('https://catalog.example.com');
const index = await provider.fetchIndex();
expect(index.bundles[0].id).toBe('general');
});
it('body read aborts mid-stream (AbortError) => BadGateway (not a generic 500)', async () => {
// The 10s timer aborts the whole request; on a slow/dripping source the
// body read (reader.read()) rejects with an AbortError AFTER fetch()
// resolved. The provider must map that to BadGateway, not let it escape.
const abortErr = Object.assign(new Error('The operation was aborted'), {
name: 'AbortError',
});
global.fetch = jest
.fn()
.mockResolvedValue(mockResponse({ body: errorStream(abortErr) })) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
BadGatewayException,
);
});
it('null body (no readable stream) => response.text() fallback parses', async () => {
const json = JSON.stringify({
schemaVersion: 1,
bundles: [
{
id: 'general',
name: { en: 'General' },
languages: ['en'],
roles: [{ slug: 'researcher', version: 2 }],
},
],
});
global.fetch = jest
.fn()
.mockResolvedValue(mockResponse({ body: null, text: json })) as never;
const provider = makeProvider('https://catalog.example.com');
const index = await provider.fetchIndex();
expect(index.bundles[0].id).toBe('general');
});
it('null body + text() over the cap => BadGateway (too large)', async () => {
const oversized = 'a'.repeat(1_000_001);
global.fetch = jest
.fn()
.mockResolvedValue(
mockResponse({ body: null, text: oversized }),
) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
BadGatewayException,
);
});
it('invalid JSON body => BadGateway (parse failure)', async () => {
const body = streamOf([new TextEncoder().encode('{not valid json')]);
global.fetch = jest
.fn()
.mockResolvedValue(mockResponse({ body })) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
BadGatewayException,
);
});
it('malformed index.json (valid JSON, wrong shape) => BadGateway', async () => {
// Parses as JSON but fails isCatalogIndex (schemaVersion not a number).
const body = streamOf([
new TextEncoder().encode(
JSON.stringify({ schemaVersion: 'x', bundles: [] }),
),
]);
global.fetch = jest
.fn()
.mockResolvedValue(mockResponse({ body })) as never;
const provider = makeProvider('https://catalog.example.com');
await expect(provider.fetchIndex()).rejects.toThrow(/malformed/i);
});
});
describe('path-traversal / SSRF guard (^[a-z0-9-]+$)', () => {
const bad = ['../etc', 'a/b', 'A', 'foo.bar', 'foo_bar', '', '..'];
for (const value of bad) {
it(`rejects bundleId="${value}" with BadRequest`, async () => {
const provider = makeProvider('https://catalog.example.com');
await expect(
provider.fetchBundle(value, 'en'),
).rejects.toBeInstanceOf(BadRequestException);
});
it(`rejects language="${value}" with BadRequest`, async () => {
const provider = makeProvider('https://catalog.example.com');
await expect(
provider.fetchBundle('general', value),
).rejects.toBeInstanceOf(BadRequestException);
});
}
});
});

View File

@@ -0,0 +1,311 @@
import {
BadGatewayException,
BadRequestException,
Injectable,
Logger,
} from '@nestjs/common';
import { EnvironmentService } from '../../../../integrations/environment/environment.service';
import {
CatalogBundleFile,
CatalogBundleMeta,
CatalogIndex,
CatalogRole,
} from './catalog-types';
/** Identifier shape allowed in any path/URL segment (bundleId, language). The
* ONLY characters that can appear in a fetched path — the path-traversal and
* SSRF guard. Anything else is rejected before a path/URL is built. */
const SEGMENT_RE = /^[a-z0-9-]+$/;
/** Remote fetch timeout and response-size cap. A curated catalog file is tiny;
* the cap stops a hostile/misconfigured source from streaming unbounded data. */
const FETCH_TIMEOUT_MS = 10_000;
const MAX_BYTES = 1_000_000;
/**
* Fetches + validates the agent-roles catalog from its configured source. The
* source (EnvironmentService.getAiAgentRolesCatalogSource()) is an http(s)://
* base URL — REMOTE only; local-filesystem sources are no longer supported. The
* value is baked into the Docker image at build time (set per-branch in CI).
*
* The catalog is UNTRUSTED input: every file is JSON-parsed and run through a
* hand-written type guard before any field is exposed, and every dynamic path
* segment is validated against SEGMENT_RE up front (path-traversal + SSRF).
*/
@Injectable()
export class AiAgentRolesCatalogProvider {
private readonly logger = new Logger(AiAgentRolesCatalogProvider.name);
constructor(private readonly environmentService: EnvironmentService) {}
/** Read + validate the top-level index (`index.json`). */
async fetchIndex(): Promise<CatalogIndex> {
const raw = await this.readRelative('index.json');
const parsed = this.parseJson(raw, 'index.json');
if (!isCatalogIndex(parsed)) {
throw new BadGatewayException(
'Agent roles catalog index is malformed (index.json)',
);
}
return parsed;
}
/** Read + validate one language file (`bundles/<bundleId>/<language>.json`). */
async fetchBundle(
bundleId: string,
language: string,
): Promise<CatalogBundleFile> {
// SECURITY: validate BEFORE building any path/URL (path-traversal + SSRF).
this.assertSegment(bundleId, 'bundleId');
this.assertSegment(language, 'language');
const rel = `bundles/${bundleId}/${language}.json`;
const raw = await this.readRelative(rel);
const parsed = this.parseJson(raw, rel);
if (!isCatalogBundleFile(parsed)) {
throw new BadGatewayException(
`Agent roles catalog bundle is malformed (${rel})`,
);
}
return parsed;
}
/** Reject a segment that is not a safe `[a-z0-9-]+` identifier. */
private assertSegment(value: string, field: string): void {
if (typeof value !== 'string' || !SEGMENT_RE.test(value)) {
throw new BadRequestException(`Invalid ${field}`);
}
}
/** JSON.parse with a clear BadGateway on malformed content. */
private parseJson(raw: string, rel: string): unknown {
try {
return JSON.parse(raw);
} catch (err) {
const reason = shortError(err);
this.logger.error(`Agent roles catalog JSON parse failed (${rel}): ${reason}`);
throw new BadGatewayException(
`Agent roles catalog file is not valid JSON (${rel}): ${reason}`,
);
}
}
/** Read a relative catalog path as text from the configured remote source. */
private async readRelative(rel: string): Promise<string> {
const source = this.environmentService
.getAiAgentRolesCatalogSource()
.trim();
if (!/^https?:\/\//i.test(source)) {
this.logger.error(
'Agent roles catalog source is not configured (expected an http(s):// base URL)',
);
throw new BadGatewayException(
'Agent roles catalog is unavailable: source is not configured',
);
}
return this.fetchRemote(source, rel);
}
/**
* Fetch a remote catalog file with a timeout + a STREAMING size cap. The body
* is never buffered in full before the check: we reject on a too-large
* Content-Length up front, then read the stream chunk-by-chunk and abort the
* moment the running total exceeds MAX_BYTES, so a hostile/misconfigured
* source cannot make us hold an unbounded body in memory.
*/
private async fetchRemote(base: string, rel: string): Promise<string> {
const url = `${base.replace(/\/+$/, '')}/${rel}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
let response: Response;
try {
// `redirect: 'error'` hardens against redirect-SSRF: a
// compromised-but-trusted upstream cannot 3xx the fetch into the
// internal network (e.g. http://169.254.169.254/...). A redirect
// response rejects here and is mapped to BadGateway below.
response = await fetch(url, {
signal: controller.signal,
redirect: 'error',
});
} catch (err) {
const reason = shortError(err);
this.logger.error(
`Agent roles catalog remote fetch failed (${rel}): ${reason}`,
);
throw new BadGatewayException(
`Agent roles catalog is unavailable: ${reason}`,
);
}
if (!response.ok) {
this.logger.error(
`Agent roles catalog remote returned ${response.status} (${rel})`,
);
throw new BadGatewayException(
`Agent roles catalog returned ${response.status}`,
);
}
// Reject a too-large declared size before reading any body bytes.
const declared = Number(response.headers.get('content-length'));
if (Number.isFinite(declared) && declared > MAX_BYTES) {
throw new BadGatewayException('Agent roles catalog file is too large');
}
// Bound the actual read: a missing/lying Content-Length is caught here.
// The 10s timer aborts the WHOLE request, so a slow/dripping hostile
// source rejects reader.read() (or response.text()) with an AbortError
// mid-body. Map that — and any other read failure — to a logged
// BadGateway so the admin endpoint returns 502 (not a generic 500). The
// cap's own BadGateway is rethrown as-is (no double-wrap).
try {
if (response.body) {
return await readStreamCapped(response.body, MAX_BYTES);
}
// Edge: no readable stream — fall back to a buffered read + length check.
const text = await response.text();
if (text.length > MAX_BYTES) {
throw new BadGatewayException('Agent roles catalog file is too large');
}
return text;
} catch (err) {
if (err instanceof BadGatewayException) throw err;
const reason = shortError(err);
this.logger.error(
`Agent roles catalog body read failed (${rel}): ${reason}`,
);
throw new BadGatewayException(
`Agent roles catalog is unavailable: ${reason}`,
);
}
} finally {
clearTimeout(timer);
}
}
}
/**
* Read a web ReadableStream into a UTF-8 string, throwing as soon as the
* accumulated byte count exceeds `maxBytes` (the reader is cancelled so the
* underlying connection is released). Never buffers more than the cap + the
* final chunk before bailing out.
*/
async function readStreamCapped(
body: ReadableStream<Uint8Array>,
maxBytes: number,
): Promise<string> {
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
total += value.length;
if (total > maxBytes) {
throw new BadGatewayException('Agent roles catalog file is too large');
}
chunks.push(value);
}
} finally {
// Release the stream on both the normal and the too-large/abort paths.
await reader.cancel().catch(() => undefined);
}
return Buffer.concat(chunks).toString('utf8');
}
/**
* A short, non-sensitive error string for logging/propagation: only the first
* line of the message head is kept (upstream bodies / URLs are discarded).
*/
function shortError(err: unknown): string {
let message = '';
if (typeof err === 'string') {
message = err;
} else if (
err &&
typeof err === 'object' &&
typeof (err as { message?: unknown }).message === 'string'
) {
// Read `.message` directly (works for Error instances and the realm-shifted
// Error-likes jest can hand back, where `instanceof Error` is false).
message = (err as { message: string }).message;
}
const head = (message || 'unknown error').split('\n')[0];
return head.length > 200 ? `${head.slice(0, 200)}` : head;
}
// ---------------------------------------------------------------------------
// Hand-written type guards (no zod / new deps). Each validates the exact wire
// shape declared in catalog-types.ts; anything else is rejected by the caller.
// ---------------------------------------------------------------------------
function isObject(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === 'object' && !Array.isArray(v);
}
function isStringMap(v: unknown): v is Record<string, string> {
if (!isObject(v)) return false;
return Object.values(v).every((x) => typeof x === 'string');
}
function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every((x) => typeof x === 'string');
}
export function isCatalogRole(v: unknown): v is CatalogRole {
if (!isObject(v)) return false;
if (typeof v.slug !== 'string') return false;
if (typeof v.name !== 'string') return false;
if (typeof v.instructions !== 'string') return false;
if (v.emoji !== undefined && typeof v.emoji !== 'string') return false;
if (v.description !== undefined && typeof v.description !== 'string') {
return false;
}
if (v.autoStart !== undefined && typeof v.autoStart !== 'boolean') {
return false;
}
if (
v.launchMessage !== undefined &&
v.launchMessage !== null &&
typeof v.launchMessage !== 'string'
) {
return false;
}
if (
v.modelConfig !== undefined &&
v.modelConfig !== null &&
!isObject(v.modelConfig)
) {
return false;
}
return true;
}
export function isCatalogBundleFile(v: unknown): v is CatalogBundleFile {
if (!isObject(v)) return false;
if (typeof v.schemaVersion !== 'number') return false;
if (typeof v.language !== 'string') return false;
if (!Array.isArray(v.roles)) return false;
return v.roles.every(isCatalogRole);
}
function isCatalogBundleMeta(v: unknown): v is CatalogBundleMeta {
if (!isObject(v)) return false;
if (typeof v.id !== 'string') return false;
if (!isStringMap(v.name)) return false;
if (v.description !== undefined && !isStringMap(v.description)) return false;
if (!isStringArray(v.languages)) return false;
if (!Array.isArray(v.roles)) return false;
return v.roles.every(
(r) =>
isObject(r) &&
typeof r.slug === 'string' &&
typeof r.version === 'number',
);
}
export function isCatalogIndex(v: unknown): v is CatalogIndex {
if (!isObject(v)) return false;
if (typeof v.schemaVersion !== 'number') return false;
if (!Array.isArray(v.bundles)) return false;
return v.bundles.every(isCatalogBundleMeta);
}

View File

@@ -0,0 +1,47 @@
/**
* Catalog wire shapes. The catalog is curated, untrusted JSON (a GitHub repo or
* a local folder), so every shape is validated by a hand-written type guard in
* the provider before any field is used — no zod / new deps on the server.
*
* Localized fields (`name` / `description` at the bundle level) are
* `Record<language, string>` so one bundle serves many UI languages; per-role
* `name` / `description` are already language-specific (the bundle file is keyed
* by language).
*/
/** One role's content as shipped in a per-language bundle file. */
export interface CatalogRole {
slug: string;
emoji?: string;
name: string;
description?: string;
instructions: string;
autoStart?: boolean;
launchMessage?: string | null;
// Optional model override; same loose object shape as ai_agent_roles.model_config.
modelConfig?: Record<string, unknown> | null;
}
/** A single language file: `bundles/<id>/<language>.json`. */
export interface CatalogBundleFile {
schemaVersion: number;
language: string;
roles: CatalogRole[];
}
/** Bundle metadata as listed in the top-level index. Versions live here (per
* slug), so an UPDATE check needs only the index, not every language file. */
export interface CatalogBundleMeta {
id: string;
// Localized display name/description: { en: '...', ru: '...' }.
name: Record<string, string>;
description?: Record<string, string>;
languages: string[];
roles: { slug: string; version: number }[];
}
/** Top-level catalog index: `index.json`. */
export interface CatalogIndex {
schemaVersion: number;
bundles: CatalogBundleMeta[];
}

View File

@@ -0,0 +1,62 @@
import {
IsArray,
IsIn,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
} from 'class-validator';
/** Safe identifier shape for any catalog path segment (bundleId / language).
* Mirrors SEGMENT_RE in the catalog provider — the path-traversal/SSRF guard
* is enforced both at the API boundary (here) and in the provider. */
const SEGMENT_RE = /^[a-z0-9-]+$/;
/** Browse the catalog, optionally localized to `language` (defaults applied in
* the service: fall back to 'en', then the first available language). */
export class CatalogQueryDto {
@IsOptional()
@IsString()
@MaxLength(16)
language?: string;
}
/** Open one catalog bundle in a specific language. */
export class CatalogBundleDto {
@IsString()
@Matches(SEGMENT_RE)
bundleId: string;
@IsString()
@Matches(SEGMENT_RE)
language: string;
}
/** Import roles from a catalog bundle into the workspace. */
export class ImportFromCatalogDto {
@IsString()
@Matches(SEGMENT_RE)
bundleId: string;
@IsString()
@Matches(SEGMENT_RE)
language: string;
// Omitted => import the whole bundle; otherwise only these slugs.
@IsOptional()
@IsArray()
@IsString({ each: true })
slugs?: string[];
// How to handle a name collision with an existing (non-catalog) role:
// 'skip' leaves it; 'rename' imports under a free " (N)" name.
@IsIn(['skip', 'rename'])
conflict: 'skip' | 'rename';
}
/** Update an already-imported role from its catalog source. */
export class UpdateFromCatalogDto {
@IsUUID()
id: string;
}

View File

@@ -1,4 +1,5 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { NoResultError } from 'kysely';
import { ShareAliasService } from './share-alias.service';
/**
@@ -7,13 +8,18 @@ import { ShareAliasService } from './share-alias.service';
* request-time readable-target resolution (which re-runs the share boundary).
*/
describe('ShareAliasService', () => {
// Sentinel handed to repo calls so tests can assert they ran inside the tx.
const trx = { __trx: true };
function makeService() {
const shareAliasRepo = {
findByAliasAndWorkspace: jest.fn(),
findByPageId: jest.fn(),
findById: jest.fn(),
insert: jest.fn(),
updateAlias: jest.fn(),
updatePageId: jest.fn(),
deleteOthersForPage: jest.fn(),
delete: jest.fn(),
};
const pageRepo = { findById: jest.fn() };
@@ -21,12 +27,19 @@ describe('ShareAliasService', () => {
resolveReadableSharePage: jest.fn(),
isSharingAllowed: jest.fn(),
};
// Fake kysely db: only .transaction().execute(cb) is used by setAlias.
const db = {
transaction: jest.fn(() => ({
execute: jest.fn(async (cb: any) => cb(trx)),
})),
};
const service = new ShareAliasService(
shareAliasRepo as any,
pageRepo as any,
shareService as any,
db as any,
);
return { service, shareAliasRepo, pageRepo, shareService };
return { service, shareAliasRepo, pageRepo, shareService, db };
}
describe('setAlias', () => {
@@ -43,9 +56,10 @@ describe('ShareAliasService', () => {
expect(shareAliasRepo.findByAliasAndWorkspace).not.toHaveBeenCalled();
});
it('normalizes then inserts a brand-new alias', async () => {
it('normalizes then inserts a brand-new alias (page has none yet)', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.findByPageId.mockResolvedValue(undefined);
shareAliasRepo.insert.mockResolvedValue({ id: 'a-1', alias: 'my-page' });
const res = await service.setAlias({
@@ -58,17 +72,70 @@ describe('ShareAliasService', () => {
expect(shareAliasRepo.findByAliasAndWorkspace).toHaveBeenCalledWith(
'my-page',
'ws-1',
trx,
);
expect(shareAliasRepo.insert).toHaveBeenCalledWith(
{
workspaceId: 'ws-1',
alias: 'my-page',
pageId: 'p-1',
creatorId: 'u-1',
},
trx,
);
expect(shareAliasRepo.updateAlias).not.toHaveBeenCalled();
// self-heal still runs, keeping just the inserted row
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
'p-1',
'a-1',
'ws-1',
trx,
);
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 () => {
it('renames the existing row in place when editing to a free name (te -> ted)', async () => {
const { service, shareAliasRepo } = makeService();
// The new slug is free...
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
// ...but the page already owns an alias named `te`.
shareAliasRepo.findByPageId.mockResolvedValue({
id: 'a-1',
alias: 'te',
pageId: 'p-1',
});
shareAliasRepo.updateAlias.mockResolvedValue({
id: 'a-1',
alias: 'ted',
pageId: 'p-1',
});
const res = await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'ted',
});
// RENAME, not INSERT a second row.
expect(shareAliasRepo.insert).not.toHaveBeenCalled();
expect(shareAliasRepo.updateAlias).toHaveBeenCalledWith(
'a-1',
'ted',
'ws-1',
trx,
);
// ...and any other row for the page is reaped, so `te` cannot survive.
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
'p-1',
'a-1',
'ws-1',
trx,
);
expect(res).toMatchObject({ id: 'a-1', alias: 'ted' });
});
it('is a no-op when the alias already points at the same page (and self-heals)', async () => {
const { service, shareAliasRepo } = makeService();
const existing = { id: 'a-1', alias: 'foo', pageId: 'p-1' };
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(existing);
@@ -82,7 +149,45 @@ describe('ShareAliasService', () => {
expect(res).toBe(existing);
expect(shareAliasRepo.insert).not.toHaveBeenCalled();
expect(shareAliasRepo.updateAlias).not.toHaveBeenCalled();
expect(shareAliasRepo.updatePageId).not.toHaveBeenCalled();
// self-heal reaps any legacy duplicate rows for the page
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
'p-1',
'a-1',
'ws-1',
trx,
);
});
it('self-heals a page with pre-existing duplicate rows down to one', async () => {
const { service, shareAliasRepo } = makeService();
// Name free; the page already has a (legacy) alias row we rename.
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.findByPageId.mockResolvedValue({
id: 'a-keep',
alias: 'old',
pageId: 'p-1',
});
shareAliasRepo.updateAlias.mockResolvedValue({
id: 'a-keep',
alias: 'new',
pageId: 'p-1',
});
await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'new',
});
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
'p-1',
'a-keep',
'ws-1',
trx,
);
});
it('throws 409 with current target when name is taken and not confirmed', async () => {
@@ -134,15 +239,190 @@ describe('ShareAliasService', () => {
'a-1',
'p-1',
'ws-1',
trx,
);
// ORDER MATTERS: the target page's existing alias row(s) are reaped BEFORE
// the retarget, so the non-deferrable (workspace_id, page_id) index never
// sees two rows for the page mid-statement. There is no trailing self-heal.
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
'p-1',
'a-1',
'ws-1',
trx,
);
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledTimes(1);
const deleteOrder =
shareAliasRepo.deleteOthersForPage.mock.invocationCallOrder[0];
const updateOrder =
shareAliasRepo.updatePageId.mock.invocationCallOrder[0];
expect(deleteOrder).toBeLessThan(updateOrder);
expect(res).toMatchObject({ pageId: 'p-1' });
});
it('maps a unique-violation race to 409', async () => {
it('maps a unique-violation race (no constraint info) to 409 "Alias already taken"', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.insert.mockRejectedValue({ code: '23505' });
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({
message: 'Alias already taken',
});
}
});
it('maps the (workspace_id, alias) index violation to "Alias already taken"', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
// postgres@3.x driver exposes the index name as `constraint_name`.
shareAliasRepo.insert.mockRejectedValue({
code: '23505',
constraint_name: 'share_aliases_workspace_id_alias_unique',
});
try {
await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
});
fail('expected ConflictException');
} catch (err) {
expect((err as ConflictException).getResponse()).toMatchObject({
message: 'Alias already taken',
});
}
});
it('maps the (workspace_id, page_id) index violation to a DISTINCT page-race outcome', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.insert.mockRejectedValue({
code: '23505',
constraint_name: 'share_aliases_workspace_id_page_id_unique',
});
try {
await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
});
fail('expected ConflictException');
} catch (err) {
expect(err).toBeInstanceOf(ConflictException);
// NOT the misleading "Alias already taken" — a separate, page-scoped code.
expect((err as ConflictException).getResponse()).toMatchObject({
code: 'ALIAS_PAGE_RACE',
});
expect((err as ConflictException).getResponse()).not.toMatchObject({
message: 'Alias already taken',
});
}
});
it('reads the index name from `.constraint` when `.constraint_name` is absent', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
// Fallback path for non-postgres@3.x drivers.
shareAliasRepo.insert.mockRejectedValue({
code: '23505',
constraint: 'share_aliases_workspace_id_page_id_unique',
});
try {
await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
});
fail('expected ConflictException');
} catch (err) {
expect((err as ConflictException).getResponse()).toMatchObject({
code: 'ALIAS_PAGE_RACE',
});
}
});
it('maps a concurrent-delete race in the SWAP branch to a retryable 409 (not a 200-without-alias)', async () => {
const { service, shareAliasRepo } = makeService();
// Name points at another page; reassign confirmed -> swap branch.
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
id: 'a-1',
alias: 'foo',
pageId: 'p-other',
});
// A concurrent removeAlias deleted the row between read and UPDATE, so the
// repo's executeTakeFirstOrThrow finds 0 rows and throws NoResultError.
shareAliasRepo.updatePageId.mockRejectedValue(
new NoResultError({} as any),
);
try {
await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'foo',
confirmReassign: true,
});
fail('expected ConflictException');
} catch (err) {
// Crucially NOT a resolved 200 carrying `undefined` as the alias.
expect(err).toBeInstanceOf(ConflictException);
expect((err as ConflictException).getResponse()).toMatchObject({
code: 'ALIAS_PAGE_RACE',
});
}
});
it('maps a concurrent-delete race in the RENAME branch to a retryable 409 (not a generic 400)', async () => {
const { service, shareAliasRepo } = makeService();
// New slug is free, but the page already owns an alias we rename in place.
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.findByPageId.mockResolvedValue({
id: 'a-1',
alias: 'te',
pageId: 'p-1',
});
// The row vanished before the UPDATE; repo throws NoResultError rather
// than returning undefined (which would dereference undefined.id -> 400).
shareAliasRepo.updateAlias.mockRejectedValue(new NoResultError({} as any));
try {
await service.setAlias({
workspaceId: 'ws-1',
pageId: 'p-1',
creatorId: 'u-1',
alias: 'ted',
});
fail('expected ConflictException');
} catch (err) {
expect(err).toBeInstanceOf(ConflictException);
expect(err).not.toBeInstanceOf(BadRequestException);
expect((err as ConflictException).getResponse()).toMatchObject({
code: 'ALIAS_PAGE_RACE',
});
}
});
it('maps a non-unique-violation db error to BadRequest (Failed to set alias)', async () => {
const { service, shareAliasRepo } = makeService();
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
shareAliasRepo.insert.mockRejectedValue({ code: '08006' }); // connection error
await expect(
service.setAlias({
workspaceId: 'ws-1',
@@ -150,7 +430,7 @@ describe('ShareAliasService', () => {
creatorId: 'u-1',
alias: 'foo',
}),
).rejects.toBeInstanceOf(ConflictException);
).rejects.toBeInstanceOf(BadRequestException);
});
});

View File

@@ -9,9 +9,24 @@ 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';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import {
executeTx,
isUniqueViolation,
violatedConstraint,
} from '@docmost/db/utils';
import { NoResultError } from 'kysely';
/** Postgres unique_violation; the (workspace_id, alias) constraint races here. */
const PG_UNIQUE_VIOLATION = '23505';
/**
* Unique index name from the share_aliases migrations whose violation we map to
* a DISTINCT, non-misleading outcome:
* - PAGE_ID: partial `(workspace_id, page_id) WHERE page_id IS NOT NULL`
* -> a concurrent writer already gave THIS page an alias.
* The `(workspace_id, alias)` index (the vanity NAME being taken) needs no
* constant: it is the default "Alias already taken" mapping.
*/
const UNIQUE_PAGE_ID_INDEX = 'share_aliases_workspace_id_page_id_unique';
export interface ResolvedAliasTarget {
share: NonNullable<
@@ -28,16 +43,30 @@ export class ShareAliasService {
private readonly shareAliasRepo: ShareAliasRepo,
private readonly pageRepo: PageRepo,
private readonly shareService: ShareService,
@InjectKysely() private readonly db: KyselyDB,
) {}
/**
* 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).
* Create, RENAME or retarget a page's vanity alias. INVARIANT: a page has
* EXACTLY ONE custom address. The alias name is workspace-scoped:
* - name free, page has no alias yet -> INSERT a new pointer
* - name free, page already has one -> RENAME that row in place (the slug
* edit, e.g. `te` -> `ted`); we never spawn a second row, so no orphan
* `/l/<old>` link survives
* - name already points at pageId -> no-op (idempotent)
* - name points at ANOTHER page -> 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 cache).
*
* To keep the invariant self-healing we DELETE every other alias row still
* pointing at this page (a legacy duplicate, or the target page's own former
* alias during a swap). The whole thing runs in one transaction. Because the
* `(workspace_id, page_id)` unique index is NON-deferrable (checked at the end
* of each statement), the swap branch DELETEs the target page's existing row
* BEFORE retargeting, so the page is never transiently carried by two rows;
* the other branches self-heal AFTER their write. Either way the page never
* ends a statement with duplicate rows.
*
* Caller is responsible for authorizing the page (edit rights + public
* readability); this method owns only the alias-name semantics.
@@ -57,48 +86,128 @@ export class ShareAliasService {
);
}
const existing = await this.shareAliasRepo.findByAliasAndWorkspace(
alias,
workspaceId,
);
if (!existing) {
try {
return await this.shareAliasRepo.insert({
workspaceId,
try {
return await executeTx(this.db, async (trx) => {
const byName = await this.shareAliasRepo.findByAliasAndWorkspace(
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' });
workspaceId,
trx,
);
// The name is occupied by a DIFFERENT (or dangling) target page.
if (byName && byName.pageId !== pageId) {
if (!confirmReassign) {
const currentPage = byName.pageId
? await this.pageRepo.findById(byName.pageId)
: null;
throw new ConflictException({
message: 'Alias already in use',
code: 'ALIAS_REASSIGN_REQUIRED',
currentPageId: byName.pageId,
currentPageTitle: currentPage?.title ?? null,
});
}
// Confirmed swap. ORDER MATTERS: the partial unique index on
// `(workspace_id, page_id)` is NON-deferrable, so it is checked at the
// end of EVERY statement. If we retargeted `byName` onto `pageId`
// first while `pageId` still had its OWN alias row, there would
// momentarily be two rows with this page_id -> immediate 23505 and a
// rolled-back tx (a misleading "Alias already taken"). So we FIRST drop
// the target page's existing alias row(s), THEN retarget. `byName.id`
// still points at its old page here, so excluding it via `keepId` is
// harmless; after the retarget it is the page's only row, so no
// trailing self-heal is needed.
await this.shareAliasRepo.deleteOthersForPage(
pageId,
byName.id,
workspaceId,
trx,
);
return await this.shareAliasRepo.updatePageId(
byName.id,
pageId,
workspaceId,
trx,
);
}
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;
}
// The name is FREE, or already points at THIS page. Ensure the page has
// a single row carrying this name: rename its current one, or insert.
const current =
byName ??
(await this.shareAliasRepo.findByPageId(pageId, workspaceId, trx));
// 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,
let row: ShareAlias;
if (current) {
row =
current.alias === alias
? current // same-name no-op
: await this.shareAliasRepo.updateAlias(
current.id,
alias,
workspaceId,
trx,
);
} else {
row = await this.shareAliasRepo.insert(
{ workspaceId, alias, pageId, creatorId },
trx,
);
}
// Self-heal: a page keeps EXACTLY ONE custom address.
await this.shareAliasRepo.deleteOthersForPage(
pageId,
row.id,
workspaceId,
trx,
);
return row;
});
} catch (err: any) {
if (
err instanceof ConflictException ||
err instanceof BadRequestException
) {
throw err;
}
// The row we read was deleted (concurrent `removeAlias`) before our UPDATE
// matched it, so `executeTakeFirstOrThrow` found no row. Surface a
// retryable conflict instead of a 200-without-alias (swap branch) or a
// generic 400 from dereferencing `undefined.id` (rename branch).
if (err instanceof NoResultError) {
this.logger.warn(
'share alias update matched no row (concurrent-delete race)',
);
throw new ConflictException({
message: 'The address changed concurrently, please retry',
code: 'ALIAS_PAGE_RACE',
});
}
// A unique index fired. Which one decides the message — always log the
// constraint so the race is diagnosable.
if (isUniqueViolation(err)) {
const constraint = violatedConstraint(err);
this.logger.warn(
`share alias unique violation on ${constraint ?? '<unknown>'}`,
);
// `(workspace_id, page_id)`: a concurrent request already gave this page
// an alias. The page still has exactly one custom address (the racing
// writer's), so this is not a user-facing name clash — surface a
// distinct, non-misleading message instead of "Alias already taken".
if (constraint === UNIQUE_PAGE_ID_INDEX) {
throw new ConflictException({
message: 'This page is being given an address by another request',
code: 'ALIAS_PAGE_RACE',
});
}
// `(workspace_id, alias)` or any other/unknown unique index: treat as
// the vanity name being claimed first.
throw new ConflictException({ message: 'Alias already taken' });
}
this.logger.error(err);
throw new BadRequestException('Failed to set alias');
}
return this.shareAliasRepo.updatePageId(existing.id, pageId, workspaceId);
}
/** Free a vanity name (no history kept). */

View File

@@ -0,0 +1,161 @@
import { NotFoundException } from '@nestjs/common';
import { ShareService } from './share.service';
/**
* Regression for issue #218: public-share content must be bound to the requested
* shareId. `getSharedPage` resolves the page off its slug, but when the caller
* supplies a shareId it must be reachable THROUGH that exact share — a forged or
* mismatched shareId 404s instead of rendering the page off its slug alone. A
* request with no shareId keeps the legacy slug-capability behavior.
*/
const WS = 'ws-1';
const PAGE_ID = 'page-uuid-1';
const OWN_SHARE_ID = 'share-own';
const OWN_SHARE_KEY = 'ownkey';
function buildService(over: {
resolvedShare?: any;
ancestorShare?: any; // returned by shareRepo.findById(requestedShareId)
ancestorFound?: boolean; // getShareAncestorPage result
} = {}) {
const resolvedShare = over.resolvedShare ?? {
id: OWN_SHARE_ID,
key: OWN_SHARE_KEY,
includeSubPages: false,
spaceId: 'space-1',
workspaceId: WS,
};
const page = { id: PAGE_ID, deletedAt: null, content: { type: 'doc' } };
const shareRepo = {
findById: jest.fn(async () => over.ancestorShare ?? null),
};
const service = new ShareService(
shareRepo as any,
{} as any, // pageRepo (resolveReadableSharePage is spied)
{} as any, // pagePermissionRepo
{} as any, // db
{} as any, // tokenService
{} as any, // transclusionService
{} as any, // workspaceRepo
);
jest
.spyOn(service, 'resolveReadableSharePage')
.mockResolvedValue({ share: resolvedShare, page } as any);
jest
.spyOn(service, 'updatePublicAttachments')
.mockResolvedValue(page.content as any);
jest
.spyOn(service, 'getShareAncestorPage')
.mockResolvedValue(over.ancestorFound ? { id: 'anc' } : null);
return { service, shareRepo, page, resolvedShare };
}
describe('ShareService.getSharedPage — share binding (#218)', () => {
it('returns the page when no shareId is supplied (legacy slug path)', async () => {
const { service } = buildService();
const out = await service.getSharedPage({ pageId: PAGE_ID } as any, WS);
expect(out.page.id).toBe(PAGE_ID);
});
it('returns the page when the shareId matches the resolved share key', async () => {
const { service } = buildService();
const out = await service.getSharedPage(
{ pageId: PAGE_ID, shareId: OWN_SHARE_KEY } as any,
WS,
);
expect(out.page.id).toBe(PAGE_ID);
});
it('returns the page when the shareId matches the resolved share id (case-insensitive key)', async () => {
const { service } = buildService();
const out = await service.getSharedPage(
{ pageId: PAGE_ID, shareId: OWN_SHARE_KEY.toUpperCase() } as any,
WS,
);
expect(out.page.id).toBe(PAGE_ID);
});
it('404s for a forged shareId that resolves to nothing', async () => {
const { service } = buildService({ ancestorShare: null });
await expect(
service.getSharedPage(
{ pageId: PAGE_ID, shareId: 'doesnotexist99' } as any,
WS,
),
).rejects.toBeInstanceOf(NotFoundException);
});
it('allows an includeSubPages ANCESTOR share that contains the page', async () => {
const { service } = buildService({
ancestorShare: {
id: 'ancestor-share',
pageId: 'ancestor-page',
includeSubPages: true,
workspaceId: WS,
},
ancestorFound: true,
});
const out = await service.getSharedPage(
{ pageId: PAGE_ID, shareId: 'ancestorkey' } as any,
WS,
);
expect(out.page.id).toBe(PAGE_ID);
});
it('404s for a different share WITHOUT includeSubPages', async () => {
const { service } = buildService({
ancestorShare: {
id: 'other-share',
pageId: 'other-page',
includeSubPages: false,
workspaceId: WS,
},
});
await expect(
service.getSharedPage(
{ pageId: PAGE_ID, shareId: 'otherkey' } as any,
WS,
),
).rejects.toBeInstanceOf(NotFoundException);
});
it('404s for an includeSubPages share that does NOT contain the page', async () => {
const { service } = buildService({
ancestorShare: {
id: 'unrelated-share',
pageId: 'unrelated-page',
includeSubPages: true,
workspaceId: WS,
},
ancestorFound: false,
});
await expect(
service.getSharedPage(
{ pageId: PAGE_ID, shareId: 'unrelatedkey' } as any,
WS,
),
).rejects.toBeInstanceOf(NotFoundException);
});
it('404s for a share in a different workspace', async () => {
const { service } = buildService({
ancestorShare: {
id: 'foreign-share',
pageId: 'foreign-page',
includeSubPages: true,
workspaceId: 'other-ws',
},
ancestorFound: true,
});
await expect(
service.getSharedPage(
{ pageId: PAGE_ID, shareId: 'foreignkey' } as any,
WS,
),
).rejects.toBeInstanceOf(NotFoundException);
});
});

View File

@@ -0,0 +1,69 @@
import { Page } from '@docmost/db/types/entity.types';
/**
* The EXACT shape returned to anonymous public-share viewers by the
* `/shares/page-info` route — the only unauthenticated path that serializes the
* full {page, share} records. This is a security boundary (#218): the raw rows
* carry internal metadata — creatorId/lastUpdatedById/contributorIds,
* spaceId/workspaceId, AI/source bookkeeping, lock/template flags,
* parent/position and raw timestamps — none of which may leak to an
* unauthenticated viewer. Keeping the allowlist as an explicit TYPE plus a
* single mapper means a new leaking field cannot be returned without also
* widening this contract (and tripping its key-test in share.controller.spec.ts).
*/
export interface PublicSharePayload {
page: {
id: string;
slugId: string;
title: string | null;
icon: string | null;
content: unknown;
};
share: {
id: string;
key: string;
includeSubPages: boolean | null;
searchIndexing: boolean | null;
level: number;
sharedPage: unknown;
};
}
/**
* The subset of the resolved share read by the public payload. Declared
* structurally so the richer getShareForPage result (which adds `level` and
* `sharedPage` on top of the base Shares row) passes without a cast.
*/
interface PublicShareSource {
id: string;
key: string;
includeSubPages: boolean | null;
searchIndexing: boolean | null;
// `level` is derived via a SQL literal in getShareForPage, so it surfaces as
// `unknown` in the resolved share; it is a number at runtime.
level: unknown;
sharedPage: unknown;
}
export function toPublicSharePayload(
page: Page,
share: PublicShareSource,
): PublicSharePayload {
return {
page: {
id: page.id,
slugId: page.slugId,
title: page.title,
icon: page.icon,
content: page.content,
},
share: {
id: share.id,
key: share.key,
includeSubPages: share.includeSubPages,
searchIndexing: share.searchIndexing,
level: share.level as number,
sharedPage: share.sharedPage,
},
};
}

View File

@@ -0,0 +1,190 @@
import { ShareController } from './share.controller';
import {
PublicSharePayload,
toPublicSharePayload,
} from './share-public-payload';
// The `/shares/page-info` route is the ONLY anonymous path that serializes the
// full {page, share} records. Trimming the response to an explicit allowlist is
// a security control (#218): a regression that returns `...shareData` (or adds a
// new field to the allowlist) must fail loudly. These tests lock the exact key
// set returned to anonymous viewers so internal metadata can never silently leak.
const PAGE_KEYS = ['id', 'slugId', 'title', 'icon', 'content'].sort();
const SHARE_KEYS = [
'id',
'key',
'includeSubPages',
'searchIndexing',
'level',
'sharedPage',
].sort();
// A page row carrying internal metadata that MUST NOT reach anonymous viewers.
function internalPage() {
return {
id: 'page-1',
slugId: 'slug-1',
title: 'Public Title',
icon: '📄',
content: { type: 'doc', content: [] },
// --- leaky internals ---
creatorId: 'user-1',
lastUpdatedById: 'user-2',
contributorIds: ['user-1', 'user-2'],
spaceId: 'space-1',
workspaceId: 'ws-1',
parentPageId: 'parent-1',
position: 'aa',
isLocked: true,
isTemplate: false,
textContent: 'secret text content',
ydoc: Buffer.from('binary'),
createdAt: new Date('2020-01-01'),
updatedAt: new Date('2020-01-02'),
deletedAt: null,
} as any;
}
// A resolved share carrying internal metadata.
function internalShare() {
return {
id: 'share-1',
key: 'share-key',
includeSubPages: false,
searchIndexing: true,
level: 0,
sharedPage: { id: 'page-1', slugId: 'slug-1', title: 'Public Title' },
// --- leaky internals ---
creatorId: 'user-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
pageId: 'page-1',
createdAt: new Date('2020-01-01'),
updatedAt: new Date('2020-01-02'),
deletedAt: null,
} as any;
}
function buildController(over?: { aiAssistant?: boolean }) {
const shareService = {
// Deliberately returns the FULL internal records (as the real service does).
getSharedPage: jest.fn(async () => ({
page: internalPage(),
share: internalShare(),
})),
isSharingAllowed: jest.fn(async () => true),
};
const aiSettings = {
isPublicShareAssistantEnabled: jest.fn(
async () => over?.aiAssistant ?? false,
),
resolvePublicShareAssistantName: jest.fn(async () => 'Assistant'),
};
const licenseCheckService = {
resolveFeatures: jest.fn(() => ({ tier: 'free' })),
};
const controller = new ShareController(
shareService as any,
{} as any, // shareRepo
{} as any, // pageRepo
{} as any, // pagePermissionRepo
{} as any, // pageAccessService
licenseCheckService as any,
aiSettings as any,
{} as any, // auditService
);
return { controller, shareService, aiSettings, licenseCheckService };
}
const workspace = {
id: 'ws-1',
licenseKey: null,
plan: 'free',
} as any;
describe('ShareController.getSharedPageInfo — public payload whitelist (#218)', () => {
it('returns EXACTLY the page allowlist keys (no leaked internals)', async () => {
const { controller } = buildController();
const res = await controller.getSharedPageInfo(
{ pageId: 'page-1' } as any,
workspace,
);
expect(Object.keys(res.page).sort()).toEqual(PAGE_KEYS);
for (const leaked of [
'creatorId',
'lastUpdatedById',
'contributorIds',
'spaceId',
'workspaceId',
'parentPageId',
'position',
'textContent',
'ydoc',
'createdAt',
'updatedAt',
'deletedAt',
]) {
expect((res.page as any)[leaked]).toBeUndefined();
}
// The serialized payload must not carry the secret text content either.
expect(JSON.stringify(res.page)).not.toContain('secret text content');
});
it('returns EXACTLY the share allowlist keys (no leaked internals)', async () => {
const { controller } = buildController();
const res = await controller.getSharedPageInfo(
{ pageId: 'page-1' } as any,
workspace,
);
expect(Object.keys(res.share).sort()).toEqual(SHARE_KEYS);
for (const leaked of [
'creatorId',
'spaceId',
'workspaceId',
'pageId',
'createdAt',
'updatedAt',
'deletedAt',
]) {
expect((res.share as any)[leaked]).toBeUndefined();
}
});
it('surfaces the public AI-assistant flags and license features alongside the trimmed payload', async () => {
const { controller } = buildController({ aiAssistant: true });
const res = await controller.getSharedPageInfo(
{ pageId: 'page-1' } as any,
workspace,
);
expect(res.aiAssistant).toBe(true);
expect(res.aiAssistantName).toBe('Assistant');
expect(res.features).toEqual({ tier: 'free' });
// Top-level keys are limited to the trimmed payload + the public extras.
expect(Object.keys(res).sort()).toEqual(
['page', 'share', 'aiAssistant', 'aiAssistantName', 'features'].sort(),
);
});
});
describe('toPublicSharePayload — key set is the contract', () => {
it('copies only the allowlisted page/share keys', () => {
const payload: PublicSharePayload = toPublicSharePayload(
internalPage(),
internalShare(),
);
expect(Object.keys(payload.page).sort()).toEqual(PAGE_KEYS);
expect(Object.keys(payload.share).sort()).toEqual(SHARE_KEYS);
expect(payload.page.id).toBe('page-1');
expect(payload.share.key).toBe('share-key');
});
});

View File

@@ -36,6 +36,7 @@ import {
IAuditService,
} from '../../integrations/audit/audit.service';
import { AiSettingsService } from '../../integrations/ai/ai-settings.service';
import { toPublicSharePayload } from './share-public-payload';
@UseGuards(JwtAuthGuard)
@Controller('shares')
@@ -93,8 +94,13 @@ export class ShareController {
? await this.aiSettings.resolvePublicShareAssistantName(workspace.id)
: null;
// Trim the public payload to the explicit allowlist the anonymous renderer
// needs (#218); the PublicSharePayload type + mapper guarantee internal
// metadata can never leak to anonymous viewers (see share-public-payload.ts).
const { page, share } = shareData;
return {
...shareData,
...toPublicSharePayload(page, share),
aiAssistant,
aiAssistantName,
features: this.licenseCheckService.resolveFeatures(

View File

@@ -189,9 +189,9 @@ export class ShareService {
}
async getSharedPage(dto: ShareInfoDto, workspaceId: string) {
// Resolve via the single canonical boundary. There is no independent
// requested shareId here (the share is resolved FROM the page), so no
// share-id match is performed.
// Resolve via the single canonical boundary. The share is resolved FROM the
// page (the request carries the page slug), so the boundary itself performs
// no share-id match here.
const resolved = await this.resolveReadableSharePage(
null,
dto.pageId,
@@ -205,11 +205,85 @@ export class ShareService {
const { share, page } = resolved;
// Bind content to the requested share (#218). When the caller supplies a
// shareId/key (the `/share/:shareId/p/:slug` route now forwards it), the
// page must be reachable THROUGH that exact share — a forged or mismatched
// shareId must 404 instead of rendering the page off its slug alone, and it
// must not be answerable with the page's real (canonical) share key. A
// request with no shareId keeps the legacy slug-capability behavior (the
// `/share/p/:slug` route + internal title look-ups); the slug nanoid stays
// the access secret there — an inherited Docmost design we don't widen.
// FUTURE: this ancestor-aware match could fold INTO resolveReadableSharePage
// (so the boundary's narrow `share.id === shareId` gate isn't effectively
// dead). Deferred — it widens the contract for the 3 other callers that pass
// no shareId (share-alias.controller, share-alias.service, share-seo.controller);
// the two ai-chat callers (public-share-chat.controller,
// public-share-chat-tools.service) already pass a real shareId. Kept here as
// a local post-check until that consolidation is worth the blast radius.
if (dto.shareId) {
const reachable = await this.isPageReachableThroughShare(
dto.shareId,
share,
page.id,
workspaceId,
);
if (!reachable) {
throw new NotFoundException('Shared page not found');
}
}
page.content = await this.updatePublicAttachments(page);
return { page, share };
}
/**
* Does `requestedShareId` (a share id OR key) legitimately grant access to
* `pageId`? True when it names the page's own resolved share, or an ancestor
* share with `includeSubPages` that contains the page. Any other value
* (unknown key, wrong workspace, a sibling share that doesn't cover the page)
* is false, so a guessed slug paired with a forged shareId can't render.
*/
private async isPageReachableThroughShare(
requestedShareId: string,
resolvedShare: NonNullable<
Awaited<ReturnType<ShareService['getShareForPage']>>
>,
pageId: string,
workspaceId: string,
): Promise<boolean> {
// Fast path: the request names the page's own resolved share.
if (this.shareIdGrantsAccess(requestedShareId, resolvedShare)) {
return true;
}
// Otherwise it may name an includeSubPages ANCESTOR share: the page has its
// own closer share but is also served under the ancestor's public tree.
const requested = await this.shareRepo.findById(requestedShareId);
if (!requested || requested.workspaceId !== workspaceId) return false;
if (!requested.includeSubPages) return false;
const ancestor = await this.getShareAncestorPage(requested.pageId, pageId);
return !!ancestor;
}
/**
* Does the requested share id/key directly name `resolvedShare` — by id, or
* by key (case-insensitive)? This is the "names the page's OWN share" half of
* the access concept; ancestor includeSubPages shares are matched separately.
* Intentionally narrower than `resolveReadableSharePage`'s id-only gate, which
* keeps its own contract for the callers that pass a shareId there.
*/
private shareIdGrantsAccess(
requestedShareId: string,
resolvedShare: { id: string; key?: string | null },
): boolean {
return (
requestedShareId === resolvedShare.id ||
requestedShareId.toLowerCase() === resolvedShare.key?.toLowerCase()
);
}
async getShareForPage(pageId: string, workspaceId: string) {
// here we try to check if a page was shared directly or if it inherits the share from its closest shared ancestor
const share = await this.db
@@ -351,7 +425,14 @@ export class ShareService {
.limit(1)
.executeTakeFirst();
} catch (err) {
// empty
// Fail closed (return null -> caller 404s), but never silently: this is
// now a live public-share path (isPageReachableThroughShare), so a
// transient DB error here would otherwise turn a legitimate viewer of an
// includeSubPages descendant into a misleading "not found" with no trace.
this.logger.error(
`getShareAncestorPage failed (ancestorPageId=${ancestorPageId}, childPageId=${childPageId})`,
err instanceof Error ? err.stack : String(err),
);
}
return ancestor;

View File

@@ -31,10 +31,6 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsBoolean()
aiSearch: boolean;
@IsOptional()
@IsBoolean()
generativeAi: boolean;
@IsOptional()
@IsBoolean()
disablePublicSharing: boolean;

View File

@@ -145,7 +145,7 @@ export class WorkspaceService {
status = WorkspaceStatus.Active;
plan = 'standard';
billingEmail = user.email;
settings = { ai: { generative: true, chat: true } };
settings = { ai: { chat: true } };
}
// create workspace
@@ -439,20 +439,6 @@ export class WorkspaceService {
);
}
if (typeof updateWorkspaceDto.generativeAi !== 'undefined') {
const prev = settingsBefore?.ai?.generative ?? false;
if (prev !== updateWorkspaceDto.generativeAi) {
before.generativeAi = prev;
after.generativeAi = updateWorkspaceDto.generativeAi;
}
await this.workspaceRepo.updateAiSettings(
workspaceId,
'generative',
updateWorkspaceDto.generativeAi,
trx,
);
}
if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
const prev = settingsBefore?.sharing?.disabled ?? false;
if (prev !== updateWorkspaceDto.disablePublicSharing) {
@@ -587,7 +573,6 @@ export class WorkspaceService {
delete updateWorkspaceDto.restrictApiToAdmins;
delete updateWorkspaceDto.aiSearch;
delete updateWorkspaceDto.generativeAi;
delete updateWorkspaceDto.disablePublicSharing;
delete updateWorkspaceDto.mcpEnabled;
delete updateWorkspaceDto.allowMemberTemplates;

View File

@@ -21,6 +21,41 @@ export interface TreeNodeSnapshot {
position: string;
spaceId: string;
parentPageId: string | null;
// Death-timer deadline carried so the `addTreeNode` broadcast shows the
// temporary-note clock marker immediately on every client (incl. the author,
// whose optimistic insert can lose the race to this broadcast). null/absent =>
// permanent.
temporaryExpiresAt?: Date | string | null;
}
/**
* Single canonical builder for a `TreeNodeSnapshot` from a page-like row. Both
* the `PAGE_CREATED` event enrichment (`page.repo.insertPage`) and the
* `addTreeNode` broadcast (`WsTreeService.broadcastPageCreated`) build this same
* snapshot; routing both through here keeps the optional `temporaryExpiresAt`
* (and the `?? null` normalisation that pins a permanent note to an explicit
* null) from silently drifting between the two literals.
*/
export function toTreeNodeSnapshot(page: {
id: string;
slugId: string;
title: string | null;
icon: string | null;
position: string;
spaceId: string;
parentPageId: string | null;
temporaryExpiresAt?: Date | string | null;
}): TreeNodeSnapshot {
return {
id: page.id,
slugId: page.slugId,
title: page.title,
icon: page.icon,
position: page.position,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
temporaryExpiresAt: page.temporaryExpiresAt ?? null,
};
}
export class PageEvent {

View File

@@ -0,0 +1,19 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// `source` links an imported role back to its catalog origin
// `{ slug, language, version }`. Nullable: null => a manually-created role
// (no catalog provenance). The version lets the admin UI offer an UPDATE when
// the catalog ships a newer revision of the same slug.
await db.schema
.alterTable('ai_agent_roles')
.addColumn('source', 'jsonb', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('ai_agent_roles')
.dropColumn('source')
.execute();
}

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