e89ac627dda2829bd23e08eba4fc6ba6b56cabc0
6 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3b80285d57 |
fix(#260): open MCP collab docs by canonical UUID (slugId doc-name split)
Real root cause of the silent MCP edit loss: the web editor always opens the
collaboration document by the page UUID (`page.${page.id}`), but the MCP
opened it by the agent-supplied id — usually a slugId — so `page.${pageId}`
became `page.<slugId>`. For one DB page that is TWO independent Yjs documents;
both persist to the same `pages` row (findById/updatePage resolve id or
slugId), so the human tab's debounced store overwrites the agent edit
(last-store-wins) — gone after reload, never shown live. The slugId doc also
made the server's transclusion sync + embedding reindex throw Postgres 22P02.
Fix:
- MCP (primary): resolvePageId(pageId) returns the canonical UUID — a UUID
short-circuits with no network call, a slugId resolves once via getPageRaw
and is cached both ways. Every collab-write path (mutatePageContent /
updatePageContentRealtime / replacePageContent and the mutate/replace/
unlocked seams) now opens by the resolved UUID, so the MCP and the editor
share ONE Yjs doc. replaceImage's whole-operation page lock also keys on the
UUID so it serializes against the other (now-UUID-keyed) writes.
- Server (defense + kills the 22P02 noise): onStoreDocument passes the resolved
page.id — not the raw doc-name id — to syncTransclusion, the embedding queue,
the mention-notification job, addContributors, and the in-tx history read.
Content store and the empty-guard are untouched.
Tests: a new MCP test stands up a real Hocuspocus server and asserts a slugId
input opens `page.<uuid>` (never `page.<slugId>`), with UUID short-circuit and
single-resolve caching; the server spec asserts the side-effects receive the
UUID for a `page.<slugId>` doc. closes #260
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
cce539e8e2 |
fix(collab): hoist intentional-clear consume out of the store retry loop (#251)
The store-side empty-guard consumed the per-document intentional-clear flag INSIDE the bounded retry loop. consumeIntentionalClear always deletes the in-memory Map entry, but a tx rollback cannot un-delete it: attempt 1 consumed the flag then updatePage threw a transient error and rolled back; attempt 2 re-read the page non-empty, saw the flag gone, and the empty-guard silently BLOCKED the write — dropping the user's deliberate clear and defeating the retry guarantee for clears. Hoist the decision out of the loop (like consumeContributors / consumeAgentTouched): consume once into `allowIntentionalClear` before the `for`, and only read that boolean on the empty-over-non-empty branch. The single hoisted consume still drops a pending flag for a non-empty store (the "cleared then retyped" case), since every store consumes regardless of incoming emptiness. Add a regression test: arm via the real onStateless transport, updatePage throws once then succeeds, assert it is called twice and the retry writes the empty doc (the clear survives). It fails on the old consume-in-loop ordering (updatePage called once) and passes after the hoist. Document the known fail-safe limitation near the TTL constant: if document ownership transfers / a node crashes between the stateless signal and the debounced store, the in-memory flag is lost and the clear is silently not applied (the doc reloads non-empty) — fail-safe, content is never destroyed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
3fdb1e05a4 |
feat(collab): persist a deliberate page clear via an intentional-clear signal (#251)
The #248 store-side empty-guard (onStoreDocument) unconditionally refuses to overwrite non-empty persisted content with an empty document, because a momentarily-empty live Y.Doc is indistinguishable from a real clear at the store layer. That correctly blocks glitches/bad-merges, but also blocks a user who genuinely wants to empty a page. This re-introduces a WORKING, narrow, non-spoofable exception (the dead context.intentionalClear hatch #248 removed never had a real channel). Definition of an intentional clear (client, IntentionalClear editor extension): a LOCAL user transaction (docChanged, NOT a remote y-sync change — filtered via isChangeOrigin) that reduces a non-empty doc to the empty single-paragraph shape. This is exactly the select-all + Delete/Backspace keystroke path. Transport (option b — hocuspocus stateless message): on that transition the client sends a `{type:'intentional-clear'}` stateless message. The server (PersistenceExtension.onStateless) records a short-lived (TTL 60s > 45s maxDebounce), single-use "pending clear" flag keyed by the connection's document. The next debounced onStoreDocument consumes it on the empty-guard branch to let that one empty write through. Why this is the right channel and non-spoofable: - Yjs transaction origin/metadata does not survive to the server store; awareness is per-connection and racy. A stateless message ties the signal to a specific clear, survives the debounce, and rides the authenticated connection. - The document is taken from the connection, never the payload, so a client cannot target another page. - The flag is read ONLY on the empty-over-non-empty branch, so the worst a forged signal can do is clear a page the connection may already edit; it can never force or alter a non-empty write. Read-only connections cannot arm it. Every non-empty store drops a pending flag, so "cleared then retyped" leaves nothing usable; the flag is single-use and TTL-bounded. NOTE: #248 is not yet on develop, so the empty-guard block is included here as the foundation this exception extends. If #248 lands first this rebases cleanly (the guard logic is identical; the #251-unique additions are the exception, onStateless, the pending-flag state, and the client extension). Tests: - Server (real transport path, not a hand-poke): onStateless sets the flag with the exact client payload, then the debounced onStoreDocument persists the empty doc; plus single-use consumption, read-only rejection, non-empty-store drops the flag, and the unchanged #248 guard tests (empty-over-non-empty blocked, empty-over-empty allowed). - Client: a real Editor + the actual selectAll+deleteSelection command emits the signal; typing / non-emptying edits / already-empty docs do not. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2d36641f28 |
test(coverage): add regression tests for issues #192, #206, #204
Additive test coverage across server, editor-ext, client and mcp. #192 — AiChatService.stream integration (Section 3, against real Postgres): - new apps/server/test/integration/ai-chat-stream.int-spec.ts drives the real streamText through a seeded ai/test MockLanguageModelV3 and a real Node ServerResponse, covering: onError persists an assistant error record (status 'error' + partial answer + provider cause in metadata); external MCP client closed exactly once on BOTH onFinish and onError; anti-tamper — history is rebuilt from the DB transcript, not from body.messages. #206 — red-team findings (most already fixed+tested in #212): - mdrt-2 (UNFIXED, data loss): turndown.dataloss.test.ts documents that pageBreak / transclusionReference / mention are silently dropped on Markdown export (characterization + it.fails for the desired survive-export contract). - persist-6 (UNFIXED, data loss): persistence-store.spec.ts adds an it.failing documenting that a momentarily-empty live doc overwrites non-empty content (left unfixed — a store-side empty-guard is a behaviour change). #204 — test-strategy plan, highest-priority subset: - Phase 1: mcp-clients.lease.spec.ts covers the external MCP client lease/refcount/eviction lifecycle (leak / premature-close / double-close). - Phase 2 data-integrity pure functions: editor-ext table-utils (transpose/moveRow/convert round-trip) and math tokenizer false-positive guard; client emoji-menu (+ it.fails for the unguarded localStorage JSON.parse bug), sort-cells, normalizeTableColumnWidths; mcp htmlEmbed/ pageBreak markdown data-loss + footnote-diff; server export getInternalLinkPageName extensionless-path bug — FIXED (small/clear) + tested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
770ba70541 |
fix(collab): retry transient store failures so autosave edits aren't lost (#206)
persist-1: onStoreDocument wrapped the page write in a try/catch that only logged and swallowed the error, then resolved "successfully". hocuspocus destroys/unloads the in-memory Y.Doc right after the hook resolves (the only copy of the latest edit), so a transient DB error (deadlock, serialization failure, dropped connection) silently lost the edit. Worse, the post-store branch ran on the partially-assigned `page`, broadcasting a phantom "page.updated" and enqueueing a history snapshot for content never written. Wrap the write in a small bounded retry (3 attempts) so the save is re-attempted while we still hold the doc, and clear `page` on failure so the success-only side effects never report a save that didn't happen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
3d4ad664b3 |
test(refactor-tail): extract pure cores + cover collab/share/ai-chat/client gate
Batches 6-9: behaviour-preserving extractions of testable pure cores plus the tests they unblock, and a fix for the broken client test environment. Full suites green: server 113 suites / 1117 + 1 todo, client 30 files / 338. client (R0 infra): - vitest.setup.ts: in-memory localStorage/sessionStorage Storage stub wired via setupFiles. Unblocks menu-items.gating.test.ts (was 9 failing) -> client suite fully green. + menu-items.suggestions.test.ts (getSuggestionItems filter/sort). share: - extract buildShareMetaHtml (share-seo.util.ts) from the SEO controller; tests for reflected-XSS escaping in <title>/og/twitter meta, noindex, truncation; extractPageSlugId; updateAttachmentAttr; prepareContentForShare comment-strip (anonymous-viewer metadata-leak guard). ai-chat (security extractions): - selectAccessibleHits: CASL post-filter for semantic search (restricted page in an accessible space must NOT leak to the agent). - validateResolvedAddresses: SSRF connect-time guard (block if ANY resolved address is private). - resolveAudioFormat: mime whitelist (dead `?? 'webm'` fallback dropped, set unchanged). + mcp-servers toView header-leak guard, MCP tool namespacing. collaboration (data-loss area): - extract computeHistoryJob (pins the "agent delay MUST stay 0" invariant) and resolveSource. Integration: onAuthenticate read-only matrix (collab auth bypass), HistoryProcessor (contributor restore on save failure), onStoreDocument Approach-A boundary snapshot (human revision pinned before agent overwrite). Reviewed (APPROVE WITH SUGGESTIONS): extractions behaviour-preserving, security tests mutation-resistant. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |