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>
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>
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 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 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>
## What
Renames the agent-guidance file `CLAUDE.md` → `AGENTS.md` (the opencode-standard name) and prepends a process layer on top of the existing technical/architecture content.
## Changes
- **Rename** `CLAUDE.md` → `AGENTS.md` (git detects 64% similarity, history preserved).
- **New top section "Жизненный цикл задачи"** codifies the workflow we just went through:
1. Sync with `develop`, branch off
2. Implement (per the system-prompt workflow + subagents)
3. **Commit ONLY to Gitea and ONLY as `claude_code`** — never as `vvzvlad`, never push to `origin`/`upstream`
4. Push + PR to `develop`
5. User merges; agent deletes the task's `docs/backlog/<task>.md`
- **New release-cycle section**: before cutting a version, run the three orchestrator skills (test-orchestrator, review-orchestrator, red-team-orchestrator), fix their findings, then tag per the existing procedure.
- **Credentials cheat-sheet**: agent identity, keychain service name (`gitea-claude-code`), Gitea PR API endpoint, base branch, and do-not-push warnings for `origin`/`upstream`.
- **Fix typo**: repo slug is `gitmost`, not `gtimost` (the remote was redirecting on every push). Local `gitea` remote URL is updated to the canonical form.
## Out of scope
No code changes — docs only.
Reviewed-on: #2
Co-authored-by: claude_code <claude_code@vvzvlad.xyz>
Co-committed-by: claude_code <claude_code@vvzvlad.xyz>
Delete the backlog documentation that described the removal of non‑functional DOCX, PDF, and Confluence import features now that the code changes have been merged.
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.
Rename the macOS app repository reference in README.md and README.ru.md
from vvzvlad/docmost-app to vvzvlad/gitmost-app (both the URL and the
link label), reflecting the renamed repo.
- 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.
Add a dedicated section describing the licensing conflict between the AGPL‑3.0‑licensed web client and App Store DRM/usage rules. Explain why this is a non‑technical blocker, outline possible distribution approaches (server‑loaded client, OTA updates, PWA, sideload), and recommend confirming the chosen path before implementing any iOS wrapper code.
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.
Add markdown files describing the per‑user authentication mechanism and the ability to expand or collapse all nodes in the page tree, improving guidance for developers working with the MCP backlog feature.
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).
Add two new backlog documentation files:
- ai-chat-collapse-on-page-focus.md describing auto‑collapse behavior for the AI chat window.
- comments-panel-density.md outlining UI density improvements for the comments panel.
Add a backlog design note for making page-tree realtime updates
server-authoritative instead of client-relayed.
Problem: page content syncs via Yjs/Hocuspocus (server-authoritative),
but tree create/move/delete is broadcast by the originating browser only,
so non-UI creation paths (AI agent, MCP, REST API, import) and lost-event
races leave other clients' sidebars stale.
The note specifies a WsService.emitTreeEvent broadcaster, WsTreeService
broadcast helpers, a PageWsListener on PAGE_CREATED/SOFT_DELETED/DELETED/
MOVED/RESTORED, event-payload enrichment to avoid the in-transaction
re-fetch race, a dedicated PAGE_MOVED event, removal of the client relay,
plus edge cases, work breakdown, tests, alternatives and open questions.
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.
Remove outdated process sections from several backlog markdown files and add new backlog items for AI chat step limits, endpoint status config, and API key field UI improvements.
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).
Add docs/backlog/stt-providers-and-async.md: how to add new synchronous STT
request formats (Deepgram, native Gemini, ElevenLabs) via the explicit
sttApiStyle axis, which providers are inherently async and don't fit the
current sync model, and a target job-based async architecture (BullMQ job
table, sync+async unification, polling -> push -> live streaming) with the
migration path and security/cleanup considerations.
Add docs/streaming-dictation-plan.md — a design document for true
"text appears as you speak" dictation via the OpenAI Realtime API.
- Maps the current batch dictation flow (client MediaRecorder -> single
blob -> POST /ai-chat/transcribe) and why streaming is impossible there.
- Documents the Realtime API contract (transcription session, ephemeral
token, pcm16 audio, input_audio_buffer.append, input_audio_transcription
delta/completed events, server_vad).
- Recommends a server-side WS proxy transport (key stays server-side,
SSRF-guarded, provider-agnostic via sttBaseUrl) over direct browser
WebRTC, and a ProseMirror decoration for interim text with final-only
commit to avoid polluting Yjs collab/history.
- Covers config additions, AudioWorklet PCM16 capture, security per repo
conventions, edge cases, phased rollout, risks, and impacted files.
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 a documentation bullet that enforces comprehensive error logging and user‑facing messages, ensuring caught errors are fully logged and presented with specific, human‑readable explanations rather than generic messages.
The README files now list Voice dictation as a completed feature (✅) instead of an upcoming one (🔭). Consequently, the detailed `voice-dictation-plan.md` documentation has been removed. This reflects that the feature is ready and no longer merely a plan.
Add a section describing how Kysely runs migrations in alphabetical (timestamp) order and the need to verify migration timestamps when merging branches. This helps prevent migration ordering errors and boot failures.
Update the README files to list newly planned features on the roadmap, including page templates, a public‑share AI assistant, and academic‑style footnotes. This improves documentation of upcoming functionality.
Add a detailed design and implementation plan for an AI assistant that
operates on publicly shared document trees. The document outlines the
feature scope, architecture, security considerations, and remaining work,
providing context for future development.
Add docs/page-templates-plan.md describing a whole-page live
transclusion feature: pages flagged is_template, a new pageEmbed
node referencing a source page, a whole-page lookup endpoint reusing
the existing transclusion access-control and share paths, reference
sync, duplicate remap, and cycle/deletion/access/export edge cases.
Decision: separate pageEmbed node over extending transclusionReference.
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.
Add a "Cutting a release" subsection to CI / release: version selection
(SemVer), synchronized package.json bump (root + client + server; mcp is
independent), CHANGELOG update, lightweight v-tag, and push that triggers
release.yml. Document moving a misplaced tag via git tag -f + force-push,
and note the git tag (not package.json) is the source of truth for the
displayed version.
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 root CLAUDE.md describing the Gitmost project for Claude Code and
other AI-agent tooling. Covers: the AGPL-only/no-EE fork philosophy and the
"internal identifiers stay docmost" naming gotcha; the pnpm+Nx monorepo
layout; build/lint/test (incl. single-test) and migration commands; and the
big-picture architecture — the two server processes (API on PORT, collab on
COLLAB_PORT via Hocuspocus/Yjs), NestJS module layout, Kysely/Postgres+
pgvector/Redis persistence, the shared editor-ext (client + server; the MCP
package vendors its own schema mirror), and the two AI subsystems (embedded
/mcp server and the CASL-scoped AI chat agent with RAG).
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.