PageTemplateController (added on this branch) guards its lookup/toggle routes
with UserThrottlerGuard, which depends on the throttler options provided by
ThrottleModule. CollaborationModule -> TransclusionModule registers that
controller, and the collab server bootstraps CollabAppModule, which did not
import ThrottleModule. The API server's AppModule does, so :3000 booted, but
the collab server (:3001) crashed at startup with
'Nest can't resolve dependencies of the UserThrottlerGuard ... THROTTLER:MODULE_OPTIONS'.
Without collab the editor can't sync, so live editing was broken on this branch.
Import ThrottleModule into CollabAppModule, mirroring AppModule, so the guard
resolves in the collab process too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Develop" workflow builds the :develop image but was triggered on
push to main (the stable/default branch, released via v* tags). Switch
the trigger to the develop branch so pushes to develop build the image.
Anonymous public-share AI assistant:
- Add a workspace setting `publicShareAssistantRoleId` so an admin can pick which
agent role (identity/persona) the anonymous assistant adopts. The role's
instructions REPLACE the built-in persona while the immutable safety framework
is still always appended; the role's optional model override takes precedence
over the cheap publicShareChatModel. Resolved server-authoritatively
(workspace-scoped, soft-delete aware; disabled/missing roles fall back to the
built-in persona, so the tool scope remains the real security boundary).
- Plumb the field through the update DTO, ai-settings service, the workspace.repo
ALLOWED whitelist, resolve()/getMasked(), stream-time role resolution and the
prompt/model, plus the settings UI: a new "Assistant identity" Select listing
enabled roles (and surfacing a saved-but-disabled role explicitly).
Public-share branding / floating icon:
- Fix the AI assistant FAB overlapping the "Powered by ..." button (both were
Affixed bottom-right): stack the FAB above the bottom-right branding.
- Rename "Powered by Docmost" -> "Powered by Gitmost" and point the link at the
gitmost repo.
Tests: extend public-share-chat.spec (role persona replacement still appends the
safety framework, resolveShareRole edge cases, model-override precedence).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The html-embed feature toggle was enforced CLIENT-side in the NodeView (reads
settings.htmlEmbed from the logged-in workspace), so an anonymous public-share
viewer — who has no workspace context — always saw it as OFF and got a
placeholder instead of the executing embed. That broke the whole point (a
tracker must run for anonymous visitors).
Make it server-authoritative:
- share.service prepareContentForShare (the single path both share-content
flows use) strips htmlEmbed from served content when the workspace toggle is
OFF; both callers (updatePublicAttachments host page + lookupTransclusionForShare)
resolve the toggle once and pass it. Fail-closed: missing workspace -> OFF ->
stripped.
- NodeView executes whatever it was served in read-only/share mode
(shouldExecute = !editor.isEditable || htmlEmbedEnabled); the disabled
placeholder now only shows in the editable editor when OFF.
Net: anonymous share + toggle ON -> server serves the (admin-authored) embed ->
it executes for everyone; toggle OFF -> stripped server-side from every
share-content path (true kill switch); a non-admin embed can never be served
(save-path strip). No XSS regression in the editable editor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- make addTreeNode receivers idempotent (invalidateOnCreatePage guard +
buildTree dedup) so the author's self-echo no longer duplicates the node
- broadcast realtime tree updates for bulk copy/duplicate and import via a
root refetch: PAGE_CREATED now carries spaceId and the WS listener falls
back to refetchRootTreeNodeEvent when no per-node snapshot is present
- remove the now-dead client-relay inbound path (isTreeEvent/handleTreeEvent)
that remained a stale-restriction-cache attack surface
- honest string|null cast for a root move's parent id
- add tests: buildTree dedup; onPageCreated per-node vs refetch branching
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The admin-only raw HTML/JS embed is a deliberate stored-XSS surface, so gate the
whole feature behind a workspace toggle that is OFF by default; it only works
when a workspace admin explicitly enables it.
- settings.htmlEmbed (boolean, default false) + workspace-update field htmlEmbed,
persisted via WorkspaceRepo.updateSetting with an audit diff. Flipping it is
admin-only (same Manage Settings CASL as other workspace toggles).
- New gate htmlEmbedAllowed(featureEnabled, role) = featureEnabled && admin/owner.
All 7 server write paths (create, duplicate, collab onStoreDocument, REST/MCP/AI
updatePageContent, single + zip import, transclusion unsync) now read the
workspace's settings.htmlEmbed and strip unless (toggle ON AND admin). OFF
(default, or a failed/empty workspace lookup) strips htmlEmbed for EVERYONE
including admins -> existing embeds are cleaned up on next save, none persist.
- Client (defense-in-depth): the /html slash item is hidden unless toggle ON +
admin; the NodeView executes nothing and shows a 'disabled in this workspace'
placeholder when OFF; an admin Switch in Workspace Settings -> General with a
description of the behavior.
- docs/html-embed-admin.md documents the toggle + admin-only + fail-closed
coedit (a non-admin save strips an admin's embed) + execution semantics.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve conflicts with the independently-merged ai-agent-roles feature:
- ai-chat.module.ts: keep BOTH AiAgentRolesModule and the public-share
wiring (Share/Search modules, PublicShareChatController, services).
- ai.service.ts: take develop's getChatModel ChatModelOverride superset,
which already covers the public-share model-id-only override.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The anonymous public-share AI assistant's per-IP rate limit is only
effective behind a trusted reverse proxy that overwrites X-Forwarded-For
with the real client IP (the app runs with trustProxy). Document this
deployment requirement and the per-workspace cost backstop env var
(SHARE_AI_WORKSPACE_MAX_PER_HOUR, default 300) in .env.example.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up fixes on the agent-roles feature:
- ai.service: a cross-driver override to the ollama driver (when the
workspace driver is not ollama) now fails with an explicit 503 instead
of silently reusing the workspace base URL, which belongs to a different
provider. Same-driver ollama and openai/gemini overrides are unchanged.
- migration: add a partial unique index on (workspace_id, name) WHERE
deleted_at IS NULL so role names are unique per workspace without
soft-deleted rows blocking re-creation; map Postgres 23505 to a 409
ConflictException on create/update.
- dto: validate the role id as @IsUUID instead of @IsString.
- roles list: do not expose instructions/modelConfig to non-admin members.
The list endpoint now returns a picker view (id/name/emoji/description/
enabled) to members and the full view only to admins (same gate as the
CRUD endpoints). Client IAiRole fields made optional accordingly.
Adds tests for the cross-driver-ollama throw, the 23505->409 mapping, and
the non-admin picker-view security invariant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Integrate the already-merged step-limit work from develop. Only conflict was
ai-chat.service.spec.ts: both sides appended a describe block and edited the
import line. Resolved as a union — keep compactToolOutput + the assistantParts/
serializeSteps/rowToUiMessage suites (this branch) AND the prepareAgentStep
suite (develop), importing all symbols from ai-chat.service.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds explicit isIpAllowed cases for the CGNAT, ULA (fd00::/8) and IPv4-mapped
IPv6 loopback (::ffff:127.0.0.1) sample addresses from the parallel
safety-coverage branch. The mapped-loopback case is genuinely new (the existing
table only covered the mapped *private* variant); CGNAT and ULA ranges were
already covered with other samples and are kept here as explicit regression
guards for these specific addresses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Behaviour change (split out of the test commit per review, and now covered).
Both the stream onError log line and the error text streamed to the client were
formatted by separate inline blocks that only emitted "<status>: <message>".
Route both through the shared describeProviderError() so formatting stays in one
place.
BEHAVIOUR CHANGE: describeProviderError additionally appends a single-line,
300-char-truncated snippet of the provider responseBody/text. So the log line
AND the user-facing stream error now include that snippet (e.g. the HTML error
page from a misconfigured endpoint), which previously neither did. This is
intentional — it makes a misconfigured external endpoint diagnosable — and is
safe: the API key travels in the Authorization header and is never echoed in
the response body (see the util's docstring). A `fallback` param is added so
each call site keeps its own default ('AI stream error' for the stream).
Adds ai-error.util.spec.ts covering the formatter, including the appended /
truncated body snippet, so this behaviour is no longer untested.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Behaviour change (split out of the test commit per review).
In AI SDK v6 the useChat `onFinish` callback does NOT fire when the stream
errors. A brand-new chat whose very first turn fails would therefore never run
the post-turn path: the chat list was not invalidated and the client never
adopted the server-created chat id — so the failed chat only appeared in
history after a manual refresh (the server already creates the row and stores
the error message). Running the same `onTurnFinished()` handler on `onError`
makes the failed chat show up immediately. The error itself is still surfaced
to the user via the existing `error` state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unit tests for the safety-critical paths: crypto secret-box (round-trip,
tamper detection, wrong key), the SSRF guard (blocked ranges + DNS-rebinding),
the ai-chat tools service, the page-embedding repo, and the
assistant-parts/serialization helpers. Those server helpers (assistantParts,
rowToUiMessage, serializeSteps) are exported ONLY for the tests — no runtime
change.
Also: keyboard a11y on the chat history header and conversation rows
(role/tabIndex/Enter+Space), and DRY refactors that move shared logic into one
place (isToolPart -> tool-parts util; buildInitialValues in the MCP form).
The behaviour-changing edits that previously rode along in this commit are
split out into the following two commits, per review.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port two refinements from the GLM variant onto the Claude base:
- prepareAgentStep: add a comment note that AI SDK v7 renames the per-step
`system` field to `instructions` (v6 ^6.0.134 still uses `system`), so it
gets updated correctly on the next SDK bump.
- ai-chat.service.spec: add an explicit off-by-one boundary test for
prepareAgentStep, expressed via MAX_AGENT_STEPS instead of a hardcoded 18/19
so it tracks the constant if the cap changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expand all kept the menu open (closeMenuOnClick={false}) while Collapse all
closed it. Make both close on click for consistent behavior, and drop the
now-pointless in-menu isExpanding loading state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- extract collectAllIds / collectBranchIds into tree/utils and use them in
space-tree.tsx instead of inline closures
- drop the duplicate SidebarPageTreeDto, reuse the existing SidebarPageDto
for the /pages/tree endpoint
- type the getSpaceTree client call as api.post<{ items: IPage[] }>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The configured x enabled status dot is implemented and merged via this
branch, so the backlog plan is no longer needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The in-field Clear for the API key fields is implemented and merged via
this branch, so the backlog plan is no longer needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gate page-tree row density behind the COMPACT_PAGE_TREE flag
(standard 32px default, compact 26px opt-in). Authored by the local
Claude agent on machine 180.
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: 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.