3fdb1e05a4
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>
95 lines
3.9 KiB
TypeScript
95 lines
3.9 KiB
TypeScript
import { Extension } from "@tiptap/core";
|
|
import { isChangeOrigin } from "@tiptap/extension-collaboration";
|
|
import type { Node as PMNode } from "@tiptap/pm/model";
|
|
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
|
|
|
/**
|
|
* Stateless message type sent to the server when a user deliberately clears a
|
|
* page to empty. Kept in one place so the client emitter and the server
|
|
* consumer (PersistenceExtension.onStateless) agree on the wire format.
|
|
*/
|
|
export const INTENTIONAL_CLEAR_MESSAGE_TYPE = "intentional-clear";
|
|
|
|
export interface IntentionalClearOptions {
|
|
/** The collab provider used to send the stateless clear signal. */
|
|
provider: HocuspocusProvider | null;
|
|
}
|
|
|
|
/**
|
|
* A "document is empty" check that mirrors the server's `isEmptyParagraphDoc`
|
|
* (collaboration.util.ts): exactly one top-level paragraph with no inline
|
|
* content. After a select-all + delete TipTap leaves precisely this shape, so
|
|
* matching it here keeps the client signal aligned with the server guard that
|
|
* consumes it.
|
|
*/
|
|
function isEmptyParagraphDoc(doc: PMNode): boolean {
|
|
if (doc.childCount !== 1) return false;
|
|
const child = doc.firstChild;
|
|
return (
|
|
child !== null &&
|
|
child !== undefined &&
|
|
child.type.name === "paragraph" &&
|
|
child.content.size === 0
|
|
);
|
|
}
|
|
|
|
/**
|
|
* #251 — intentional-clear signal.
|
|
*
|
|
* The server's #248 store-side empty-guard unconditionally refuses to overwrite
|
|
* non-empty persisted content with an empty document, because a momentarily
|
|
* empty live Y.Doc (a glitch, a bad merge, an emptying transclusion) is
|
|
* indistinguishable from a real clear *at the store layer*. That protection is
|
|
* correct, but it also blocks a user who genuinely wants to empty the page.
|
|
*
|
|
* This extension supplies the missing distinction. It watches LOCAL, user-driven
|
|
* transactions and, the moment one reduces a non-empty document to the empty
|
|
* single-paragraph shape, it sends a hocuspocus stateless message to the server.
|
|
* The server records a short-lived, single-use "intentional clear pending" flag
|
|
* for this document that the next (debounced) onStoreDocument consumes to let
|
|
* that one empty write through the guard.
|
|
*
|
|
* What counts as an intentional clear (precise definition):
|
|
* - the transaction actually changed the document (`docChanged`), AND
|
|
* - it is a LOCAL user edit, not a remote collab application — remote y-sync
|
|
* transactions are tagged and filtered out via `isChangeOrigin`, so an
|
|
* emptiness that arrives from another client / a merge never emits a signal,
|
|
* AND
|
|
* - the document was non-empty before the transaction and is the empty
|
|
* single-paragraph doc after it.
|
|
*
|
|
* This is exactly the select-all + Delete / Backspace (or any local command that
|
|
* empties the doc, e.g. clearContent) keystroke path. A transient/programmatic
|
|
* empty serialization that the server might see on the wire does NOT come with
|
|
* this signal, so the guard still blocks it.
|
|
*/
|
|
export const IntentionalClear = Extension.create<IntentionalClearOptions>({
|
|
name: "intentionalClear",
|
|
|
|
addOptions() {
|
|
return {
|
|
provider: null,
|
|
};
|
|
},
|
|
|
|
onTransaction({ transaction }) {
|
|
if (!transaction.docChanged) return;
|
|
// Only react to local user edits. Remote collaboration steps (and other
|
|
// y-sync-applied changes) carry the change origin and must never be treated
|
|
// as an intentional clear, otherwise a remote/merge-induced emptiness would
|
|
// punch through the server guard.
|
|
if (isChangeOrigin(transaction)) return;
|
|
|
|
const becameEmpty =
|
|
!isEmptyParagraphDoc(transaction.before) &&
|
|
isEmptyParagraphDoc(transaction.doc);
|
|
if (!becameEmpty) return;
|
|
|
|
// The server reads the originating document from the connection, so the
|
|
// payload only needs to declare intent — it cannot target another document.
|
|
this.options.provider?.sendStateless(
|
|
JSON.stringify({ type: INTENTIONAL_CLEAR_MESSAGE_TYPE }),
|
|
);
|
|
},
|
|
});
|