Make the denser page-tree layout opt-in instead of hardcoded, so row
density can be toggled per deployment via the COMPACT_PAGE_TREE runtime
config flag.
- doc-tree: extract ROW_HEIGHT_STANDARD (32) / ROW_HEIGHT_COMPACT (26);
default the virtualizer row stride to STANDARD density.
- client: isCompactPageTreeEnabled() in lib/config (reads
COMPACT_PAGE_TREE, default true); used by space-tree and shared-tree
to choose the row height.
- server: EnvironmentService.isCompactPageTreeEnabled() and expose
COMPACT_PAGE_TREE through the window runtime config (static.module).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Release-cycle audit flagged WsService.invalidateSpaceRestrictionCache and
WsTreeService.notifyPageRestricted/notifyPermissionGranted as never-wired dead
code. Investigation: this community fork has NO page-permission grant/revoke/
restrict mutation site (the page-access repo mutators have zero callers — that
flow is EE / not yet built), so there is nothing to wire them into.
- Keep invalidateSpaceRestrictionCache (it's the one-line correctness primitive
the future permission-mutation path must call to avoid the 30s stale-cache
window) but document exactly that + add a test that it deletes only the
space-scoped cache key.
- Remove the untested, security-adjacent dead methods notifyPageRestricted /
notifyPermissionGranted and their now-orphaned helpers emitToUsers /
emitToSpaceExceptUsers (no remaining references; build confirms). A future
permission-change realtime feature can reintroduce them wired + tested.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Release-cycle review found two hardening gaps:
- The sync plugin deleted+rebuilt the WHOLE footnotesList on any reorder/orphan,
replacing every definition's Yjs subtree -> a collaborator typing in a
definition could lose in-flight characters on merge. Rework to targeted,
minimal mutations: attr-only setNodeMarkup for collision re-ids, delete only
genuine orphans, insert only genuinely-missing definitions (at the list end,
not shifting existing subtrees), and consolidate multiple lists only in the
abnormal paste/merge case. An unchanged (correct id, referenced) definition is
left completely untouched. Numbering is decoration-only, so physical list order
may drift after a reorder (accepted) while displayed numbers stay correct.
Invariants preserved (reviewed + tested): one SYNC_META transaction, null when
canonical (terminates), deterministic deriveFootnoteId, remote-skip -> no
re-introduced freeze or divergence.
- computeFootnoteNumbers ran per-NodeView-render (O(n^2)/keystroke in big docs).
The numbering plugin now caches the number map in its state (computed once per
docChanged); NodeViews read it O(1) via getFootnoteNumber.
Tests: no-rebuild-on-reorder asserts unchanged definition node subtrees are
identity-preserved; isRemoteTransaction skip; enableSync:false read-only; cache
correctness. Browser re-smoke: insert (no freeze), number, persist across reload,
cascade delete all pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Release-cycle review: POST /pages/template/lookup had only JwtAuthGuard and the
embed depth cap was client-only, so a scripted client could drive heavy
full-content fan-out (access control holds per-id, but a cost/DoS gap). And
page_template_references rows were written for any sourcePageId with no
workspace check at sync time (no leak today since lookup re-checks access, but
the graph could accumulate cross-space rows).
- Apply the standard per-user throttler (PAGE_TEMPLATE_THROTTLER, 30/min) to
/pages/template/lookup and /pages/toggle-template (mirrors ai-chat); auth +
the toggle's validateCanEdit CASL are unchanged.
- syncPageTemplateReferences / insertTemplateReferencesForPages now restrict
inserts to in-workspace source ids (filterInWorkspaceSourceIds, workspace +
not-deleted scoped, trx-aware) and still delete stale out-of-workspace rows
(self-heal). SECURITY comment: the ref table is NOT access-filtered; every
consumer must permission-filter at read time (as lookupTemplate does).
- Tests: lookup access exercises the REAL filterViewerAccessiblePageIds
(no_access / cross-workspace excluded / accessible+comment-stripped / <=50);
toggle controller CASL (cannot-edit -> Forbidden, flag not flipped); ref-sync
excludes cross-workspace and keeps in-workspace.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Release-cycle review: the per-workspace cost cap was fixed-window + per-instance
(allowed ~2x at a window boundary and K*cap behind K instances) on an anonymous
endpoint that spends the owner's provider budget. Rewrite it as a sliding-window,
CLUSTER-WIDE Redis limiter: one atomic Lua EVAL does ZREMRANGEBYSCORE (age out)
-> ZCARD -> ZADD with PEXPIRE, so concurrent instances share one budget and the
true rate over any trailing window is <= cap. Fails OPEN on a Redis error (logged)
— it's a cost backstop, not access control (the funnel gates + per-IP throttle
still apply), so a Redis blip must not take the assistant offline. Per-IP @Throttle
kept; commented that it needs an XFF-rewriting trusted proxy to be meaningful.
Extract deriveShareAccess (resolvedShareId===requestedShareId + isSharingAllowed +
!restricted, equality-only, never widening) and filterShareTranscript into pure
helpers, and add tests: limiter sliding-window + boundary-burst + fail-open;
access derivation; and red-team boundary locks (cross-share/cross-workspace swap
rejected, forged shareId can't widen tool scope, transcript injection filtered).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Release-cycle test audit: the strip boundary was tested only via a stand-in
helper re-implemented in the spec, so a deleted/misplaced guard kept CI green
(the missing create() guard was proof). Replace it with tests against real code:
- persistence.extension.onStoreDocument: real ydoc from a rich doc (columns/
table/mention/htmlEmbed) -> non-admin strip removes only htmlEmbed, every other
node preserved (data-loss guard); admin keeps; empty fragment no-throw.
- collaboration.handler.updatePageContent: real path, user?.role gate, decoded
ydoc embed-free for non-admin, kept for admin.
- transclusion unsync: member stripped, admin preserved.
- editor-ext gains a vitest setup (was zero tests) + a markdown round-trip:
the <!--html-embed:BASE64--> marker -> htmlEmbed node with decoded source, and
hasHtmlEmbedNode matches it — pinning the marked/turndown shape the import
strip relies on. tsconfig now excludes specs from the shipped dist.
- Fail-closed identity: source-pinned contracts that the gate keys on
fileTask.creatorId (zip) / request userId (single) / callerRole (create) /
authUser.role (duplicate), and missing-user -> strip (services can't load under
jest's ESM graph; helpers replay the exact predicate).
Adds the verified-safe ^src/ jest moduleNameMapper (identical fail set).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Release-cycle test audit: the /mcp auth's constant-time token guard, IP keying,
ACCESS-type pinning, and brute-force message coupling were untested. Extract
behavior-preserving pure helpers so they're testable and cover them:
- sharedTokenMatches: length-mismatch early-returns before timingSafeEqual
(which throws on unequal lengths); equal-length uses timingSafeEqual; array
header -> first element; non-string -> false.
- clientIp: req.ip > socket > first XFF hop > 'unknown' (limiter keying).
- bindAccessJwtVerifier: verifyJwt pinned to JwtType.ACCESS (rejects REFRESH).
- CREDENTIALS_MISMATCH_MESSAGE single source of truth shared by
verifyUserCredentials and isCredentialsFailure, so a reworded auth error can't
silently disable the /mcp brute-force counter.
- verifyUserCredentials no-side-effect contract asserted via a TS-AST spec
(AuthService can't load under jest): its body has no createSessionAndToken/
audit/updateLastLogin while login() has all three.
Extractions are behavior-preserving (reviewed); class delegates to the helpers,
dead code + unused imports removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Release-cycle test audit found the role feature's security-critical paths
untested. Adds real unit tests (against the actual functions):
- resolveRoleForRequest invariants: role comes from chat.roleId not body.roleId
(no per-turn swap), lookup scoped to workspace.id, disabled/soft-deleted role
-> null, new-chat uses body.roleId, stale chatId falls back.
- CASL admin gate: non-admin create/update/delete -> Forbidden and service not
called; admin delegates with workspace.id; list() is member-reachable.
- roleModelOverride: unknown driver dropped (never reaches getChatModel's
throwing default), valid override passes through, blanks ignored.
- getChatModel override success path (cross-driver fetch + decrypt; chatModel-
only reuse), and service update/remove cross-workspace 'not found' guards +
modelConfig tri-state.
Tiny fix: findByCreator badge left-join now also requires enabled=true, so a
disabled role (downgraded to universal by resolveRoleForRequest) no longer shows
a misleading chat-list badge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Release-cycle review: update() re-read the role via findById (filters
deleted_at IS NULL) and passed it straight to toView(updated as AiAgentRole).
A concurrent soft-delete between the UPDATE and the re-fetch makes findById
return undefined, and toView(undefined) dereferences row.id -> opaque 500. Add
the same 'Role not found' guard remove() already uses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Comments panel was sparse: 12px inner/outer paddings per thread, a
16px gap between avatar and body, body text at the global 16px ProseMirror
size. On a narrow aside column this ate vertical space - few comments per
screen, lots of air.
Tighten strictly inside features/comment (the shared aside frame is left
untouched, so TOC/Details tabs keep their padding):
- Thread Paper: p='sm'->p='xs', mb='sm'->mb='xs' (12->10px).
- Reply-editor Divider: my={4}->my={2}.
- CommentListItem outer Box: pb='xs'->pb={6}; the header Group
(avatar + body) gains gap='xs' (16->10px).
- Font hierarchy: author name sm->xs (14->12px, fw=500 kept), selection
quote sm->xs; comment body via a scoped CSS override on
.commentEditor .ProseMirror: font-size sm (14px) + line-height 1.4,
margin-top 10->4. The page editor is unaffected (the override is
scoped to the comment editor module).
- Selection quote padding 8->6, margin-top 4->2.
- Dropped the unused .wrapper rule (no references).
Release-cycle review found two move-path issues:
- Remote moves were placed at index:0 (broadcastPageMoved hardcodes index:0),
so every observer rendered the moved node at the TOP of its new siblings
until refetch. Client moveTreeNode now places by fractional position
(treeModel.placeByPosition, mirroring addTreeNode/insertByPosition) and
applies the payload's pageData (title->name, icon, hasChildren) so receivers
keep the node correct.
- Moving a page under a restricted ancestor left a stale named node (title/
slugId/icon) in the trees of users who lost visibility. broadcastPageMoved
now derives one FRESH hasRestrictedAncestor decision and drives both paths
from it: when restricted, the move goes to authorized users only
(emitToAuthorizedUsers, not the space-cache-gated emitTreeEvent) and a
compensating deleteTreeNode goes to the unauthorized complement (same fresh
getUserIdsWithPageAccess set) — disjoint, no stale-cache window. Non-restricted
moves are unchanged (one moveTreeNode to the room).
Follow-up (noted): invalidateSpaceRestrictionCache is still unwired at
permission-mutation sites; the open-space fast path can lag up to the 30s TTL,
but the move/delete consistency above no longer depends on it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The PasswordInput for each endpoint API key (Chat / LLM, Embeddings,
Voice / STT) used to show Mantine's built-in visibility toggle (the
'eye') plus a separate 'Clear' link below the field. The eye is useless
here: the key field is a write-only buffer, the stored key never loads
back (the server only returns hasApiKey), so clicking the eye reveals an
empty buffer.
Replace it with a Clear ActionIcon in the field's right section. Passing
a custom rightSection suppresses the built-in eye (Mantine). The Clear
action appears ONLY when a key is stored AND the buffer is empty
(has*ApiKey && form.values.*ApiKey.length === 0); as soon as the user
starts typing a new key, the rightSection falls back to undefined and
the default eye returns - now it is useful (verify what was typed).
After Clear, the handler sets has*ApiKey=false, so the rightSection
flips back too. Self-consistent.
The old Stack wrapper and Anchor 'Clear' link are gone; Anchor is
removed from the @mantine/core import (no remaining usages). The Clear
icon-only button carries type='button' (never submits) and an
aria-label. The two-column 'Model | API key' layout and the write-only
buffer/handler semantics are unchanged.
The header dot on each AI endpoint card (Chat / LLM, Embeddings, Voice /
STT) used to reflect the last 'Test endpoint' probe result - green/red/
gray. That was misleading: a configured-and-enabled endpoint showed GRAY
until someone manually clicked 'Test endpoint'. The dot now reads as the
endpoint's health at a glance, derived synchronously from the live form
values + the workspace feature toggle - never from a network probe.
Four-state model (resolveCardStatus):
ready (green) - configured AND enabled
configured (yellow) - configured but the feature toggle is OFF
off (gray) - not configured (nothing to enable)
warning (orange) - enabled but not configured (a real misconfig:
the feature is on but will not work; surfaced
instead of hidden under gray)
'configured' = model field non-empty AND a base URL available (own OR
inherited from chat for embeddings/STT). The API key is optional - local
servers (Ollama, speaches) work without one. Source of truth is the live
form.values so the dot reacts as the admin types; the persistent feature
toggles drive the enabled axis. The 'Test endpoint' probe result stays
as text under the button - it just no longer paints the dot.
A Tooltip with a human-readable label wraps the dot so the state is not
color-only (colorblind-friendly). resolveCardStatus is exported and
covered by a Vitest spec (4 cases, including the misconfig branch).
Release-cycle review found the /mcp Basic path skipped the controller's
pre-token gates and over-eagerly minted sessions:
- SSO/MFA bypass (blocker): the Basic path called AuthService.login/
verifyUserCredentials directly, but validateSsoEnforcement + the lazy EE MFA
gate live in AuthController.login. Now enforceBasicLoginGate runs in the Basic
branch BEFORE any token is minted: validateSsoEnforcement(workspace) (reject
on enforced SSO) and the same lazy-require MFA check the controller uses
(reject MFA users -> 'use a Bearer access token'). No EE module bundled (this
fork) -> no MFA gate, identical to the controller; a throw from the check
fails closed (no token). Bearer/service-account paths are not gated (those
JWTs are minted post-gate).
- Non-init session mint: isSessionInit is now (no mcp-session-id) AND the body
is a real JSON-RPC initialize (isInitializeRequestBody). A header-less
non-initialize request takes the side-effect-free verifyCredentials path -> no
user_sessions row, no USER_LOGIN audit, no lastLoginAt bump.
- FailedLoginLimiter.sweep() now runs on an unref'd 60s interval, cleared on
module destroy (was never scheduled -> unbounded Map growth under XFF rotation).
- Subsequent (non-init) valid login no longer resets the global per-email brute
bucket (only per-IP / per-IP+email); the email backstop is reset only on a
deliberate init login.
Note: in a hypothetical EE build, checkMfaRequirements is called with no
FastifyReply (we only read requirement flags); a res-dereferencing EE impl would
surface as a clean rejection (fail-closed), not a bypass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Release-cycle red-team found getShareForPage joins only the shares table, so it
does not exclude restricted descendants. The public share VIEW (getSharedPage)
compensates with hasRestrictedAncestor, but the assistant's getSharePage tool
and the controller funnel did not — so an anonymous caller could read a
restricted descendant's content (tool) or surface its title into the system
prompt (funnel) within an includeSubPages share.
- getSharePage: after the share-membership check and before returning content,
reject with the generic 'not part of this published share' message when
hasRestrictedAncestor(page.id) is true (page.id is the resolved UUID, so
slugId inputs work). Inject PagePermissionRepo.
- funnel: resolve the OPENED page to its UUID and treat a restricted opened page
as not-in-share (same uniform 404, fail closed if unresolvable) so its title
never reaches buildShareSystemPrompt.
search/list already exclude restricted subtrees (getPageAndDescendantsExcludingRestricted),
so these were the only two bypasses. Generic messages keep restricted
indistinguishable from not-in-share.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Release-cycle red-team found the admin-only gate missed PageService.create():
content/textContent/ydoc were derived and persisted without the strip, so any
space member could POST /pages/create with an htmlEmbed node (incl. the
markdown/html <!--html-embed:BASE64--> form) and store executing JS for every
reader. Add the same gate used by duplicatePage: strip htmlEmbed when the
caller is not a workspace admin/owner. Role is plumbed from the controller
(user.role); unknown role => non-admin (strip). All four create paths (create,
duplicate, single import, zip import) plus the update paths are now guarded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds footnotes: a superscript marker in the text linked to an editable
definition in a Footnotes section at the end of the page, with auto-numbering
and a read-only hover popover. Chose the reference+definitions model (3 plain
nodes) over an inline atom with a sub-editor specifically for collaboration
safety.
editor-ext (packages/editor-ext/src/lib/footnote/):
- footnoteReference (inline atom, id), footnotesList (block, last child),
footnoteDefinition (paragraph+, id). renderHTML emits sup[data-footnote-ref]
/ section[data-footnotes] / div[data-footnote-def]; parse-rule priority makes
the empty reference win over the Superscript mark (else it is dropped on the
server save).
- numbering: a decoration-only plugin (pure function of doc order) -> every
client computes identical numbers, no document mutation, Yjs-safe.
- sync plugin: single-pass, always SYNC_META-tagged and skipping remote txns
(terminates, no loop), idempotent; canonicalizes to one trailing footnotesList
(merging duplicates), creates missing definitions, drops orphans, and
coexists with TrailingNode. Disabled in read-only.
- commands setFootnote (one tx: reference + definition at the matching index +
focus) / removeFootnote (cascade, one undo) / scrollTo*. slash /footnote.
client: superscript NodeView + floating-ui read-only popover; bottom-list and
definition NodeViews; registered in mainExtensions.
server: the three nodes registered in tiptapExtensions so collab/save/export
keep them. Round-trip regression spec guards the Superscript parse-priority.
markdown: turndown/marked round-trip to pandoc/GFM [^id] (+ a code-fence guard
so footnote-like lines inside code blocks are not extracted).
MCP mirror: schema + markdown-converter + commentsToFootnotes rewritten to real
footnote nodes + diff marker counting; NUL sentinels written as \u0000 escapes.
v2 follow-ups (per plan): definition reordering on reference move, id-collision
regeneration on paste, multiple references to one footnote.
Implements docs/footnotes-plan.md (variant B).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Embed another page's LIVE content into a host page (it updates when the source
changes, not a static copy). A page can be flagged a template for discovery in
the picker; any accessible page can be embedded.
Server:
- migrations: pages.is_template (+ partial index) and page_template_references
(whole-page back-refs); db.d.ts/entity types hand-merged (db.d.ts is curated).
- POST /pages/toggle-template (CASL Edit) flips is_template; is_template is
returned by findById + the sidebar tree select so the tree menu label
reflects state. Search suggestions gain an onlyTemplates filter for the picker.
- POST /pages/template/lookup ({sourcePageIds[]}, <=50): returns each accessible
source's {title, icon, slugId, content, sourceUpdatedAt} with comment marks
stripped (same access path as transclusion: filterViewerAccessiblePageIds;
inaccessible -> no_access, missing -> not_found; error path -> not_found, never
raw content).
- reference sync (collectPageEmbedsFromPmJson + syncPageTemplateReferences) on
the Yjs save hook; duplicatePage remaps pageEmbed.sourcePageId + inserts refs.
Known MVP gap: REST content updates don't resync refs (lookup uses in-doc ids).
Client:
- pageEmbed node (editor-ext, registered in BOTH client + server schemas);
read-only NodeView with a batching lookup; '/Embed page' slash + template
picker (self-embed prevented); 'Make/Unset template' in the tree node menu.
- Cycle guard: an ancestry-chain context + depth cap (5) render a 'circular
embed' placeholder instead of recursing.
- Public shares show a placeholder (no public lookup in MVP).
MVP excludes (follow-ups): public-share lookup, unsync->static copy, server-side
expansion for export/RAG, MCP schema mirror, point-in-time snapshots.
Implements docs/page-templates-plan.md (MVP, variant A).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an htmlEmbed block node that renders and executes raw HTML/CSS/JS in the
wiki origin (e.g. an analytics tracker) — the owner-chosen variant C. Because
this is stored-XSS by design, only workspace admins/owners may get such a node
persisted; everyone executes it when reading.
- Node (editor-ext): htmlEmbed atom/isolating block; source stored base64 in
data-source for lossless HTML<->JSON round-trip. renderHTML emits only the
encoded marker (never inlines raw markup), so generateHTML/export/search are
not themselves injection vectors. Registered in BOTH client extensions and
server tiptapExtensions. Markdown round-trip via an <!--html-embed:b64-->
comment (turndown) + a marked rule.
- Client NodeView: injects source and re-creates <script> elements so they
actually run; edit modal; renders in read-only/share too. Slash item is
admin-gated (adminOnly filtered by the user's workspace role).
- SERVER ENFORCEMENT (the real control — UI gating alone is insufficient):
stripHtmlEmbedNodes() removes htmlEmbed from any document persisted by a
non-admin, applied at every write path that introduces content from an
untrusted author: collab onStoreDocument, REST/MCP/AI updatePageContent,
single-file import, zip/multi-file import, page duplication, and transclusion
unsync. Page restore introduces no new content. Public share/readonly viewers
render fetched (already-stripped) content and do NOT open a collab socket, so
the only residual is a transient broadcast window to concurrent authenticated
editors (documented).
Implements docs/arbitrary-html-embed-plan.md (variant C).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sidebar page tree only updated on other clients when a change was made
via the UI tree, in an open tab, within a ~50ms client relay window — API/MCP/
AI/import changes never propagated. Move the source of truth to the server.
Server:
- Enrich PageEvent with thin TreeNodeSnapshot(s) so the WS listener never reads
the DB (avoids the in-transaction visibility race). insertPage fills the
create snapshot from its returning() row; removePage ships only the deleted
subtree ROOT (client treeModel.remove drops descendants); restorePage carries
spaceId.
- New PAGE_MOVED event from movePage with old/new parent + position + snapshot
(generic PAGE_UPDATED stays for content/rename).
- WsService.emitTreeEvent mirrors emitCommentEvent (per-space restriction gate:
spaceHasRestrictions -> hasRestrictedAncestor -> broadcastToAuthorizedUsers);
author NOT excluded so non-UI creators see their own page (receiver is
idempotent).
- WsTreeService.broadcastPageCreated/Deleted/Moved + broadcastRefetchRoot;
new PageWsListener (create/delete/move/restore) registered in WsModule.
Client:
- Remove the client relay (emit + setTimeout(50)) from create/move/delete;
keep optimistic local updates. Make the optimistic create insert id-idempotent
(find-then-skip) so the now-fast server addTreeNode broadcast can't race it
into a duplicate row. addTreeNode inserts by fractional position among loaded
siblings (consistent order across clients).
Restore uses refetchRootTreeNodeEvent (robust for subtree re-attach). Rename/icon
updateOne and cross-space move realtime are deferred (commented as follow-ups).
Implements docs/backlog/realtime-tree-server-authoritative.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lets an unauthenticated viewer of a published share ask an AI scoped strictly
to that share's page tree. The authenticated agent is untouched; the security
boundary is the tool scope (no identity), and nothing is persisted.
Server:
- workspace toggle settings.ai.publicShareAssistant (default off) +
optional settings.ai.provider.publicShareChatModel (cheap model id; reuses
the chat driver/baseUrl/key). getChatModel(workspaceId, override) substitutes
only the model id, falling back to chatModel.
- POST /api/shares/ai/stream (@Public, SSE). Guardrail funnel, each failing
before streaming: toggle off -> 404; share missing/wrong-workspace/sharing
off -> 404; pageId not in share tree -> 404; provider unconfigured -> 503;
per-IP (5/min) and per-workspace (300/h, IP-independent) rate limits -> 429.
Uniform 404s never confirm a private page's existence.
- forShare read-only in-process toolset: searchSharePages (existing shareId
FTS branch, no spaceId/userId), getSharePage (getShareForPage gate +
share.id check, content via the public sanitizer), listSharePages. No write/
comment/history/cross-space/external-MCP tools.
- Locked share system prompt + immutable safety block; stepCountIs(5).
- /shares/page-info exposes an aiAssistant flag (gated behind isSharingAllowed).
Client: an ephemeral, text-only Ask-AI widget on the public shared page,
shown only when the flag is set; useChat -> /api/shares/ai/stream,
credentials omit. Admin toggle + model field in Settings -> AI.
Also adds a jest moduleNameMapper for src/-rooted imports (fixes pre-existing
unresolvable specs; additive).
Implements docs/public-share-assistant-plan.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The embedded MCP server acted as a single service account; now each /mcp
session authenticates as the current user, so tools run under that user's
CASL and edits attribute to them.
- HTTP Basic (chosen path): Authorization: Basic email:password, validated
server-side via AuthService; the session carries the issued user JWT (not
the raw password). Password may contain ':' (split on first only).
- Bearer fallback: Authorization: Bearer <access JWT>, verified as ACCESS and
additionally checked for an active session + non-disabled user (matching
JwtStrategy), so revoked/disabled users are rejected.
- Service account stays as an optional fallback (no creds + env configured).
- packages/mcp createMcpHttpHandler accepts a per-request config resolver
(back-compat: static config / stdio unchanged); identity is bound to the
mcp-session-id at init and re-validated from the caller's own credentials on
every request (anti session-fixation: a guessed session id can't be reused
without matching creds).
- A full login (session + audit) happens only once at session init; later
requests re-verify credentials via a new non-side-effecting
AuthService.verifyUserCredentials (no session/audit spam).
- Failed-login limiter (5/60s, keyed per-IP, per-IP+email, and per-email so IP
rotation can't brute one account) since direct login bypasses the controller
throttler. Only real credential failures count.
- MCP_TOKEN shared guard moved off Authorization to an X-MCP-Token header
(timing-safe compare); credsConfigured 503 gate replaced by a clear 401.
- No secrets logged; all auth resolved before res.hijack() so failures return
clean 401 JSON. .env.example marks the service account optional.
Implements docs/backlog/mcp-per-user-auth.md (variant L).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reusable, workspace-shared agent roles for the built-in AI chat. A role is
a named persona (system-prompt instructions) + optional model override; a
chat is bound to a role at creation and applies it every turn.
Backend:
- migration 20260620T120000: ai_agent_roles table + ai_chats.role_id
(FK ON DELETE SET NULL); hand-merged types into db.d.ts/entity.types.ts
(db.d.ts is hand-curated here, full codegen would clobber it).
- core/ai-chat/roles: CRUD module. list = any workspace member; create/
update/delete = admin (Manage Settings ability, like ai-settings/mcp).
All repo queries scoped by workspace_id; soft-delete (deleted_at).
- buildSystemPrompt gains roleInstructions: role REPLACES the persona base
(admin prompt / DEFAULT_PROMPT) but SAFETY_FRAMEWORK + context are always
still appended.
- stream(): role resolved from ai_chats.role_id for existing chats (never
the request body -> no per-turn role swap); body.roleId only on creation.
Disabled (enabled=false) and soft-deleted roles fall back to universal.
- getChatModel(workspaceId, override): role model_config can swap model id /
driver; a driver without configured creds throws 503 with a clear message
naming the driver+role, resolved BEFORE response hijack.
Client:
- new-chat role picker (enabled roles only, default Universal assistant),
roleId sent only on the first message; role badge (emoji+name) in the chat
header and conversation list; admin Agent-roles management section in
Settings -> AI (add/edit/delete, MCP-form pattern).
Tests: ai-chat.prompt.spec (role layering + safety always present, incl.
jailbreak); ai.service.spec (override on unconfigured driver -> 503).
Implements docs/ai-agent-roles-plan.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A narrow research question could burn all 8 steps on tool calls and end the
turn with no assistant text (empty turn). Two changes:
- MAX_AGENT_STEPS = 20 (was a magic stepCountIs(8)) so multi-search turns
aren't cut off mid-investigation.
- prepareStep reserves the LAST allowed step for a text-only synthesis:
toolChoice 'none' + a FINAL_STEP_INSTRUCTION appended to (not replacing)
the system prompt, so a tool-heavy turn always ends with a real answer.
Logic extracted into the pure, exported prepareAgentStep(stepNumber, system)
for unit testing; earlier steps return undefined (default behavior).
Implements docs/backlog/ai-chat-step-limit-and-forced-final-answer.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a server-authoritative whole-tree endpoint and sidebar menu commands
so a deep space tree can be expanded in one request instead of a per-level
BFS storm.
Server:
- POST /pages/tree (SidebarPageTreeDto: spaceId | pageId), same CASL space
scoping as /sidebar-pages. Returns the whole space tree / subtree as a flat
list in the sidebar item shape (id, slugId, title, icon, position,
parentPageId, spaceId, hasChildren, canEdit), ordered by position
(collate C byte order), content never fetched.
- page.service.getSidebarPagesTree reproduces getSidebarPages' two-branch
permission model: open space -> spaceCanEdit; restricted space -> seed the
full descendant set then prune via filterAccessibleTreePages +
filterAccessiblePageIdsWithPermissions (keeps restricted-but-granted pages,
prunes inaccessible subtrees). hasChildren is derived from the final
filtered set so it can never reveal inaccessible children.
- page.repo.getSpaceDescendants: recursive CTE seeded by space roots.
Client:
- SpaceTree is forwardRef exposing expandAll/collapseAll/isExpanding;
expandAll fetches the whole tree once, replaces current-space nodes, opens
every branch (current space only), aborts on space switch, surfaces real
errors; collapseAll collapses only current-space ids (shared open-map).
- SpaceMenu gains Expand all / Collapse all items (no admin gate).
Implements docs/backlog/tree-expand-collapse-all.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
These import paths relied on the private EE module that was deleted from
the repo. In the community build they either threw 'enterprise license'
(DOCX/PDF) or silently no-op'd (Confluence). The frontend buttons were
already removed in 38064064; this cleans up the dead backend stubs.
- import.service.ts: drop processDocx/processPdf methods, their dispatcher
branches, the pageId computation + insertPage spread, and the now-unused
moduleRef param/ModuleRef import
- file-import-task.service.ts: drop the Confluence branch and the now-unused
moduleRef param/ModuleRef import
- import.controller.ts: restrict file extensions to .md/.html and zip
sources to generic/notion; update the error message accordingly
- file.utils.ts: remove Confluence from the FileImportSource enum
- features.ts: remove the unused CONFLUENCE_IMPORT/DOCX_IMPORT/PDF_IMPORT
feature keys
The isConfluenceImport logic in import-attachment.service.ts is intentionally
left in place (real shared attachment-parsing code, not a stub); its removal
is a separate, riskier refactor.
The two catch blocks in importPage() threw an opaque "Error processing file
content" / "Failed to create imported page" BadRequest, hiding the real cause
from the HTTP response. This made a production 400 regression impossible to
diagnose without server log access, and violated the project convention that
errors must never be swallowed.
Extract `${err.name}: ${err.message}` into both the log (full err object kept
for the stack) and the thrown BadRequestException. Inner processMarkdown/
processHTML rethrowing catches and the EE processDocx/processPdf license
catches are left unchanged.
Local reproduction of the happy-dom 14->20 theory failed (full import chain
+ 22 edge cases pass on happy-dom@20.8.9), so the root cause is still pending
the now-visible reason from a recurring 400. Diagnostic script test-import.tsx
added; backlog doc updated with findings.
- Remove automatic panel opening in handleAddComment
- Remove automatic panel opening in handleAddReadOnlyComment
- Keep panel open on click for existing comments in editor
Reduce DocTree row stride from 32px to 26px for a denser sidebar tree,
and fix the selection/hover highlight that looked unbalanced at the
tighter spacing.
Root causes:
- The virtualized <li> had no explicit height, so `.node`'s height:100%
collapsed to content height; combined with the asymmetric
`[role="treeitem"] { padding-bottom: 2px }` rule, row content was
pushed to the top of the highlight pill (icon glued to the top edge).
- NodeMenu / CreateNode action icons used the default Mantine ActionIcon
size (md = 28px), overflowing the tighter 26px row stride onto
neighbouring rows.
Changes:
- doc-tree.tsx: rowHeight 32 -> 26; give each row <li> a definite
height = rowHeight.
- tree.module.css: rowWrapper fills the slot (height:100%); node pill is
inset and vertically centered (height: calc(100% - 4px)); drop the
asymmetric [role="treeitem"] padding-bottom.
- space-tree-node-menu.tsx / space-tree-row.tsx: action icons size={20}.
- share.module.css: drop now-dead .treeNode padding-bottom override.
Verified in an isolated browser harness: highlight content is centered
(2.8/2.8px) and nothing overflows the row stride.
Reduce the recent/favorites/created-by-me list tables from
verticalSpacing="sm" (12px Td padding) to a numeric 6px, removing
~12px of extra height per row so the home page lists pack closer
together. The shared RecentChanges table also drives the space home
view, so both stay consistent.
Drop the Overview home link that sat between the space switcher and
the Pages section in the authenticated space sidebar. Remove the JSX
block and clean up the now-unused imports (UnstyledButton, IconHome,
useLocation, getSpaceUrl) and the local `location` variable.
Reduce DocTree default rowHeight from 32px to 26px so sidebar page
tree rows pack closer together. The virtualizer uses rowHeight as the
row stride (estimateSize + translateY), and row content is only ~22px
tall, so the previous 32px left an ~8px gap between nodes. Both the
space tree and the shared (public) tree inherit the default, so both
become denser and stay consistent.
Rework the space sidebar:
- remove the "New page" and "Search" menu items (search stays in the app
header; page creation stays via the "+" button in the Pages section)
- move "Space settings" into a gear icon next to the current space name
- drop the searchable space popover and render all spaces as an
always-visible grid of fixed-width cards (icon + name), several per row,
sorted alphabetically, with the active space highlighted
- always inject the active space into the grid so it stays highlightable
even when the user has more than the 100-space API page limit
The shared SpaceSelect component is left untouched (still used by the
move/copy page modals).
list_pages gains an opt-in `tree` parameter on both surfaces (the
@docmost/mcp server tool and the AI-chat agent tool), which share the
same DocmostClient.listPages. Default behavior (recent-by-updatedAt flat
list) is unchanged.
- client.ts: listPages(spaceId?, limit=50, tree=false); when tree is
true it requires spaceId (throws a specific error otherwise), walks the
sidebar tree via the existing bounded/cycle-safe enumerateSpacePages,
and returns a nested tree; limit is ignored in tree mode.
- lib/tree.ts: new pure buildPageTree() — lean nodes { id, slugId, title,
children? }, children sorted by position (code-unit order), orphans
promoted to roots, cycle-safe.
- index.ts + ai-chat-tools.service.ts: expose `tree` in the tool schemas
and descriptions; docmost-client.loader.ts: mirror the new signature.
- tests: add packages/mcp/test/unit/tree.test.mjs (nesting, ordering,
lean shape, orphan promotion, cycle/self-reference safety).
- rebuild @docmost/mcp (build/ is tracked and loaded at runtime).
Wrap the logo link and the APP_VERSION text into a single bottom-aligned
Group so they read as one lockup ("gitmost v0.9..."). Move the version
styling into a new .brandVersion CSS class: shrink it from 12px to 10px,
keep the dimmed color and selectability, and lift it via margin-bottom so
its text baseline sits on the wordmark baseline of the 30px desktop logo
(derived from the logo SVG geometry). Drop the redundant lh prop.
The transcript force-scrolled to the bottom on every streamed delta because
the auto-scroll effect ran unconditionally whenever the messages array identity
changed. Scrolling up to read earlier messages was impossible — each token
yanked the view back down.
Implement a "stick to bottom" pattern in MessageList:
- track whether the viewport is pinned to the bottom via a scroll listener
(pinnedToBottomRef, BOTTOM_THRESHOLD = 40px);
- only auto-scroll while pinned; a freshly sent user message always re-pins;
- attach the scroll listener via a [hasScrollArea] dependency so a brand-new
empty chat (whose ScrollArea mounts only after the first message) wires it up;
- guard the effect's own scrollTop write (programmaticScrollRef) so it is not
misread as a user scroll.
The floating AI-chat header badge summed metadata.usage (AI SDK
totalUsage, all steps) across every assistant row, showing the
cumulative tokens SPENT — which grows each turn as history is re-sent.
Replace it with the conversation's CURRENT context size.
- server: persist metadata.contextTokens in streamText onFinish from the
final-step `usage` (inputTokens + outputTokens ≈ current context
window occupancy); keep usage: totalUsage for back-compat/fallback
- client: derive the badge from the most recent assistant row's
contextTokens (fallback to that row's usage total for older chats)
instead of summing all rows
- types: add metadata.contextTokens to IAiChatMessageRow
- i18n: rename badge label "Tokens used in this chat" -> "Current
context size" (en-US)
No DB migration needed (metadata is a JSON column).
Replace the implicit `hostname endsWith openrouter.ai` detection with an
explicit, admin-chosen provider field `sttApiStyle` ('multipart' = OpenAI-
compatible multipart /audio/transcriptions; 'json' = OpenRouter-style JSON +
base64 input_audio). The transcription path now branches on the stored field,
not on the URL — nothing hidden from the admin.
- ai.types: add SttApiStyle + STT_API_STYLES; field on AiProviderSettings and
MaskedAiSettings (resolved via ResolvedAiConfig).
- update-ai-settings.dto: validate sttApiStyle with @IsIn(STT_API_STYLES).
- ai-settings.service: plumb sttApiStyle through resolve()/getMasked() and the
non-secret update whitelist; workspace.repo: add it to the ALLOWED array so it
persists.
- ai.service: drop isOpenRouter(); transcribe() branches on cfg.sttApiStyle;
rename helper to transcribeJsonBase64 with provider-neutral error text and a
BadRequestException (400) when the base URL is missing for the JSON style.
- client: SttApiStyle type on IAiSettings/IAiSettingsUpdate; "Request format"
Select on the Voice/STT settings card; i18n.
- ai.service: route *.openrouter.ai STT to its JSON+base64
/audio/transcriptions API; keep the OpenAI multipart path (AI SDK) for
OpenAI/self-hosted whisper. Unify transcription behind transcribe().
- /transcribe controller: surface the real provider/transport reason
(describeProviderError) instead of an opaque 500; preserve HttpException.
- testConnection: add an 'stt' capability (silent-WAV probe) + DTO; client
gets a Test endpoint button and status dot on the Voice/STT card.
- useDictation: log full errors to the console and show the real reason
(mic start + transcription paths); handle NotReadable/Abort and missing
mediaDevices.
- docs(CLAUDE.md): require full error logging + specific user-facing messages.
Add push-to-talk voice dictation that transcribes recorded audio on the
server via the workspace's OpenAI-compatible AI provider (Whisper /
gpt-4o-transcribe / self-hosted whisper), then inserts the text.
Backend:
- New `stt_api_key_enc` column + migration; STT creds parity with chat/
embeddings (sttModel/sttBaseUrl/sttApiKey, write-only key, fallbacks to
chat baseUrl/key). Both provider whitelists updated (service + repo).
- AiService.getTranscriptionModel + AiTranscriptionService.
- Gated POST /ai-chat/transcribe (dictation flag → 403, JWT + workspace
scope + throttle, 25MB cap, MIME whitelist, never logs audio/key).
- New `settings.ai.dictation` workspace flag (DTO + service + audit).
Frontend:
- Wire up the Voice/STT settings card (model/base URL/key) and the
Voice-dictation toggle.
- New `features/dictation`: useDictation (MediaRecorder state machine),
MicButton, transcribe service; integrated into the chat composer and a
new editor-toolbar dictation group, both gated by ai.dictation.
Make the floating AI chat window open at a larger default size and
allow stretching it further, plus shrink the fonts.
- ai-chat-window.tsx: DEFAULT_WIDTH 362->540, DEFAULT_HEIGHT 602->680;
clamp the default width to the viewport in computeInitialGeom()
(symmetric with the existing height clamp) to avoid overflow on
narrow screens.
- ai-chat-window.module.css: raise resize caps (max-width 560->900px,
max-height 880->1100px); base font-size 12->11px.
- ai-chat.module.css: chat content font .messages sm->xs.
Add a header button to the AI agent chat window that copies the active
conversation to the clipboard as Markdown, including the request
internals already persisted client-side — tool calls with their
input/output, per-message token usage, and finish/error info. No new
network call and no server/DB change: it serializes the already-loaded
persisted message rows.
- New util chat-markdown.ts (renamed from export-chat.ts): pure
buildChatMarkdown() serializer reusing the tool-parts helpers so tool
labels match the on-screen labels; fence() escapes embedded code
fences.
- ai-chat-window.tsx: Copy button (shown only for a saved chat with
loaded rows) using the project useClipboard hook; toggles a check
icon on success and shows the standard "Copied" notification. Drag is
unaffected (startDrag ignores button clicks).
- en-US: add "Copy chat" key, drop the obsolete "Export chat".
Move the AI-chat toggle icon (IconSparkles) from the page header menu
into the global top bar, placed next to the notifications icon. The
"AI chat enabled" gate (workspace.settings.ai.chat) is preserved, and
the icon style is aligned with the neighbouring notifications icon
(subtle, size sm). As a result the entry point is now available on all
routes instead of only on page routes.
- app-header.tsx: render the gated AI-chat ActionIcon before
NotificationPopover; wire it to aiChatWindowOpenAtom.
- page-header-menu.tsx: remove the old AI icon block and its now-unused
imports/locals.
Add autoFocus to the chat composer Textarea so a freshly created chat
(window open, "New chat", chat switch — all remount ChatThread via key)
lands with the cursor ready in the input field, letting the user type
immediately without clicking into it.
edit_page_text reported "success" when asked to change formatting (e.g. remove
strikethrough): the markdown-strip fallback matched the bare text, the replace
preserved marks, and the tool returned success — so the agent believed it had
fixed something that never changed.
Two fixes, both in the shared @docmost/mcp DocmostClient so they reach BOTH the
standalone MCP server and the in-app AI chat (which loads @docmost/mcp):
- Verifiable result for every content mutator: mutatePageContent now computes a
`verify` change-report (text inserted/deleted, blocks changed, per-mark-type
delta, integrity/structure delta) via summarizeChange() and returns it on all
mutators (incl. replaceImage via mutateLiveContentUnlocked). diffDocs is
text-only, so the mark/structure delta is what surfaces formatting changes.
- edit_page_text hard-refuses formatting edits: applyTextEdits rejects an edit
whose find/replace differ only in markdown markers (via stripBalancedWrappers,
which strips balanced wrappers/links without trimming whitespace/emoji, so
plain-text edits like trailing-space trims, snake_case, math are NOT refused).
A fully-refused batch errors instead of silently succeeding.
Also updated the model-facing edit_page_text descriptions in BOTH tool layers
(packages/mcp/src/index.ts and ai-chat-tools.service.ts) to drop the misleading
"strip-and-retry tolerated" wording and point formatting changes to patch_node.
New unit tests: test/unit/diff-verify.test.mjs, test/unit/json-edit-refuse.test.mjs.
The sidebar page tree previously kept its expanded/collapsed state in an
in-memory jotai atom, so it reset on every reload. Persist it in
localStorage instead, scoped by `${workspaceId}:${userId}` so multiple
accounts sharing a browser origin don't leak tree state into each other.
- open-tree-nodes-atom: replace the in-memory atom with a facade over an
atomFamily of atomWithStorage, scoped via currentUserAtom; keep the same
[OpenMap, functional-updater setter] public API so space-tree needs no
change.
- Use an explicit synchronous createJSONStorage + getOnInit: true so the
saved state is read at atom init — no collapse-then-expand flicker on
reload and no write against an un-hydrated empty map.
- README / README.ru: document persistent page-tree state in the
"What's different from Docmost" table.
Add an "Export chat" button to the AI agent chat window header that
downloads the active conversation as a Markdown file. The export is
client-only: it serializes the already-loaded persisted message rows
(no new network call, no server/DB change) and includes the request
internals the chat already holds — tool calls with their input/output,
per-message token usage, finish reason and error info.
- New util apps/client/src/features/ai-chat/utils/export-chat.ts:
buildChatMarkdown() + exportChatAsMarkdown(); reuses tool-parts
helpers so tool labels match the on-screen labels; fence() escapes
embedded code fences; slugify() yields a safe filename with a chatId
fallback; downloads via file-saver's saveAs.
- ai-chat-window.tsx: IconFileExport button in the header, shown only
for a saved chat with loaded rows (canExport); drag is unaffected.
- en-US: add "Export chat" and "You" i18n keys.
Display the app version (output of `git describe --tags`) in the header
beside the gitmost logo: a clean tag renders as `vX.Y.Z`, otherwise the
tag plus commits-since and short hash (e.g. v0.90.1-56-g25975acd).
- vite.config.ts: resolve APP_VERSION from env (Docker/CI) -> git describe
(local) -> package.json version fallback
- app-header.tsx: render APP_VERSION after the brand block (ml="md"),
nudge the Home nav group (ml={50} -> "xl")
- Dockerfile: accept APP_VERSION build-arg in the builder stage (.git is
excluded from the build context)
- CI: pass APP_VERSION build-arg — release.yml uses the tag, develop.yml
computes git describe with fetch-depth: 0
- nx.json: add APP_VERSION to the build target inputs so the cache
invalidates when the version changes