diff --git a/CHANGELOG.md b/CHANGELOG.md index a4d531af..7afee77d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to **Inkspan** (`@contextualwisdomlab/cwl-editor`) are docum ## [Unreleased] +## [0.5.27] — 2026-08-04 + +### Added +- `revision` on successful `CwlEditorIfMatchRestoreResult` values, paired with the exact active-schema-normalized `envelope` applied to the editor + +### Changed +- Package version **0.5.27** +- Successful guarded restores now reconstruct the incoming source through the active ProseMirror schema, serialize the prepared node to normalized document JSON, hash that envelope before mutation, and return complete before-and-after revision-envelope transition evidence + +### Reliability +- Hosts can continue autosave, compare, merge, fork, audit, and retry workflows from the returned resulting revision without a second editor read or a separately scheduled hash that could race with a newer edit +- Source properties ignored by ProseMirror and schema-materialized attributes cannot make the returned validator diverge from the document subsequently exported by the editor +- Document movement, editor destruction, or resulting-digest failure leaves the prepared incoming document unapplied + +### Performance +- A stable mismatch invokes SHA-256 once and does not inspect the incoming source +- A successful transition invokes SHA-256 twice and, after one source parse plus one schema reconstruction, adds one ProseMirror `toJSON()` traversal and one resource-bounded envelope normalization/freeze without a second source parse, schema reconstruction, or post-mutation editor read + +### Security +- Resulting revisions remain equality validators rather than signatures, authorization, tenant membership, or proof of durable persistence; CWL and naruon hosts retain authenticated atomic RFC 9110 `If-Match`, tenant isolation, transport, persistence, retention, encryption, redaction, and audit policy +- Returned envelopes contain complete client-controlled document content and must not be copied into ordinary logs, metric labels, analytics events, exception messages, public URLs, or compact revision metadata + +### Tests +- Added exact before-and-after evidence pairing, active-schema normalization, resulting-digest failure, incoming-digest movement, object and strict UTF-8 paths, imperative-handle, provider-neutral Yjs, packed declaration-consumer, and repository-wide 100% production coverage verification + +### Documentation +- Expanded the revision-guarded restore contract with resulting evidence, active-schema normalization, digest-count, lifecycle, privacy, server compare-and-swap, and APA 7th-style normative references + + ## [0.5.26] — 2026-08-04 ### Added @@ -89,6 +118,7 @@ All notable changes to **Inkspan** (`@contextualwisdomlab/cwl-editor`) are docum ### Documentation - Expanded README and persistence guidance with compile-time evidence pairing, null-evidence retry behavior, no-extra-copy performance guarantees, privacy boundaries, and durable server compare-and-swap ownership + ## [0.5.23] — 2026-08-04 ### Added diff --git a/docs/revision-guarded-restore.md b/docs/revision-guarded-restore.md index f2357f13..e2f1cbea 100644 --- a/docs/revision-guarded-restore.md +++ b/docs/revision-guarded-restore.md @@ -1,17 +1,18 @@ # Revision-guarded document restore -Inkspan 0.5.24 adds atomic revision-envelope evidence to the local -optimistic-concurrency boundary introduced in 0.5.23 for delayed autosave, AI, -template, and review operations. A validated versioned envelope can replace the -active editor document only when the editor still matches an expected Inkspan -SHA-256 strong entity tag. +Inkspan 0.5.27 completes the local optimistic-concurrency transition boundary +for delayed autosave, AI, template, and review operations. A validated versioned +envelope can replace the active editor document only when the editor still +matches an expected Inkspan SHA-256 strong entity tag. On success, Inkspan now +returns both the exact previous revision-envelope pair and the exact resulting +active-schema revision-envelope pair. The feature prevents a stale asynchronous result from silently replacing newer -standalone or Yjs-backed content. Every non-null revision returned by a guarded -restore is now paired with the exact frozen envelope from which that revision -was derived, so hosts do not need a second editor read that could race with a -later edit. It complements—but does not replace—the server-side atomic -`If-Match` compare-and-swap required for durable persistence. +standalone or Yjs-backed content. It also lets a host continue with the next +save, audit, compare, merge, fork, or retry operation without rereading the +editor or separately hashing the applied document. It complements—but does not +replace—the server-side atomic `If-Match` compare-and-swap required for durable +persistence. ## Pure API @@ -28,7 +29,12 @@ const result: CwlEditorIfMatchRestoreResult = incomingEnvelope, ); -if (result.status === 'conflict') { +if (result.status === 'restored') { + queueNextSave({ + envelope: result.envelope, + expectedStrongEntityTag: result.revision.strongEntityTag, + }); +} else { // Do not discard either version. Ask the host conflict workflow to reload, // compare, merge, fork, or retry from the returned atomic evidence or a // fresh read when the evidence is null. @@ -51,23 +57,32 @@ provider is invoked. ## Result states -A successful result is frozen and contains the stable previous revision, the -exact frozen envelope from which that revision was computed, and the validated -envelope that was applied: +A successful result is frozen and contains complete before-and-after transition +evidence: ```ts { status: 'restored', previousRevision, previousEnvelope, + revision, envelope, } ``` -`previousRevision` and `previousEnvelope` describe one captured editor document. -Inkspan reuses the envelope already captured for hashing; it does not perform a -second document clone, canonical serialization, digest, schema reconstruction, -or editor read. +`previousRevision` and `previousEnvelope` describe one stable editor document +captured before the operation. `revision` and `envelope` describe the exact +active-schema document accepted by the editor. Each revision is derived from the +RFC 8785 canonical UTF-8 bytes of the adjacent envelope. + +Inkspan prepares the incoming source once, including resource checks, version +routing, hostile-value detachment, and complete active-schema reconstruction. +ProseMirror may ignore source properties that are not part of its node model and +may materialize schema-defined attributes. Inkspan therefore serializes the +prepared node back to active-schema JSON, validates and freezes that normalized +envelope, and hashes it before mutation. The returned resulting envelope is the +same structural document the editor applies, rather than an unrecognized field +that ProseMirror discarded. A stable mismatch is also a frozen value and includes the active revision and its exact frozen source envelope: @@ -83,11 +98,12 @@ its exact frozen source envelope: `currentRevision` and `currentEnvelope` are atomic evidence for one stable captured document. A host can compare, merge, fork, audit, or prepare a retry without calling `getDocumentEnvelope()` afterward and accidentally pairing the -revision with a newer document. +revision with a newer document. Inkspan does not inspect or hash the incoming +source when the expected revision already mismatches. -When the document changes while asynchronous SHA-256 or synchronous untrusted- -source preparation is in progress, or when the captured editor is destroyed, -Inkspan returns: +When the document changes while either asynchronous SHA-256 operation or +synchronous untrusted-source preparation is in progress, or when the captured +editor is destroyed, Inkspan returns: ```ts { @@ -98,15 +114,17 @@ Inkspan returns: ``` Both fields are null in lockstep because no captured version can still be -reported as the active editor document. A destroyed editor is likewise no -longer a valid mutation target. Inkspan does not parse the incoming source after -detecting destruction, and an already-destroyed editor returns the null-evidence -conflict before hashing. The host must acquire the current editor instance and a +reported as the active editor document. If movement occurs while the normalized +incoming envelope is being hashed, Inkspan discards that prepared value and +never applies it. A destroyed editor is likewise no longer a valid mutation +target. An already-destroyed editor returns the null-evidence conflict before +hashing or parsing. The host must acquire the current editor instance and a fresh revision-envelope pair before retrying. -Conflict is a normal result rather than an exception; malformed inputs, +Conflict is a normal result rather than an exception. Malformed inputs, provider failures, resource violations, active-schema incompatibility, and -active editor-policy rejection remain typed redacted exceptions. +active editor-policy rejection remain typed redacted exceptions. A failure of +the resulting-envelope digest also leaves the current editor document unchanged. `DocumentEnvelopeRestoreError` means the document passed envelope and schema preparation but a ProseMirror transaction policy refused or transformed the @@ -119,33 +137,53 @@ is raised. Inkspan captures the immutable ProseMirror `editor.state.doc` reference and creates one detached, deeply frozen versioned envelope from that exact node. It hashes that envelope's canonical bytes and retains the same envelope object as -result evidence. After the digest resolves, it verifies that the editor remains -alive and the active document reference is unchanged. If the editor was -destroyed or the document moved, the incoming source is not parsed and no -mutation occurs. +previous or conflict evidence. After the digest resolves, it verifies that the +editor remains alive and the active document reference is unchanged. If the +editor was destroyed or the document moved, the incoming source is not parsed +and no mutation occurs. When the stable revision matches, Inkspan completes envelope parsing, resource checks, version routing, hostile-value detachment, and complete active-schema reconstruction synchronously. Reflection over untrusted objects can invoke Proxy traps even though ordinary accessor properties are rejected without -execution, so Inkspan checks editor lifecycle and active document identity again -after source preparation. If reentrant code changed or destroyed the editor, -the prepared source is discarded and a null-evidence conflict is returned. - -Only after both checks does Inkspan apply one +execution, so Inkspan checks editor lifecycle and active document identity +after source preparation. + +Inkspan serializes that prepared ProseMirror node to active-schema JSON, wraps it +in the same versioned and resource-bounded envelope contract, and hashes the +normalized envelope. This second asynchronous boundary is required to return a +trustworthy resulting strong validator without a later host race. After the +digest resolves, Inkspan checks editor lifecycle and active document identity +again. Only then does it apply the already prepared node with one `setContent(documentNode, false)` replacement without another asynchronous -boundary or attacker-controlled property access. TipTap commands can report -command execution before ProseMirror transaction filters decide whether the -new document is acceptable, so Inkspan compares the resulting active document -with the prepared node. Built-in safe-link and inline-image filters, or a -host-supplied policy plugin, therefore cannot produce a false `restored` result. -Selection-only transactions keep the same document reference and do not create -false content conflicts. +boundary or attacker-controlled property access. + +TipTap commands can report command execution before ProseMirror transaction +filters decide whether the new document is acceptable, so Inkspan compares the +resulting active document with the prepared node. Built-in safe-link and inline- +image filters, or a host-supplied policy plugin, therefore cannot produce a +false `restored` result. Selection-only transactions keep the same document +reference and do not create false content conflicts. This is a local JavaScript concurrency, lifecycle, and reentrancy boundary. It does not make browser memory a durable system of record and cannot replace a database transaction. +## Performance and digest count + +A stable mismatch invokes SHA-256 once for the current envelope, does not read +the incoming source, and does not perform schema reconstruction. A successful +transition invokes SHA-256 twice: once for the current envelope and once for the +active-schema-normalized incoming envelope. + +The successful path intentionally pays for the second digest so the returned +`revision` is paired with the exact applied `envelope`. After the one source +parse and one schema reconstruction, it performs one ProseMirror `toJSON()` +traversal plus one resource-bounded envelope normalization and freeze. It does +not parse the source again, reconstruct the schema again, or reread the editor +after mutation. Hosts that do not require conditional transition evidence can +continue to use the synchronous ordinary restore APIs. + ## Imperative handle The shared standalone and collaborative handle exposes the same behavior: @@ -157,6 +195,13 @@ const result = await editorRef.current?.restoreDocumentEnvelopeIfMatch( limits, digestProvider, ); + +if (result?.status === 'restored') { + persistLocally({ + envelope: result.envelope, + revision: result.revision, + }); +} ``` Use `restoreDocumentEnvelopeBytesIfMatch()` for strict UTF-8 bytes. Before @@ -167,22 +212,21 @@ mutating a destroyed instance or reporting stale success. Successful restore suppresses normal change callbacks, matching the existing atomic restore contract. The host should update its saved revision and dirty -state after success rather than treating the loaded persistence record as a new -user edit. +state from `result.revision` rather than treating the loaded persistence record +as a new user edit. -## Conflict evidence privacy +## Transition-evidence privacy -`previousEnvelope` and `currentEnvelope` contain the complete versioned document, -including author text and accepted inline base64 images. They are returned for -local conflict handling, not for indiscriminate telemetry. Hosts must apply the -same tenant authorization, purpose limitation, retention, redaction, encryption, -and audit controls used for the underlying document. Do not place envelopes in -ordinary logs, analytics events, exception messages, revision identifiers, or -public URLs. +`previousEnvelope`, `currentEnvelope`, and successful `envelope` values contain +the complete versioned document, including author text and accepted inline +base64 images. They are returned for local transition and conflict handling, not +for indiscriminate telemetry. Hosts must apply the same tenant authorization, +purpose limitation, retention, redaction, encryption, and audit controls used +for the underlying document. Do not place envelopes in ordinary logs, analytics +events, exception messages, revision identifiers, or public URLs. -The paired revision remains an equality validator, not a signature, -authorization token, tenant identifier, or proof that a durable write was -committed. +The paired revisions remain equality validators, not signatures, authorization +tokens, tenant identifiers, or proof that a durable write was committed. ## Collaboration and authorization @@ -199,8 +243,8 @@ resolution. ## Server-side persistence remains mandatory -A local match only proves what the current editor contained during one stable -JavaScript continuation. The persistence service must independently compare the +A local match only proves what the current editor contained during one guarded +JavaScript operation. The persistence service must independently compare the expected strong validator with its current durable revision inside the same transaction that writes the new content. A false precondition must produce `412 Precondition Failed` and leave durable content unchanged. @@ -210,13 +254,23 @@ public document identifiers, tenant membership, or proof that a prior write was committed. Persist descriptive nonnumeric document, tenant, user, and revision identifiers as host metadata. -## Primary references - -- [RFC 8785: JSON Canonicalization Scheme](https://www.rfc-editor.org/rfc/rfc8785) -- [Verified RFC 8785 erratum 7920: reject negative zero](https://www.rfc-editor.org/errata/eid7920) -- [RFC 9110 §13.1.1: `If-Match`](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.1) -- [W3C Web Cryptography API Recommendation](https://www.w3.org/TR/2017/REC-WebCryptoAPI-20170126/) -- [TipTap v2 editor lifecycle and `isDestroyed`](https://v2.tiptap.dev/docs/editor/api/editor) -- [TipTap v2 `setContent`](https://v2.tiptap.dev/docs/editor/api/commands/content/set-content) -- [TipTap JSON persistence guidance](https://v2.tiptap.dev/docs/guides/output-json-html) -- [ProseMirror state and immutable document model](https://prosemirror.net/docs/ref/#state.EditorState) +## Normative references + +- Rundgren, A., Jordan, B., & Erdtman, S. (2020). *JSON Canonicalization Scheme + (JCS)* (RFC 8785). Internet Engineering Task Force. + https://www.rfc-editor.org/rfc/rfc8785 +- Rundgren, A. (2024). *RFC 8785 verified erratum 7920: Negative zero is not + permitted*. RFC Editor. https://www.rfc-editor.org/errata/eid7920 +- Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* + (RFC 9110, Section 13.1.1). Internet Engineering Task Force. + https://www.rfc-editor.org/rfc/rfc9110#section-13.1.1 +- World Wide Web Consortium. (2017). *Web Cryptography API*. + https://www.w3.org/TR/2017/REC-WebCryptoAPI-20170126/ +- TipTap GmbH. (n.d.). *Editor API: Lifecycle and `isDestroyed`*. + https://v2.tiptap.dev/docs/editor/api/editor +- TipTap GmbH. (n.d.). *Set content command*. + https://v2.tiptap.dev/docs/editor/api/commands/content/set-content +- TipTap GmbH. (n.d.). *Output JSON and HTML*. + https://v2.tiptap.dev/docs/guides/output-json-html +- ProseMirror contributors. (n.d.). *Editor state and immutable document model*. + https://prosemirror.net/docs/ref/#state.EditorState diff --git a/package.json b/package.json index 45d397bb..25df920b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@contextualwisdomlab/cwl-editor", - "version": "0.5.26", - "description": "Inkspan — commercial-grade Markdown + HTML WYSIWYG editor module (TipTap/ProseMirror, MIT) with SSR-safe client hydration, native form integration, lossless document snapshots, preparse-resource-bounded duplicate-name-safe versioned persistence envelopes, canonical JSON plus strict UTF-8 byte round trips, SHA-256 strong revision validators, framework-independent pure and imperative atomic revision-envelope evidence, local If-Match restore guards, and atomic conflict evidence for optimistic concurrency, one-call imperative envelope export and atomic active-schema restore, host-owned lifecycle callbacks, strict link and image policies, accessible editing controls, provider-neutral Yjs collaboration, a standalone base64 converter, and bundled offline multilingual Noto Sans fonts.", + "version": "0.5.27", + "description": "Inkspan — commercial-grade Markdown + HTML WYSIWYG editor module (TipTap/ProseMirror, MIT) with SSR-safe client hydration, native form integration, lossless document snapshots, preparse-resource-bounded duplicate-name-safe versioned persistence envelopes, canonical JSON plus strict UTF-8 byte round trips, SHA-256 strong revision validators, framework-independent pure and imperative atomic revision-envelope evidence, local If-Match restore guards, and atomic before/after revision-envelope transition evidence for optimistic concurrency, one-call imperative envelope export and atomic active-schema restore, host-owned lifecycle callbacks, strict link and image policies, accessible editing controls, provider-neutral Yjs collaboration, a standalone base64 converter, and bundled offline multilingual Noto Sans fonts.", "license": "MIT", "author": "ContextualWisdomLab", "type": "module", diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 8c653963..fe58ff17 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -196,9 +196,18 @@ const conditionalRestore: Promise = const conditionalEvidence: Promise = conditionalRestore.then((result) => { if (result === null) return; if (result.status === 'restored') { + const previousRevision: CwlEditorDocumentRevision = + result.previousRevision; const previousEnvelope: CwlEditorDocumentEnvelope = result.previousEnvelope; - void previousEnvelope.documentJson; + const revision: CwlEditorDocumentRevision = result.revision; + const envelope: CwlEditorDocumentEnvelope = result.envelope; + void [ + previousRevision.strongEntityTag, + previousEnvelope.documentJson, + revision.strongEntityTag, + envelope.documentJson, + ]; return; } if (result.currentRevision === null) { diff --git a/src/collaboration/CollaborativeCwlEditor.envelopeRestore.test.tsx b/src/collaboration/CollaborativeCwlEditor.envelopeRestore.test.tsx index 5209d419..1730ae48 100644 --- a/src/collaboration/CollaborativeCwlEditor.envelopeRestore.test.tsx +++ b/src/collaboration/CollaborativeCwlEditor.envelopeRestore.test.tsx @@ -69,6 +69,7 @@ describe('collaborative revision-guarded envelope restore', () => { status: 'restored', previousRevision: revision, previousEnvelope, + revision, envelope, }); expect(handle.getMarkdown()).toBe('Collaborative restored content'); diff --git a/src/components/CwlEditor.envelopeHandle.test.tsx b/src/components/CwlEditor.envelopeHandle.test.tsx index e81794cf..c56bc40e 100644 --- a/src/components/CwlEditor.envelopeHandle.test.tsx +++ b/src/components/CwlEditor.envelopeHandle.test.tsx @@ -257,6 +257,7 @@ describe('CwlEditor imperative envelope persistence', () => { status: 'restored', previousRevision: revision, previousEnvelope, + revision, envelope: objectEnvelope, }); expect(handle.getHTML()).toContain('Conditional object'); @@ -293,6 +294,7 @@ describe('CwlEditor imperative envelope persistence', () => { status: 'restored', previousRevision: revision, previousEnvelope: currentEnvelope, + revision, envelope: byteEnvelope, }); expect(handle.getHTML()).toContain('Conditional bytes'); diff --git a/src/documentEnvelopeIfMatch.evidence.test.tsx b/src/documentEnvelopeIfMatch.evidence.test.tsx index e989c554..fcecfe17 100644 --- a/src/documentEnvelopeIfMatch.evidence.test.tsx +++ b/src/documentEnvelopeIfMatch.evidence.test.tsx @@ -1,6 +1,6 @@ import { act, render, waitFor } from '@testing-library/react'; import { createRef } from 'react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { CwlEditor } from './components/CwlEditor.js'; import { createDocumentEnvelope, @@ -8,6 +8,7 @@ import { } from './documentEnvelope.js'; import { createDocumentEnvelopeRevision, + DocumentEnvelopeRevisionError, type CwlEditorDocumentRevision, type DocumentEnvelopeDigestProvider, } from './documentEnvelopeRevision.js'; @@ -17,18 +18,22 @@ import { } from './documentEnvelopeIfMatch.js'; import type { CwlEditorHandle } from './types.js'; +function createDeterministicDigest(source: BufferSource): ArrayBuffer { + const bytes = ArrayBuffer.isView(source) + ? new Uint8Array(source.buffer, source.byteOffset, source.byteLength) + : new Uint8Array(source); + const digest = new Uint8Array(32); + for (let index = 0; index < bytes.length; index += 1) { + const digestIndex = index % digest.length; + digest[digestIndex] = + (digest[digestIndex] + bytes[index] + index) % 256; + } + return digest.buffer; +} + const DIGEST_PROVIDER: DocumentEnvelopeDigestProvider = { async digest(_algorithm, source) { - const bytes = ArrayBuffer.isView(source) - ? new Uint8Array(source.buffer, source.byteOffset, source.byteLength) - : new Uint8Array(source); - const digest = new Uint8Array(32); - for (let index = 0; index < bytes.length; index += 1) { - const digestIndex = index % digest.length; - digest[digestIndex] = - (digest[digestIndex] + bytes[index] + index) % 256; - } - return digest.buffer; + return createDeterministicDigest(source); }, }; @@ -110,7 +115,7 @@ describe('atomic revision-envelope conflict evidence', () => { expect(handle.getMarkdown()).toBe('Conflict evidence document'); }); - it('returns the exact previous envelope paired with the successful guard revision', async () => { + it('returns exact previous and resulting revision-envelope evidence after restore', async () => { const handle = await renderEvidenceEditor(); const editor = handle.getEditor()!; const previousEnvelope = handle.getDocumentEnvelope()!; @@ -120,6 +125,11 @@ describe('atomic revision-envelope conflict evidence', () => { DIGEST_PROVIDER, ); const incomingEnvelope = createParagraphEnvelope('Applied next revision'); + const revision = await createDocumentEnvelopeRevision( + incomingEnvelope, + undefined, + DIGEST_PROVIDER, + ); let result!: CwlEditorIfMatchRestoreResult; await act(async () => { @@ -136,16 +146,203 @@ describe('atomic revision-envelope conflict evidence', () => { status: 'restored', previousRevision, previousEnvelope, + revision, envelope: incomingEnvelope, }); expect(Object.isFrozen(result)).toBe(true); if (result.status !== 'restored') { - throw new Error('Expected a restored result with previous evidence'); + throw new Error('Expected a restored result with transition evidence'); } await expectMatchingEvidence( result.previousEnvelope, result.previousRevision, ); + await expectMatchingEvidence(result.envelope, result.revision); + expect(handle.getDocumentEnvelope()).toEqual(result.envelope); expect(handle.getMarkdown()).toBe('Applied next revision'); }); + + it('derives resulting evidence from the active-schema document rather than ignored source fields', async () => { + const handle = await renderEvidenceEditor(); + const editor = handle.getEditor()!; + const previousEnvelope = handle.getDocumentEnvelope()!; + const previousRevision = await createDocumentEnvelopeRevision( + previousEnvelope, + undefined, + DIGEST_PROVIDER, + ); + const incomingEnvelope = createDocumentEnvelope({ + type: 'doc', + ignoredRootField: 'not part of the editor document', + content: [ + { + type: 'paragraph', + ignoredNodeField: 'not part of the editor node', + content: [{ type: 'text', text: 'Schema-normalized result' }], + }, + ], + }); + const appliedEnvelope = createParagraphEnvelope('Schema-normalized result'); + const revision = await createDocumentEnvelopeRevision( + appliedEnvelope, + undefined, + DIGEST_PROVIDER, + ); + let result!: CwlEditorIfMatchRestoreResult; + + await act(async () => { + result = await restoreDocumentEnvelopeIfMatch( + editor, + previousRevision.strongEntityTag, + incomingEnvelope, + undefined, + DIGEST_PROVIDER, + ); + }); + + expect(result).toEqual({ + status: 'restored', + previousRevision, + previousEnvelope, + revision, + envelope: appliedEnvelope, + }); + expect(result).not.toMatchObject({ envelope: incomingEnvelope }); + expect(handle.getDocumentEnvelope()).toEqual(appliedEnvelope); + }); + + it('stops before the resulting digest when source preparation changes the editor', async () => { + const handle = await renderEvidenceEditor(); + const editor = handle.getEditor()!; + const previousEnvelope = handle.getDocumentEnvelope()!; + const previousRevision = await createDocumentEnvelopeRevision( + previousEnvelope, + undefined, + DIGEST_PROVIDER, + ); + const incomingEnvelope = createParagraphEnvelope('Must remain unapplied'); + let sourceTrapInvoked = false; + const reentrantSource = new Proxy(incomingEnvelope, { + ownKeys(target) { + if (!sourceTrapInvoked) { + sourceTrapInvoked = true; + editor.commands.setContent( + '

Newer document from source preparation

', + false, + ); + } + return Reflect.ownKeys(target); + }, + }); + const digestProvider: DocumentEnvelopeDigestProvider = { + digest: vi.fn(async (_algorithm, source) => + createDeterministicDigest(source), + ), + }; + let result!: CwlEditorIfMatchRestoreResult; + + await act(async () => { + result = await restoreDocumentEnvelopeIfMatch( + editor, + previousRevision.strongEntityTag, + reentrantSource, + undefined, + digestProvider, + ); + }); + + expect(result).toEqual({ + status: 'conflict', + currentRevision: null, + currentEnvelope: null, + }); + expect(sourceTrapInvoked).toBe(true); + expect(digestProvider.digest).toHaveBeenCalledTimes(1); + expect(handle.getMarkdown()).toBe( + 'Newer document from source preparation', + ); + }); + + it('does not apply a prepared envelope when the editor moves while its revision hashes', async () => { + const handle = await renderEvidenceEditor(); + const editor = handle.getEditor()!; + const previousEnvelope = handle.getDocumentEnvelope()!; + const previousRevision = await createDocumentEnvelopeRevision( + previousEnvelope, + undefined, + DIGEST_PROVIDER, + ); + const incomingEnvelope = createParagraphEnvelope('Must remain unapplied'); + let announceNextDigest!: () => void; + const nextDigestStarted = new Promise((resolve) => { + announceNextDigest = resolve; + }); + let releaseNextDigest!: () => void; + const nextDigestRelease = new Promise((resolve) => { + releaseNextDigest = resolve; + }); + let digestCallCount = 0; + const digestProvider: DocumentEnvelopeDigestProvider = { + digest: vi.fn(async (_algorithm, source) => { + digestCallCount += 1; + if (digestCallCount === 1) return createDeterministicDigest(source); + announceNextDigest(); + await nextDigestRelease; + return createDeterministicDigest(source); + }), + }; + + const pending = restoreDocumentEnvelopeIfMatch( + editor, + previousRevision.strongEntityTag, + incomingEnvelope, + undefined, + digestProvider, + ); + await nextDigestStarted; + act(() => { + editor.commands.setContent('

Newer local document

', false); + }); + releaseNextDigest(); + + await expect(pending).resolves.toEqual({ + status: 'conflict', + currentRevision: null, + currentEnvelope: null, + }); + expect(digestProvider.digest).toHaveBeenCalledTimes(2); + expect(handle.getMarkdown()).toBe('Newer local document'); + }); + + it('preserves the current document when the resulting digest fails', async () => { + const handle = await renderEvidenceEditor(); + const editor = handle.getEditor()!; + const previousEnvelope = handle.getDocumentEnvelope()!; + const previousRevision = await createDocumentEnvelopeRevision( + previousEnvelope, + undefined, + DIGEST_PROVIDER, + ); + let digestCallCount = 0; + const digestProvider: DocumentEnvelopeDigestProvider = { + digest: vi.fn(async (_algorithm, source) => { + digestCallCount += 1; + if (digestCallCount === 1) return createDeterministicDigest(source); + throw new Error('resulting-digest-failed'); + }), + }; + + await expect( + restoreDocumentEnvelopeIfMatch( + editor, + previousRevision.strongEntityTag, + createParagraphEnvelope('Must not be applied after digest failure'), + undefined, + digestProvider, + ), + ).rejects.toThrow(DocumentEnvelopeRevisionError); + expect(digestProvider.digest).toHaveBeenCalledTimes(2); + expect(handle.getDocumentEnvelope()).toEqual(previousEnvelope); + expect(handle.getMarkdown()).toBe('Conflict evidence document'); + }); }); diff --git a/src/documentEnvelopeIfMatch.test.tsx b/src/documentEnvelopeIfMatch.test.tsx index 79f302fd..4b94574a 100644 --- a/src/documentEnvelopeIfMatch.test.tsx +++ b/src/documentEnvelopeIfMatch.test.tsx @@ -101,6 +101,11 @@ describe('revision-guarded document-envelope restore', () => { digestProvider, ); const objectEnvelope = incomingEnvelope(); + const objectRevision = await createDocumentEnvelopeRevision( + objectEnvelope, + undefined, + digestProvider, + ); let objectResult!: CwlEditorIfMatchRestoreResult; await act(async () => { @@ -117,6 +122,7 @@ describe('revision-guarded document-envelope restore', () => { status: 'restored', previousRevision: currentRevision, previousEnvelope: currentEnvelope, + revision: objectRevision, envelope: objectEnvelope, }); expect(Object.isFrozen(objectResult)).toBe(true); @@ -125,17 +131,17 @@ describe('revision-guarded document-envelope restore', () => { ); expect(onChange).not.toHaveBeenCalled(); - const restoredRevision = await createDocumentEnvelopeRevision( - objectEnvelope, + const byteEnvelope = incomingEnvelope('Restored from bytes'); + const byteRevision = await createDocumentEnvelopeRevision( + byteEnvelope, undefined, digestProvider, ); - const byteEnvelope = incomingEnvelope('Restored from bytes'); let byteResult!: CwlEditorIfMatchRestoreResult; await act(async () => { byteResult = await restoreDocumentEnvelopeBytesIfMatch( editor, - restoredRevision.strongEntityTag, + objectRevision.strongEntityTag, encodeDocumentEnvelope(byteEnvelope), undefined, digestProvider, @@ -144,8 +150,9 @@ describe('revision-guarded document-envelope restore', () => { expect(byteResult.status).toBe('restored'); expect(byteResult).toMatchObject({ - previousRevision: restoredRevision, + previousRevision: objectRevision, previousEnvelope: objectEnvelope, + revision: byteRevision, envelope: byteEnvelope, }); expect(editorRef.current!.getMarkdown()).toBe('## Restored from bytes'); @@ -250,6 +257,10 @@ describe('revision-guarded document-envelope restore', () => { expect(result).toMatchObject({ status: 'restored', previousEnvelope, + revision: { + digestHex: '00'.repeat(32), + strongEntityTag: expectedStrongEntityTag, + }, }); expect(editorRef.current!.getMarkdown()).toBe('## Selection-safe restore'); }); diff --git a/src/documentEnvelopeIfMatch.ts b/src/documentEnvelopeIfMatch.ts index 147fdfc2..0b459afd 100644 --- a/src/documentEnvelopeIfMatch.ts +++ b/src/documentEnvelopeIfMatch.ts @@ -5,6 +5,9 @@ import { type CwlEditorDocumentEnvelope, type DocumentEnvelopeLimits, } from './documentEnvelope.js'; +import { + createValidatedDocumentEnvelopeRevisionEvidence, +} from './documentRevisionEvidence.js'; import { createValidatedDocumentEnvelopeRevision, DocumentEnvelopeRevisionError, @@ -30,7 +33,9 @@ export type CwlEditorIfMatchRestoreResult = readonly previousRevision: CwlEditorDocumentRevision; /** Exact frozen envelope from which `previousRevision` was derived. */ readonly previousEnvelope: CwlEditorDocumentEnvelope; - /** Detached validated envelope applied to the editor. */ + /** SHA-256 strong validator derived from the applied `envelope`. */ + readonly revision: CwlEditorDocumentRevision; + /** Exact frozen active-schema envelope applied to the editor. */ readonly envelope: CwlEditorDocumentEnvelope; } | { @@ -108,8 +113,10 @@ export function restoreDocumentEnvelopeBytesIfMatch( * Execute one guarded restore using a caller-selected envelope preparation path. * * The function captures and hashes one current envelope, returns that same - * frozen envelope beside every non-null revision, and performs no source - * inspection when the expected validator already conflicts. + * frozen envelope beside every non-null current revision, reconstructs the + * incoming source through the active schema, and hashes the exact normalized + * document that will be applied. It does not inspect the incoming source when + * the expected validator already conflicts. */ async function restoreIfMatch( editor: Editor, @@ -150,12 +157,26 @@ async function restoreIfMatch( return createMovedDocumentConflict(); } - const envelope = applyPreparedDocumentEnvelope(editor, prepared); + const appliedEnvelope = createDocumentEnvelope( + prepared.documentNode.toJSON(), + limits, + ); + const nextEvidence = + await createValidatedDocumentEnvelopeRevisionEvidence( + appliedEnvelope, + digestProvider, + ); + if (hasEditorMoved(editor, capturedDocument)) { + return createMovedDocumentConflict(); + } + + applyPreparedDocumentEnvelope(editor, prepared); return Object.freeze({ status: 'restored', previousRevision: currentRevision, previousEnvelope: currentEnvelope, - envelope, + revision: nextEvidence.revision, + envelope: nextEvidence.envelope, }); }