diff --git a/README.md b/README.md index f2b02332..8d58e930 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ runtime. | Envelope identity | `@contextualwisdomlab/cwl-editor/envelope-identity` | Framework-independent bounded schema identity for host-owned migration routing | | Revision evidence | `@contextualwisdomlab/cwl-editor/revision-evidence` | Framework-independent canonical envelope, strong revision, and transition evidence | | Text-position selector | `@contextualwisdomlab/cwl-editor/text-position-selector` | React-free deterministic W3C `TextPositionSelector` projection core | +| Review target core | `@contextualwisdomlab/cwl-editor/review` | `implemented_on_active_pr` — React-free deterministic exact-revision review targets; durable review records and policy remain host-owned | +| Review React adapter | `@contextualwisdomlab/cwl-editor/review-react` | `implemented_on_active_pr` — controlled accessible thread presentation over the React-free review contract; hosts own actions, authorization, bodies, and persistence | | Autosave | `@contextualwisdomlab/cwl-editor/autosave` | Provider-neutral bounded single-flight persistence coordination | | Headless Markdown | `@contextualwisdomlab/cwl-editor/markdown` | React-free deterministic Markdown/HTML/email/plain-text conversion | | Styles | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming | @@ -727,4 +729,4 @@ capabilities they require. - **Fonts:** Noto Sans families are SIL Open Font License 1.1. See [`LICENSE`](LICENSE), [`src/fonts/OFL.txt`](src/fonts/OFL.txt), and -[`src/fonts/NOTICE`](src/fonts/NOTICE). \ No newline at end of file +[`src/fonts/NOTICE`](src/fonts/NOTICE). diff --git a/docs/package-distribution.md b/docs/package-distribution.md index ddb4df0e..5effa500 100644 --- a/docs/package-distribution.md +++ b/docs/package-distribution.md @@ -17,6 +17,8 @@ integrations. | `@contextualwisdomlab/cwl-editor/envelope-identity` | Framework-independent identity-only envelope routing for bounded schema identity inspection; migration remains host-owned | | `@contextualwisdomlab/cwl-editor/revision-evidence` | Framework-independent revision evidence and document-transition evidence for local content equality/lineage claims | | `@contextualwisdomlab/cwl-editor/text-position-selector` | `implemented_on_protected_main` — React-free text-position projection core implementing W3C `TextPositionSelector`; interactive capture, revision binding, authorization, persistence, and re-anchoring remain outside this subpath | +| `@contextualwisdomlab/cwl-editor/review` | `implemented_on_active_pr` — React-free deterministic review-target validation bound to exact local SHA-256 revision evidence plus Inkspan's W3C text-position projection; durable review records and policy remain host-owned | +| `@contextualwisdomlab/cwl-editor/review-react` | `implemented_on_active_pr` — controlled accessible React thread presentation over the bounded review contract; hosts supply labels and own actions, authorization, bodies, and persistence | | `@contextualwisdomlab/cwl-editor/markdown` | `implemented_on_active_pr` — headless deterministic Markdown/HTML/email/plain-text conversion with the same safe-link and strict inline-raster policies as the editor, without importing the React/TipTap editor graph | | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming | | `@contextualwisdomlab/cwl-editor/fonts.css` | Full offline KR/EN/JP/SC/TC/VI font bundle | @@ -58,11 +60,18 @@ embedded in the npm tarball. dependencies so the consumer's package manager installs and resolves it; it is not merely a type-only dependency. - The framework-independent autosave, converter, envelope-identity, - revision-evidence, text-position-selector, and Markdown entrypoints do not - require React UI, a mounted editor, naruon, contextual-orchestrator, a + revision-evidence, text-position-selector, review, and Markdown entrypoints do + not require React UI, a mounted editor, naruon, contextual-orchestrator, a database, provider credentials, or host transport. Their individual package-consumer gates additionally prevent framework dependencies from leaking into subpaths whose public contracts exclude them. +- The review-react subpath is an optional controlled React presentation adapter. + It validates every host-supplied presentation through the React-free review + contract before rendering, receives visible and accessible copy from the host, + and emits selection, reply, and resolve intent callbacks only. Presentation + capability flags never grant authority on their own; missing host callbacks + keep actions disabled. It owns no comment body, actor lookup, authorization, + persistence, notification, or transport. - The Markdown subpath exposes `markdownToHtml`, `htmlToMarkdown`, `normalizeMarkdown`, `markdownToEmailHtml`, `markdownToPlainText`, and `htmlToPlainText` plus their option types. It bundles deterministic conversion @@ -81,6 +90,15 @@ embedded in the npm tarball. state or bind a selector to a document revision. Hosts remain responsible for annotation identifiers/bodies, source-resource identity, authorization, tenancy, persistence, audit, and cross-revision re-anchoring. +- The review subpath composes only validated local SHA-256 revision evidence, + Inkspan's canonical text-position selector/projection identity, and a detached + immutable v1 target. It rejects malformed, accessor-backed, symbolic, or + unknown target fields without retaining caller-owned objects. It cannot prove + that an arbitrary caller-supplied offset is inside a document without the + referenced document, and it creates no durable comment/thread identifier, + actor identity, authorization, tenant boundary, persistence, retention, + notification, audit occurrence, collaboration-provider admission, or + cross-revision re-anchoring authority. Hosts retain all of those duties. - Envelope identity output is routing metadata only. It does not accept an unsupported document generation as current semantics and does not move schema registry, migration, persistence, rollback, or authorization authority into @@ -109,9 +127,9 @@ production library build. The verification chain: 4. rejects internal source, tests, demos, Office files, coverage output, and workflow files from the npm tarball; 5. imports the root, collaboration, converter, autosave, envelope-identity, - revision-evidence, text-position-selector, and Markdown surfaces through their - dedicated packed-consumer checks, including framework-free isolation where - that is part of the public contract; + revision-evidence, text-position-selector, review, review-react, and Markdown + surfaces through their dedicated packed-consumer checks, including + framework-free isolation where that is part of the public contract; 6. exercises supported ESM/CommonJS entrypoints and compiles strict TypeScript consumers against the published declaration surfaces; 7. resolves public CSS and font subpaths; and @@ -129,6 +147,20 @@ rejects **ambient network and credential authority** such as `fetch`, of the selector's structural contract and introduce no interactive runtime authority. +The active review-package check likewise builds and extracts a real npm tarball, +executes `@contextualwisdomlab/cwl-editor/review` through ESM and CommonJS, +compiles a strict TypeScript consumer, exercises valid and malformed target +metadata, and rejects external runtime imports, dynamic module loaders, and +ambient network or credential authority. This is active-PR evidence only until +the review subpath is integrated into protected main. + +The active review-react package check is configured to build and extract a real +npm tarball, exercise ESM and CommonJS server-render consumers plus strict +TypeScript declarations, permit only host-supplied React peer runtime imports, +and reject dynamic module loaders plus ambient network or credential authority. +Its result is active-PR evidence only until the review-react subpath is integrated +into protected main. + The Markdown package check likewise builds and extracts a real npm tarball, executes its ESM and CommonJS entrypoints outside the source tree, compiles a strict TypeScript consumer, and verifies representative safe-link, plain-text, diff --git a/package.json b/package.json index 4e55d924..aa83c356 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,16 @@ "import": "./dist/cwl-text-position-selector.js", "require": "./dist/cwl-text-position-selector.cjs" }, + "./review": { + "types": "./dist/review/index.d.ts", + "import": "./dist/cwl-review.js", + "require": "./dist/cwl-review.cjs" + }, + "./review-react": { + "types": "./dist/review-react/index.d.ts", + "import": "./dist/cwl-review-react.js", + "require": "./dist/cwl-review-react.cjs" + }, "./markdown": { "types": "./dist/markdown/index.d.ts", "import": "./dist/cwl-markdown.js", @@ -99,7 +109,7 @@ ], "scripts": { "dev": "vite", - "build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && vite build --config vite.text-position-selector.config.ts && vite build --config vite.markdown.config.ts && node ./scripts/copy-styles.mjs", + "build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && vite build --config vite.text-position-selector.config.ts && vite build --config vite.review.config.ts && vite build --config vite.review-react.config.ts && vite build --config vite.markdown.config.ts && node ./scripts/copy-styles.mjs", "build:demo": "vite build --config vite.demo.config.ts", "fonts": "node ./scripts/fetch-fonts.mjs", "preview": "vite preview", @@ -108,7 +118,7 @@ "test:watch": "vitest", "coverage": "vitest run --coverage", "test:package-config": "node --test ./scripts/revision-evidence-consumer-config.test.mjs ./scripts/release-metadata.test.mjs ./scripts/javascript-runtime-authority.test.mjs", - "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs" + "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./tests/package/verify-editor-placeholder-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs && node ./scripts/verify-review-package.mjs && node ./scripts/verify-review-react-package.mjs && node ./scripts/verify-markdown-subpath-package.mjs" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/scripts/verify-review-package.mjs b/scripts/verify-review-package.mjs new file mode 100644 index 00000000..97b585fe --- /dev/null +++ b/scripts/verify-review-package.mjs @@ -0,0 +1,312 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageJson = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), +); +const verificationRoot = mkdtempSync(join(tmpdir(), 'inkspan-review-')); +const extractionDirectory = join(verificationRoot, 'extracted'); +const consumerDirectory = join(verificationRoot, 'consumer'); +const packageDirectory = join( + consumerDirectory, + 'node_modules', + ...packageJson.name.split('/'), +); + +const dynamicLoaderPattern = /(?:\bimport\s*\(|\brequire\s*\()/u; +const externalRuntimeImportPattern = + /(?:\bimport\s+(?:[^'";]*?\sfrom\s*)?['"][^'"]+['"]|\bexport\s+[^'";]*?\sfrom\s*['"][^'"]+['"])/u; +const ambientAuthorityPattern = + /(?:\bfetch\s*\(|\bXMLHttpRequest\b|\bWebSocket\b|\bEventSource\b|\bprocess\.env\b|\bimport\.meta\.env\b|\bDeno\.env\b|\bBun\.env\b)/u; + +function run(command, argumentsList, cwd = repositoryRoot) { + return execFileSync(command, argumentsList, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); +} + +function preparePackage() { + mkdirSync(extractionDirectory, { recursive: true }); + mkdirSync(dirname(packageDirectory), { recursive: true }); + const packOutput = run('npm', [ + 'pack', + '--json', + '--ignore-scripts', + '--pack-destination', + verificationRoot, + ]); + const packResult = JSON.parse(packOutput)[0]; + assert.equal(packResult.name, packageJson.name); + assert.equal(packResult.version, packageJson.version); + const tarballPath = join(verificationRoot, packResult.filename); + assert.ok(existsSync(tarballPath)); + run('tar', ['-xzf', tarballPath, '-C', extractionDirectory]); + renameSync(join(extractionDirectory, 'package'), packageDirectory); + writeFileSync( + join(consumerDirectory, 'package.json'), + '{"name":"inkspan-review-consumer","private":true,"type":"module"}\n', + 'utf8', + ); + + const repositoryTiptap = join(repositoryRoot, 'node_modules', '@tiptap'); + const consumerTiptap = join(consumerDirectory, 'node_modules', '@tiptap'); + assert.ok(existsSync(repositoryTiptap)); + symlinkSync(repositoryTiptap, consumerTiptap, 'dir'); +} + +function verifyAuthorityFreeBundles() { + for (const filename of ['cwl-review.js', 'cwl-review.cjs']) { + const source = readFileSync(join(packageDirectory, 'dist', filename), 'utf8'); + assert.equal( + dynamicLoaderPattern.test(source), + false, + `${filename} must not invoke dynamic module loaders`, + ); + assert.doesNotMatch( + source, + externalRuntimeImportPattern, + `${filename} must not import external runtime authority`, + ); + assert.doesNotMatch( + source, + ambientAuthorityPattern, + `${filename} must not reference ambient network or credential authority`, + ); + } +} + +function verifyRuntimeConsumers() { + const esmPath = join(consumerDirectory, 'consumer.mjs'); + writeFileSync( + esmPath, + `import assert from 'node:assert/strict'; +import { + INKSPAN_REVIEW_CONTRACT_VERSION, + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + CwlReviewOperationError, + CwlReviewSuggestionError, + CwlReviewTargetError, + DocumentEnvelopeRevisionError, + TextPositionSelectorEvidenceError, + createDocumentEnvelopeRevision, + createReviewOperationResult, + createReviewSuggestion, + createReviewTarget, + createTextPositionSelector, +} from '${packageJson.name}/review'; +assert.equal(INKSPAN_REVIEW_CONTRACT_VERSION, 1); +assert.equal(TEXT_POSITION_PROJECTION_ID, 'inkspan-prosemirror-text'); +assert.equal(TEXT_POSITION_PROJECTION_VERSION, 1); +assert.equal(typeof CwlReviewOperationError, 'function'); +assert.equal(typeof CwlReviewSuggestionError, 'function'); +assert.equal(typeof CwlReviewTargetError, 'function'); +assert.equal(typeof DocumentEnvelopeRevisionError, 'function'); +assert.equal(typeof TextPositionSelectorEvidenceError, 'function'); +assert.equal(typeof createDocumentEnvelopeRevision, 'function'); +assert.equal(typeof createReviewOperationResult, 'function'); +assert.equal(typeof createReviewSuggestion, 'function'); +assert.equal(typeof createReviewTarget, 'function'); +assert.equal(typeof createTextPositionSelector, 'function'); +const digestHex = 'a'.repeat(64); +const target = createReviewTarget({ + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: '\"sha256-' + digestHex + '\"', + }, + selector: { type: 'TextPositionSelector', start: 1, end: 2 }, + projection: { id: 'inkspan-prosemirror-text', version: 1 }, +}); +assert.equal(target.revision.digestHex, digestHex); +assert.equal(target.selector.start, 1); +assert.equal(Object.isFrozen(target), true); +assert.throws( + () => createReviewTarget({ ...target, contractVersion: 2 }), + CwlReviewTargetError, +); +const insertionTarget = createReviewTarget({ + ...target, + selector: { type: 'TextPositionSelector', start: 1, end: 1 }, +}); +const suggestion = createReviewSuggestion({ + contractVersion: 1, + kind: 'insert', + target: insertionTarget, + text: 'proposal', +}); +assert.equal(suggestion.kind, 'insert'); +assert.equal(Object.isFrozen(suggestion), true); +assert.throws( + () => createReviewSuggestion({ ...suggestion, text: '' }), + CwlReviewSuggestionError, +); +`, + 'utf8', + ); + const cjsPath = join(consumerDirectory, 'consumer.cjs'); + writeFileSync( + cjsPath, + `const assert = require('node:assert/strict'); +const review = require('${packageJson.name}/review'); +assert.equal(review.INKSPAN_REVIEW_CONTRACT_VERSION, 1); +assert.equal(review.TEXT_POSITION_PROJECTION_ID, 'inkspan-prosemirror-text'); +assert.equal(review.TEXT_POSITION_PROJECTION_VERSION, 1); +assert.equal(typeof review.CwlReviewOperationError, 'function'); +assert.equal(typeof review.CwlReviewSuggestionError, 'function'); +assert.equal(typeof review.CwlReviewTargetError, 'function'); +assert.equal(typeof review.createDocumentEnvelopeRevision, 'function'); +assert.equal(typeof review.createReviewOperationResult, 'function'); +assert.equal(typeof review.createReviewSuggestion, 'function'); +assert.equal(typeof review.createReviewTarget, 'function'); +assert.equal(typeof review.createTextPositionSelector, 'function'); +const digestHex = 'b'.repeat(64); +const target = review.createReviewTarget({ + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: '\"sha256-' + digestHex + '\"', + }, + selector: { type: 'TextPositionSelector', start: 0, end: 0 }, + projection: { id: 'inkspan-prosemirror-text', version: 1 }, +}); +assert.equal(target.revision.digestHex, digestHex); +assert.equal(Object.isFrozen(target.projection), true); +const suggestion = review.createReviewSuggestion({ + contractVersion: 1, + kind: 'insert', + target, + text: 'proposal', +}); +assert.equal(suggestion.text, 'proposal'); +assert.equal(Object.isFrozen(suggestion), true); +`, + 'utf8', + ); + run(process.execPath, [esmPath], consumerDirectory); + run(process.execPath, [cjsPath], consumerDirectory); +} + +function verifyDeclarationConsumer() { + const sourcePath = join(consumerDirectory, 'consumer.ts'); + const configurationPath = join(consumerDirectory, 'tsconfig.json'); + writeFileSync( + sourcePath, + `import { + INKSPAN_REVIEW_CONTRACT_VERSION, + CwlReviewOperationError, + CwlReviewSuggestionError, + CwlReviewTargetError, + createReviewOperationResult, + createReviewSuggestion, + createReviewTarget, + type CwlReviewOperationErrorCode, + type CwlReviewOperationResult, + type CwlReviewSuggestion, + type CwlReviewSuggestionErrorCode, + type CwlReviewTarget, + type CwlReviewTargetErrorCode, + type CwlEditorDocumentRevision, + type CwlEditorTextPositionSelector, + type CwlEditorTextProjectionIdentity, +} from '${packageJson.name}/review'; +declare const revision: CwlEditorDocumentRevision; +declare const selector: CwlEditorTextPositionSelector; +declare const projection: CwlEditorTextProjectionIdentity; +const target: CwlReviewTarget = { + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + revision, + selector, + projection, +}; +const detachedTarget: CwlReviewTarget = createReviewTarget(target); +const suggestion: CwlReviewSuggestion = createReviewSuggestion({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + kind: 'delete', + target: { + ...target, + selector: { type: 'TextPositionSelector', start: 0, end: 1 }, + }, +}); +const operationPromise: Promise = + createReviewOperationResult(suggestion, 'reject', {}, {}); +const targetCode: CwlReviewTargetErrorCode = new CwlReviewTargetError().code; +const suggestionCode: CwlReviewSuggestionErrorCode = + new CwlReviewSuggestionError().code; +const operationCode: CwlReviewOperationErrorCode = + new CwlReviewOperationError('invalid_operation').code; +void [ + detachedTarget.revision, + detachedTarget.selector.start, + detachedTarget.selector.end, + detachedTarget.projection.id, + suggestion.kind, + operationPromise, + targetCode, + suggestionCode, + operationCode, +]; +`, + 'utf8', + ); + writeFileSync( + configurationPath, + `${JSON.stringify( + { + compilerOptions: { + noEmit: true, + strict: true, + skipLibCheck: false, + module: 'NodeNext', + moduleResolution: 'NodeNext', + target: 'ES2022', + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + types: [], + }, + files: ['./consumer.ts'], + }, + null, + 2, + )}\n`, + 'utf8', + ); + const compilerPath = join( + repositoryRoot, + 'node_modules', + 'typescript', + 'bin', + 'tsc', + ); + assert.ok(existsSync(compilerPath)); + run(process.execPath, [compilerPath, '--project', configurationPath], consumerDirectory); +} + +try { + preparePackage(); + verifyAuthorityFreeBundles(); + verifyRuntimeConsumers(); + verifyDeclarationConsumer(); + console.log( + `Verified packed ${packageJson.name}/review through authority-bounded ESM, CommonJS, and strict TypeScript consumers.`, + ); +} finally { + rmSync(verificationRoot, { recursive: true, force: true }); +} diff --git a/scripts/verify-review-react-package.mjs b/scripts/verify-review-react-package.mjs new file mode 100644 index 00000000..a4fd794b --- /dev/null +++ b/scripts/verify-review-react-package.mjs @@ -0,0 +1,253 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageJson = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), +); +const verificationRoot = mkdtempSync(join(tmpdir(), 'inkspan-review-react-')); +const extractionDirectory = join(verificationRoot, 'extracted'); +const consumerDirectory = join(verificationRoot, 'consumer'); +const packageDirectory = join( + consumerDirectory, + 'node_modules', + ...packageJson.name.split('/'), +); + +const ambientAuthorityPattern = + /(?:\bfetch\s*\(|\bXMLHttpRequest\b|\bWebSocket\b|\bEventSource\b|\bprocess\.env\b|\bimport\.meta\.env\b|\bDeno\.env\b|\bBun\.env\b)/u; +const dynamicImportPattern = /\bimport\s*\(/u; +const esmSpecifierPattern = + /\b(?:import|export)\s+(?:[^'";]*?\sfrom\s*)?['"]([^'"]+)['"]/gu; +const requireSpecifierPattern = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/gu; +const allowedRuntimeSpecifiers = new Set(['react', 'react/jsx-runtime']); + +function run(command, argumentsList, cwd = repositoryRoot) { + return execFileSync(command, argumentsList, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); +} + +function linkDependency(name) { + const source = join(repositoryRoot, 'node_modules', ...name.split('/')); + const target = join(consumerDirectory, 'node_modules', ...name.split('/')); + assert.ok(existsSync(source), `repository dependency missing: ${name}`); + mkdirSync(dirname(target), { recursive: true }); + symlinkSync(source, target, 'dir'); +} + +function preparePackage() { + mkdirSync(extractionDirectory, { recursive: true }); + mkdirSync(dirname(packageDirectory), { recursive: true }); + const packOutput = run('npm', [ + 'pack', + '--json', + '--ignore-scripts', + '--pack-destination', + verificationRoot, + ]); + const packResult = JSON.parse(packOutput)[0]; + assert.equal(packResult.name, packageJson.name); + assert.equal(packResult.version, packageJson.version); + const tarballPath = join(verificationRoot, packResult.filename); + assert.ok(existsSync(tarballPath)); + run('tar', ['-xzf', tarballPath, '-C', extractionDirectory]); + renameSync(join(extractionDirectory, 'package'), packageDirectory); + writeFileSync( + join(consumerDirectory, 'package.json'), + '{"name":"inkspan-review-react-consumer","private":true,"type":"module"}\n', + 'utf8', + ); + for (const dependency of [ + 'react', + 'react-dom', + '@types/react', + '@types/react-dom', + '@tiptap/core', + '@tiptap/pm', + ]) { + linkDependency(dependency); + } +} + +function verifyBoundedRuntimeImports() { + const files = [ + ['cwl-review-react.js', esmSpecifierPattern], + ['cwl-review-react.cjs', requireSpecifierPattern], + ]; + for (const [filename, specifierPattern] of files) { + const source = readFileSync(join(packageDirectory, 'dist', filename), 'utf8'); + assert.equal( + ambientAuthorityPattern.test(source), + false, + `${filename} must not reference ambient network or credential authority`, + ); + assert.equal( + dynamicImportPattern.test(source), + false, + `${filename} must not dynamically import runtime authority`, + ); + specifierPattern.lastIndex = 0; + for (const match of source.matchAll(specifierPattern)) { + assert.ok( + allowedRuntimeSpecifiers.has(match[1]), + `${filename} imports unexpected runtime authority: ${match[1]}`, + ); + } + } +} + +function presentationFixture(digestCharacter) { + const digestHex = digestCharacter.repeat(64); + return { + contractVersion: 1, + threadKey: 'thread_1', + target: { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { type: 'TextPositionSelector', start: 3, end: 8 }, + projection: { id: 'inkspan-prosemirror-text', version: 1 }, + }, + state: 'unresolved', + commentCount: 2, + selected: true, + canReply: true, + canResolve: true, + }; +} + +function verifyRuntimeConsumers() { + const labelsSource = `{ + region: 'Document review', + thread: (_thread, index) => 'Thread ' + (index + 1), + reply: 'Reply', + resolve: 'Resolve', +}`; + const fixture = JSON.stringify(presentationFixture('a')); + const esmPath = join(consumerDirectory, 'consumer.mjs'); + writeFileSync( + esmPath, + `import assert from 'node:assert/strict'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { CwlReviewThreadList } from '${packageJson.name}/review-react'; +const html = renderToStaticMarkup(React.createElement(CwlReviewThreadList, { + presentations: [${fixture}], + labels: ${labelsSource}, + onSelectThread() {}, +})); +assert.match(html, /aria-label="Document review"/u); +assert.match(html, /aria-pressed="true"/u); +assert.match(html, />Reply]*disabled=""[^>]*>Reply', 'u')); +`, + 'utf8', + ); + const cjsPath = join(consumerDirectory, 'consumer.cjs'); + writeFileSync( + cjsPath, + `const assert = require('node:assert/strict'); +const React = require('react'); +const { renderToStaticMarkup } = require('react-dom/server'); +const { CwlReviewThreadList } = require('${packageJson.name}/review-react'); +const html = renderToStaticMarkup(React.createElement(CwlReviewThreadList, { + presentations: [${JSON.stringify(presentationFixture('b'))}], + labels: ${labelsSource}, + onSelectThread() {}, +})); +assert.match(html, /aria-label="Document review"/u); +assert.match(html, /aria-pressed="true"/u); +assert.match(html, />Resolve]*disabled=""[^>]*>Resolve', 'u')); +`, + 'utf8', + ); + run(process.execPath, [esmPath], consumerDirectory); + run(process.execPath, [cjsPath], consumerDirectory); +} + +function verifyDeclarationConsumer() { + const sourcePath = join(consumerDirectory, 'consumer.ts'); + const configurationPath = join(consumerDirectory, 'tsconfig.json'); + writeFileSync( + sourcePath, + `import { + CwlReviewThreadList, + type CwlReviewThreadListLabels, + type CwlReviewThreadListProps, +} from '${packageJson.name}/review-react'; +const labels: CwlReviewThreadListLabels = { + region: 'Document review', + thread: (thread, index) => String(index) + thread.state, + reply: 'Reply', + resolve: 'Resolve', +}; +const props: CwlReviewThreadListProps = { + presentations: [], + labels, + onSelectThread(thread) { + void thread.threadKey; + }, +}; +const component: typeof CwlReviewThreadList = CwlReviewThreadList; +void [props, component]; +`, + 'utf8', + ); + writeFileSync( + configurationPath, + `${JSON.stringify( + { + compilerOptions: { + noEmit: true, + strict: true, + skipLibCheck: false, + module: 'NodeNext', + moduleResolution: 'NodeNext', + target: 'ES2022', + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + jsx: 'react-jsx', + types: ['react'], + }, + files: ['./consumer.ts'], + }, + null, + 2, + )}\n`, + 'utf8', + ); + const compilerPath = join(repositoryRoot, 'node_modules', 'typescript', 'bin', 'tsc'); + assert.ok(existsSync(compilerPath)); + run(process.execPath, [compilerPath, '--project', configurationPath], consumerDirectory); +} + +try { + preparePackage(); + verifyBoundedRuntimeImports(); + verifyRuntimeConsumers(); + verifyDeclarationConsumer(); + console.log( + `Verified packed ${packageJson.name}/review-react through bounded ESM, CommonJS, and strict TypeScript consumers.`, + ); +} finally { + rmSync(verificationRoot, { recursive: true, force: true }); +} diff --git a/src/review-react/index.test.tsx b/src/review-react/index.test.tsx new file mode 100644 index 00000000..553b20da --- /dev/null +++ b/src/review-react/index.test.tsx @@ -0,0 +1,284 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createReviewThreadPresentation } from '../review/index.js'; +import { CwlReviewThreadList } from './index.js'; + +afterEach(cleanup); + +function target(digest = 'a') { + const digestHex = digest.repeat(64); + return { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { + type: 'TextPositionSelector', + start: 3, + end: 8, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }; +} + +function presentation( + threadKey: string, + overrides: Record = {}, +) { + return { + contractVersion: 1, + threadKey, + target: target(threadKey === 'thread_1' ? 'a' : 'b'), + state: 'unresolved', + commentCount: 2, + selected: false, + canReply: true, + canResolve: true, + ...overrides, + }; +} + +const labels = { + region: 'Document review', + thread: (thread: ReturnType, index: number) => + `Thread ${index + 1}: ${thread.state}, ${thread.commentCount} comments`, + reply: 'Reply', + resolve: 'Resolve', +}; + +function expectInvalidLabels(candidate: unknown) { + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); +} + +describe('CwlReviewThreadList', () => { + it('renders a controlled accessible thread list and emits detached presentation intents', () => { + const onSelectThread = vi.fn(); + const onReplyThread = vi.fn(); + const onResolveThread = vi.fn(); + const first = presentation('thread_1', { selected: true }); + const second = presentation('thread_2', { + state: 'resolved', + canReply: false, + canResolve: true, + }); + + render( + , + ); + + const region = screen.getByRole('region', { name: 'Document review' }); + expect(region).toBeInTheDocument(); + + const firstThread = screen.getByRole('button', { + name: 'Thread 1: unresolved, 2 comments', + }); + const secondThread = screen.getByRole('button', { + name: 'Thread 2: resolved, 2 comments', + }); + expect(firstThread).toHaveAttribute('aria-pressed', 'true'); + expect(secondThread).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(secondThread); + expect(onSelectThread).toHaveBeenCalledTimes(1); + const selected = onSelectThread.mock.calls[0]?.[0] as ReturnType< + typeof createReviewThreadPresentation + >; + expect(selected.threadKey).toBe('thread_2'); + expect(selected).not.toBe(second); + expect(Object.isFrozen(selected)).toBe(true); + + const replyButtons = [ + screen.getByRole('button', { + name: 'Reply — Thread 1: unresolved, 2 comments', + }), + screen.getByRole('button', { + name: 'Reply — Thread 2: resolved, 2 comments', + }), + ]; + const resolveButtons = [ + screen.getByRole('button', { + name: 'Resolve — Thread 1: unresolved, 2 comments', + }), + screen.getByRole('button', { + name: 'Resolve — Thread 2: resolved, 2 comments', + }), + ]; + expect(replyButtons[0]).toHaveTextContent('Reply'); + expect(resolveButtons[0]).toHaveTextContent('Resolve'); + expect(replyButtons[0]).toBeEnabled(); + expect(replyButtons[1]).toBeDisabled(); + expect(resolveButtons[0]).toBeEnabled(); + expect(resolveButtons[1]).toBeDisabled(); + + fireEvent.click(replyButtons[0]!); + fireEvent.click(resolveButtons[0]!); + expect(onReplyThread).toHaveBeenCalledWith(expect.objectContaining({ threadKey: 'thread_1' })); + expect(onResolveThread).toHaveBeenCalledWith( + expect.objectContaining({ threadKey: 'thread_1' }), + ); + }); + + it('keeps capability booleans presentation-only when host action callbacks are absent', () => { + render( + , + ); + + expect( + screen.getByRole('button', { + name: 'Reply — Thread 1: unresolved, 2 comments', + }), + ).toBeDisabled(); + expect( + screen.getByRole('button', { + name: 'Resolve — Thread 1: unresolved, 2 comments', + }), + ).toBeDisabled(); + }); + + it('fails closed before rendering duplicate host thread keys', () => { + const first = presentation('thread_1'); + const duplicate = presentation('thread_1', { selected: true }); + + expect(() => + render( + , + ), + ).toThrow(/Review presentation metadata is invalid/u); + expect(screen.queryByRole('region', { name: 'Document review' })).not.toBeInTheDocument(); + }); + + it('fails closed before invoking malformed presentation collection behavior', () => { + const privateSentinel = 'private-presentation-collection-must-not-leak'; + const hostileCollection = Object.defineProperty({}, 'map', { + enumerable: true, + get() { + throw new Error(privateSentinel); + }, + }) as unknown as readonly unknown[]; + + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + }); + + it('fails closed through the review contract before rendering hostile thread metadata', () => { + const hostile = presentation('thread_1', { + commentBody: 'private-body-must-not-render', + }); + + expect(() => + render( + , + ), + ).toThrow(/Review presentation metadata is invalid/u); + expect(screen.queryByText('private-body-must-not-render')).not.toBeInTheDocument(); + }); + + it('fails closed before invoking accessor-backed or inaccessible host labels', () => { + const privateSentinel = 'private-review-label-must-not-leak'; + let regionGetterCalls = 0; + const hostileLabels = { + thread: labels.thread, + reply: labels.reply, + resolve: labels.resolve, + } as Record; + Object.defineProperty(hostileLabels, 'region', { + enumerable: true, + get() { + regionGetterCalls += 1; + throw new Error(privateSentinel); + }, + }); + + expectInvalidLabels(hostileLabels); + expect(regionGetterCalls).toBe(0); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + + expectInvalidLabels({ ...labels, region: '' }); + expectInvalidLabels({ ...labels, region: 'x'.repeat(513) }); + }); + + it('rejects malformed host label containers and descriptor shapes', () => { + expectInvalidLabels(null); + expectInvalidLabels({ + region: labels.region, + thread: labels.thread, + reply: labels.reply, + unexpected: labels.resolve, + }); + expectInvalidLabels({ + region: labels.region, + thread: labels.thread, + reply: labels.reply, + [Symbol('resolve')]: labels.resolve, + }); + expectInvalidLabels({ ...labels, thread: 'not-a-function' }); + + const hiddenRegion = { ...labels }; + Object.defineProperty(hiddenRegion, 'region', { + configurable: true, + enumerable: false, + value: labels.region, + }); + expectInvalidLabels(hiddenRegion); + + const missingDescriptor = new Proxy( + {}, + { + ownKeys: () => ['region', 'thread', 'reply', 'resolve'], + getOwnPropertyDescriptor: () => undefined, + }, + ); + expectInvalidLabels(missingDescriptor); + }); + + it('fails closed on invalid or throwing per-thread accessible labels', () => { + expectInvalidLabels({ ...labels, thread: () => '' }); + + const privateSentinel = 'private-thread-label-must-not-leak'; + expectInvalidLabels({ + ...labels, + thread() { + throw new Error(privateSentinel); + }, + }); + }); +}); diff --git a/src/review-react/index.tsx b/src/review-react/index.tsx new file mode 100644 index 00000000..4ffa5edb --- /dev/null +++ b/src/review-react/index.tsx @@ -0,0 +1,467 @@ +import { useId, useRef, useState } from 'react'; +import { + createReviewThreadPresentation, + CwlReviewPresentationError, + type CwlReviewThreadPresentation, +} from '../review/index.js'; + +/** Host-owned visible copy for Inkspan's bounded review-thread list. */ +export interface CwlReviewThreadListLabels { + /** Accessible name for the review region. */ + readonly region: string; + /** Visible and accessible label for one validated thread. */ + readonly thread: ( + thread: CwlReviewThreadPresentation, + index: number, + ) => string; + /** Optional visible status summary. Must be paired with `comments`. */ + readonly status?: ( + thread: CwlReviewThreadPresentation, + index: number, + ) => string; + /** Optional visible comment-count summary. Must be paired with `status`. */ + readonly comments?: ( + thread: CwlReviewThreadPresentation, + index: number, + ) => string; + /** Visible label for the host-owned reply intent. */ + readonly reply: string; + /** Visible label for the host-owned resolve intent. */ + readonly resolve: string; +} + +/** Controlled inputs and intent callbacks for the review-thread list. */ +export interface CwlReviewThreadListProps { + /** Untrusted host presentation records validated before rendering. */ + readonly presentations: readonly unknown[]; + /** Host-supplied localized visible and accessible copy. */ + readonly labels: CwlReviewThreadListLabels; + /** Selection intent; the host remains the controlled-state authority. */ + readonly onSelectThread: (thread: CwlReviewThreadPresentation) => void; + /** Optional reply intent; absence keeps reply controls disabled. */ + readonly onReplyThread?: (thread: CwlReviewThreadPresentation) => void; + /** Optional resolve intent; absence keeps resolve controls disabled. */ + readonly onResolveThread?: (thread: CwlReviewThreadPresentation) => void; +} + +const REVIEW_LABEL_KEYS = ['region', 'thread', 'reply', 'resolve'] as const; +const REVIEW_SUMMARY_LABEL_KEYS = ['status', 'comments'] as const; +const MAX_REVIEW_LABEL_CODE_UNITS = 512; +const MAX_REVIEW_THREAD_PRESENTATIONS = 1_024; + +type ReviewThreadLabelFactory = CwlReviewThreadListLabels['thread']; +type ReviewThreadStatusLabelFactory = NonNullable< + CwlReviewThreadListLabels['status'] +>; +type ReviewThreadCommentsLabelFactory = NonNullable< + CwlReviewThreadListLabels['comments'] +>; +type ReviewIntentCallback = CwlReviewThreadListProps['onSelectThread']; + +interface ValidatedReviewThreadListLabels { + readonly region: string; + readonly thread: ReviewThreadLabelFactory; + readonly status: ReviewThreadStatusLabelFactory | undefined; + readonly comments: ReviewThreadCommentsLabelFactory | undefined; + readonly reply: string; + readonly resolve: string; +} + +interface ValidatedReviewIntentCallbacks { + readonly onSelectThread: ReviewIntentCallback; + readonly onReplyThread: ReviewIntentCallback | undefined; + readonly onResolveThread: ReviewIntentCallback | undefined; +} + +function rejectReviewPresentation(): never { + throw new CwlReviewPresentationError(); +} + +function requireVisibleLabel(value: unknown): string { + if ( + typeof value !== 'string' || + value.trim().length === 0 || + value.length > MAX_REVIEW_LABEL_CODE_UNITS + ) { + rejectReviewPresentation(); + } + return value; +} + +function validateReviewThreadListLabels( + source: unknown, +): ValidatedReviewThreadListLabels { + try { + if (typeof source !== 'object' || source === null) { + rejectReviewPresentation(); + } + const ownKeys = Reflect.ownKeys(source); + const allowedKeys = [...REVIEW_LABEL_KEYS, ...REVIEW_SUMMARY_LABEL_KEYS]; + const hasSummaryLabels = + ownKeys.length === allowedKeys.length && + REVIEW_SUMMARY_LABEL_KEYS.every((key) => ownKeys.includes(key)); + if ( + (ownKeys.length !== REVIEW_LABEL_KEYS.length && !hasSummaryLabels) || + ownKeys.some( + (key) => + typeof key !== 'string' || + !allowedKeys.some((candidate) => candidate === key), + ) + ) { + rejectReviewPresentation(); + } + + const expectedKeys = hasSummaryLabels ? allowedKeys : REVIEW_LABEL_KEYS; + const values: Record = {}; + for (const key of expectedKeys) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if ( + descriptor === undefined || + descriptor.enumerable !== true || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + rejectReviewPresentation(); + } + values[key] = descriptor.value; + } + + if ( + typeof values.thread !== 'function' || + (hasSummaryLabels && + (typeof values.status !== 'function' || + typeof values.comments !== 'function')) + ) { + rejectReviewPresentation(); + } + return Object.freeze({ + region: requireVisibleLabel(values.region), + thread: values.thread as ReviewThreadLabelFactory, + status: hasSummaryLabels + ? (values.status as ReviewThreadStatusLabelFactory) + : undefined, + comments: hasSummaryLabels + ? (values.comments as ReviewThreadCommentsLabelFactory) + : undefined, + reply: requireVisibleLabel(values.reply), + resolve: requireVisibleLabel(values.resolve), + }); + } catch { + rejectReviewPresentation(); + } +} + +function validateReviewIntentCallbacks( + onSelectThread: unknown, + onReplyThread: unknown, + onResolveThread: unknown, +): ValidatedReviewIntentCallbacks { + if (typeof onSelectThread !== 'function') { + rejectReviewPresentation(); + } + if (onReplyThread !== undefined && typeof onReplyThread !== 'function') { + rejectReviewPresentation(); + } + if (onResolveThread !== undefined && typeof onResolveThread !== 'function') { + rejectReviewPresentation(); + } + return Object.freeze({ + onSelectThread: onSelectThread as ReviewIntentCallback, + onReplyThread: onReplyThread as ReviewIntentCallback | undefined, + onResolveThread: onResolveThread as ReviewIntentCallback | undefined, + }); +} + +function createThreadLabel( + labelFactory: ReviewThreadLabelFactory, + presentation: CwlReviewThreadPresentation, + index: number, +): string { + try { + return requireVisibleLabel(labelFactory(presentation, index)); + } catch { + rejectReviewPresentation(); + } +} + +function createThreadSummaryLabel( + labelFactory: + | ReviewThreadStatusLabelFactory + | ReviewThreadCommentsLabelFactory, + presentation: CwlReviewThreadPresentation, + index: number, +): string { + try { + return requireVisibleLabel(labelFactory(presentation, index)); + } catch { + rejectReviewPresentation(); + } +} + +function validateReviewThreadPresentations( + presentations: readonly unknown[], +): readonly CwlReviewThreadPresentation[] { + try { + if ( + !Array.isArray(presentations) || + presentations.length > MAX_REVIEW_THREAD_PRESENTATIONS + ) { + throw new CwlReviewPresentationError(); + } + + const validatedPresentations: CwlReviewThreadPresentation[] = []; + for (let index = 0; index < presentations.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor( + presentations, + String(index), + ); + if ( + descriptor === undefined || + descriptor.enumerable !== true || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new CwlReviewPresentationError(); + } + validatedPresentations.push( + createReviewThreadPresentation(descriptor.value), + ); + } + + const threadKeys = new Set(); + for (const presentation of validatedPresentations) { + if (threadKeys.has(presentation.threadKey)) { + throw new CwlReviewPresentationError(); + } + threadKeys.add(presentation.threadKey); + } + return validatedPresentations; + } catch { + throw new CwlReviewPresentationError(); + } +} + +function reviewThreadFocusIndex( + key: string, + index: number, + lastIndex: number, +): number | undefined { + switch (key) { + case 'ArrowDown': + return Math.min(index + 1, lastIndex); + case 'ArrowUp': + return Math.max(index - 1, 0); + case 'Home': + return 0; + case 'End': + return lastIndex; + default: + return undefined; + } +} + +function initialReviewThreadKey( + presentations: readonly CwlReviewThreadPresentation[], +): string | null { + let firstThreadKey: string | null = null; + for (const presentation of presentations) { + firstThreadKey ??= presentation.threadKey; + if (presentation.selected) { + return presentation.threadKey; + } + } + return firstThreadKey; +} + +function resolveReviewThreadKey( + presentations: readonly CwlReviewThreadPresentation[], + focusedThreadKey: string | null, +): string | null { + if (focusedThreadKey !== null) { + for (const presentation of presentations) { + if (presentation.threadKey === focusedThreadKey) { + return focusedThreadKey; + } + } + } + return initialReviewThreadKey(presentations); +} + +function invokeReviewIntent( + callback: ReviewIntentCallback, + presentation: CwlReviewThreadPresentation, +): void { + try { + callback(presentation); + } catch { + rejectReviewPresentation(); + } +} + +/** + * Render a controlled accessible list of bounded review-thread presentations. + * + * The collection is capped before any item inspection. Every array slot must be + * a dense enumerable data property, so accessor-backed or sparse host entries + * fail closed without invoking host accessors before the React-free review + * validator inspects each value. Host labels must be exact enumerable data + * fields and bounded non-empty visible strings. The required thread-label + * factory may be accompanied by paired status/comment-summary factories; + * supplying only one summary factory fails closed. Accessor-backed labels and + * thrown/private label failures are normalized to the same redacted + * presentation error before React commits inaccessible content. When summary + * factories are present their localized visible output also describes the + * thread-selection, reply, and resolve controls without changing action names. + * Required and optional host intent callbacks are preflighted and snapshotted + * before rendering so malformed runtime values fail closed at the same public + * presentation boundary rather than surfacing a native invocation TypeError. + * Private failures thrown by validated intent callbacks are likewise normalized + * to the public presentation error instead of leaking host details. Exactly one + * thread-selection target participates in the tab order: the host-selected + * thread is the initial rover when present, otherwise the first thread is. Once + * focus enters the list the rover is retained by stable validated `threadKey`. + * Arrow Up/Down and Home/End move that roving DOM focus only; keyboard traversal + * never commits host-controlled thread selection. Repeated reply/resolve controls + * include the already validated thread label in their accessible name so action + * lists remain disambiguated without changing visible host copy. The component + * emits only intent callbacks with the detached, frozen presentation snapshot; + * it does not authorize, persist, transport, mutate, resolve, or reply to + * host-owned review records. + */ +export function CwlReviewThreadList({ + presentations, + labels, + onSelectThread, + onReplyThread, + onResolveThread, +}: CwlReviewThreadListProps) { + const listId = useId(); + const threadButtons = useRef>([]); + const [focusedThreadKey, setFocusedThreadKey] = useState(null); + const validatedPresentations = + validateReviewThreadPresentations(presentations); + const validatedLabels = validateReviewThreadListLabels(labels); + const validatedCallbacks = validateReviewIntentCallbacks( + onSelectThread, + onReplyThread, + onResolveThread, + ); + const rovingThreadKey = resolveReviewThreadKey( + validatedPresentations, + focusedThreadKey, + ); + + return ( +
+
    + {validatedPresentations.map((presentation, index) => { + const threadLabel = createThreadLabel( + validatedLabels.thread, + presentation, + index, + ); + const statusFactory = validatedLabels.status; + const commentsFactory = validatedLabels.comments; + const semanticSummary = + statusFactory !== undefined && commentsFactory !== undefined + ? { + status: createThreadSummaryLabel( + statusFactory, + presentation, + index, + ), + comments: createThreadSummaryLabel( + commentsFactory, + presentation, + index, + ), + } + : undefined; + const summaryId = + semanticSummary === undefined + ? undefined + : `${listId}-thread-${index}-summary`; + const replyHandler = + presentation.canReply && + validatedCallbacks.onReplyThread !== undefined + ? () => + invokeReviewIntent( + validatedCallbacks.onReplyThread!, + presentation, + ) + : undefined; + const resolveHandler = + presentation.state === 'unresolved' && + presentation.canResolve && + validatedCallbacks.onResolveThread !== undefined + ? () => + invokeReviewIntent( + validatedCallbacks.onResolveThread!, + presentation, + ) + : undefined; + + return ( +
  • + + {semanticSummary === undefined ? null : ( + + {semanticSummary.status}{' '} + {semanticSummary.comments} + + )} + + +
  • + ); + })} +
+
+ ); +} diff --git a/src/review-react/intentCallbackValidation.test.tsx b/src/review-react/intentCallbackValidation.test.tsx new file mode 100644 index 00000000..d2e581b1 --- /dev/null +++ b/src/review-react/intentCallbackValidation.test.tsx @@ -0,0 +1,156 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CwlReviewPresentationError } from '../review/index.js'; +import { + CwlReviewThreadList, + type CwlReviewThreadListProps, +} from './index.js'; + +afterEach(cleanup); + +function presentation() { + const digestHex = 'a'.repeat(64); + return { + contractVersion: 1, + threadKey: 'thread_1', + target: { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { + type: 'TextPositionSelector', + start: 3, + end: 8, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }, + state: 'unresolved', + commentCount: 1, + selected: false, + canReply: true, + canResolve: true, + }; +} + +const labels = { + region: 'Document review', + thread: () => 'Thread 1', + reply: 'Reply', + resolve: 'Resolve', +}; + +function expectInvalidIntentCallbacks( + overrides: Record, +): void { + const props = { + presentations: [presentation()], + labels, + onSelectThread: vi.fn(), + ...overrides, + } as unknown as CwlReviewThreadListProps; + + expect(() => render()).toThrow( + 'Review presentation metadata is invalid.', + ); +} + +function renderWithCallbacks( + overrides: Partial, +): void { + render( + , + ); +} + +function expectRedactedIntentFailure( + action: () => void, + privateSentinel: string, +): void { + const observedErrors: unknown[] = []; + const handleWindowError = (event: ErrorEvent): void => { + observedErrors.push(event.error); + event.preventDefault(); + }; + + window.addEventListener('error', handleWindowError); + try { + action(); + } finally { + window.removeEventListener('error', handleWindowError); + } + + // React's development event guard can report the same thrown handler error + // more than once through JSDOM's window boundary. Cardinality is a test + // harness detail; the contract is that every externally observed error is + // the stable, redacted Inkspan presentation error. + expect(observedErrors.length).toBeGreaterThan(0); + for (const error of observedErrors) { + expect(error).toBeInstanceOf(CwlReviewPresentationError); + expect(error).toMatchObject({ + code: 'invalid_presentation', + message: 'Review presentation metadata is invalid.', + }); + expect(String(error)).not.toContain(privateSentinel); + } +} + +describe('CwlReviewThreadList intent callback validation', () => { + it('fails closed before rendering when the required selection callback is malformed', () => { + expectInvalidIntentCallbacks({ onSelectThread: null }); + }); + + it('fails closed before rendering when an optional reply callback is malformed', () => { + expectInvalidIntentCallbacks({ onReplyThread: 'not-a-function' }); + }); + + it('fails closed before rendering when an optional resolve callback is malformed', () => { + expectInvalidIntentCallbacks({ onResolveThread: 42 }); + }); + + it('redacts a private host selection callback failure at the presentation boundary', () => { + const callback = vi.fn(() => { + throw new Error('private selection sentinel'); + }); + renderWithCallbacks({ onSelectThread: callback }); + + expectRedactedIntentFailure(() => { + fireEvent.click(screen.getByRole('button', { name: 'Thread 1' })); + }, 'private selection sentinel'); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('redacts a private host reply callback failure at the presentation boundary', () => { + const callback = vi.fn(() => { + throw new Error('private reply sentinel'); + }); + renderWithCallbacks({ onReplyThread: callback }); + + expectRedactedIntentFailure(() => { + fireEvent.click(screen.getByRole('button', { name: 'Reply — Thread 1' })); + }, 'private reply sentinel'); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('redacts a private host resolve callback failure at the presentation boundary', () => { + const callback = vi.fn(() => { + throw new Error('private resolve sentinel'); + }); + renderWithCallbacks({ onResolveThread: callback }); + + expectRedactedIntentFailure(() => { + fireEvent.click(screen.getByRole('button', { name: 'Resolve — Thread 1' })); + }, 'private resolve sentinel'); + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/review-react/keyboardTraversal.test.tsx b/src/review-react/keyboardTraversal.test.tsx new file mode 100644 index 00000000..98e88306 --- /dev/null +++ b/src/review-react/keyboardTraversal.test.tsx @@ -0,0 +1,185 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CwlReviewThreadList } from './index.js'; + +afterEach(cleanup); + +function target(digest: string) { + const digestHex = digest.repeat(64); + return { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { + type: 'TextPositionSelector', + start: 3, + end: 8, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }; +} + +function presentation( + threadKey: string, + digest: string, + selected = false, +) { + return { + contractVersion: 1, + threadKey, + target: target(digest), + state: 'unresolved', + commentCount: 1, + selected, + canReply: false, + canResolve: false, + }; +} + +const labels = { + region: 'Document review', + thread: (_thread: unknown, index: number) => `Thread ${index + 1}`, + reply: 'Reply', + resolve: 'Resolve', +}; + +describe('CwlReviewThreadList keyboard traversal', () => { + it('moves focus between thread targets without committing host selection', () => { + const onSelectThread = vi.fn(); + render( + , + ); + + const first = screen.getByRole('button', { name: 'Thread 1' }); + const second = screen.getByRole('button', { name: 'Thread 2' }); + + first.focus(); + fireEvent.keyDown(first, { key: 'Tab' }); + expect(first).toHaveFocus(); + + fireEvent.keyDown(first, { key: 'ArrowDown' }); + expect(second).toHaveFocus(); + expect(onSelectThread).not.toHaveBeenCalled(); + + fireEvent.keyDown(second, { key: 'ArrowUp' }); + expect(first).toHaveFocus(); + + fireEvent.keyDown(first, { key: 'End' }); + expect(second).toHaveFocus(); + + fireEvent.keyDown(second, { key: 'Home' }); + expect(first).toHaveFocus(); + }); + + it('clamps arrow traversal at the first and last thread without selecting either thread', () => { + const onSelectThread = vi.fn(); + render( + , + ); + + const first = screen.getByRole('button', { name: 'Thread 1' }); + const last = screen.getByRole('button', { name: 'Thread 3' }); + + first.focus(); + fireEvent.keyDown(first, { key: 'ArrowUp' }); + expect(first).toHaveFocus(); + + last.focus(); + fireEvent.keyDown(last, { key: 'ArrowDown' }); + expect(last).toHaveFocus(); + + expect(onSelectThread).not.toHaveBeenCalled(); + }); + + it('keeps exactly one thread-selection target in the tab order while arrow focus roves', () => { + const onSelectThread = vi.fn(); + render( + , + ); + + const first = screen.getByRole('button', { name: 'Thread 1' }); + const second = screen.getByRole('button', { name: 'Thread 2' }); + const third = screen.getByRole('button', { name: 'Thread 3' }); + + expect(first).toHaveAttribute('tabindex', '-1'); + expect(second).toHaveAttribute('tabindex', '0'); + expect(third).toHaveAttribute('tabindex', '-1'); + + second.focus(); + fireEvent.keyDown(second, { key: 'ArrowDown' }); + expect(third).toHaveFocus(); + expect(second).toHaveAttribute('tabindex', '-1'); + expect(third).toHaveAttribute('tabindex', '0'); + + fireEvent.keyDown(third, { key: 'Home' }); + expect(first).toHaveFocus(); + expect(first).toHaveAttribute('tabindex', '0'); + expect(third).toHaveAttribute('tabindex', '-1'); + expect(onSelectThread).not.toHaveBeenCalled(); + }); + + it('re-homes the rover when the focused thread disappears from host presentation state', () => { + const onSelectThread = vi.fn(); + const { rerender } = render( + , + ); + + const second = screen.getByRole('button', { name: 'Thread 2' }); + fireEvent.focus(second); + expect(second).toHaveAttribute('tabindex', '0'); + + rerender( + , + ); + + const first = screen.getByRole('button', { name: 'Thread 1' }); + const selected = screen.getByRole('button', { name: 'Thread 2' }); + expect(first).toHaveAttribute('tabindex', '-1'); + expect(selected).toHaveAttribute('tabindex', '0'); + expect(onSelectThread).not.toHaveBeenCalled(); + }); +}); diff --git a/src/review-react/presentationCollectionBoundary.test.tsx b/src/review-react/presentationCollectionBoundary.test.tsx new file mode 100644 index 00000000..a7a28952 --- /dev/null +++ b/src/review-react/presentationCollectionBoundary.test.tsx @@ -0,0 +1,111 @@ +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CwlReviewThreadList } from './index.js'; + +afterEach(cleanup); + +function presentation(threadKey = 'thread_1') { + const digestHex = 'a'.repeat(64); + return { + contractVersion: 1, + threadKey, + target: { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { + type: 'TextPositionSelector', + start: 3, + end: 8, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }, + state: 'unresolved', + commentCount: 1, + selected: false, + canReply: true, + canResolve: true, + }; +} + +const labels = { + region: 'Document review', + thread: () => 'Thread', + reply: 'Reply', + resolve: 'Resolve', +}; + +function renderPresentations(presentations: readonly unknown[]) { + return () => + render( + , + ); +} + +describe('CwlReviewThreadList presentation collection boundary', () => { + it('rejects accessor-backed array entries without invoking the accessor', () => { + let getterCalls = 0; + const presentations: unknown[] = []; + Object.defineProperty(presentations, '0', { + enumerable: true, + configurable: true, + get() { + getterCalls += 1; + return presentation(); + }, + }); + + expect(renderPresentations(presentations)).toThrow( + 'Review presentation metadata is invalid.', + ); + expect(getterCalls).toBe(0); + }); + + it('rejects oversized collections before inspecting any presentation entry', () => { + let getterCalls = 0; + const presentations = new Array(1_025); + Object.defineProperty(presentations, '0', { + enumerable: true, + configurable: true, + get() { + getterCalls += 1; + return presentation(); + }, + }); + + expect(renderPresentations(presentations)).toThrow( + 'Review presentation metadata is invalid.', + ); + expect(getterCalls).toBe(0); + }); + + it('rejects sparse presentation arrays instead of silently skipping holes', () => { + expect(renderPresentations(new Array(1))).toThrow( + 'Review presentation metadata is invalid.', + ); + }); + + it('rejects non-enumerable presentation entries before value validation', () => { + const presentations: unknown[] = []; + Object.defineProperty(presentations, '0', { + enumerable: false, + configurable: true, + writable: true, + value: presentation(), + }); + + expect(renderPresentations(presentations)).toThrow( + 'Review presentation metadata is invalid.', + ); + }); +}); diff --git a/src/review-react/semanticSummary.test.tsx b/src/review-react/semanticSummary.test.tsx new file mode 100644 index 00000000..596572f7 --- /dev/null +++ b/src/review-react/semanticSummary.test.tsx @@ -0,0 +1,126 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CwlReviewPresentationError } from '../review/index.js'; +import { + CwlReviewThreadList, + type CwlReviewThreadListLabels, +} from './index.js'; + +afterEach(cleanup); + +function presentation( + threadKey: string, + state: 'unresolved' | 'resolved', + commentCount: number, +) { + const digestHex = (threadKey === 'thread_1' ? 'a' : 'b').repeat(64); + return { + contractVersion: 1, + threadKey, + target: { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { + type: 'TextPositionSelector', + start: 3, + end: 8, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }, + state, + commentCount, + selected: false, + canReply: true, + canResolve: state === 'unresolved', + }; +} + +const labels = { + region: 'Document review', + thread: (_thread: unknown, index: number) => `Thread ${index + 1}`, + status: (thread: { readonly state: 'unresolved' | 'resolved' }) => + thread.state === 'resolved' ? 'Resolved' : 'Unresolved', + comments: (thread: { readonly commentCount: number }) => + `${thread.commentCount} comments`, + reply: 'Reply', + resolve: 'Resolve', +} as unknown as CwlReviewThreadListLabels; + +describe('CwlReviewThreadList semantic summaries', () => { + it('renders host-localized status and count text as the accessible description for each thread and action', () => { + render( + , + ); + + const firstThread = screen.getByRole('button', { name: 'Thread 1' }); + const secondThread = screen.getByRole('button', { name: 'Thread 2' }); + expect(firstThread).toHaveAccessibleDescription('Unresolved 2 comments'); + expect(secondThread).toHaveAccessibleDescription('Resolved 5 comments'); + expect(screen.getByText('Unresolved')).toBeVisible(); + expect(screen.getByText('Resolved')).toBeVisible(); + expect(screen.getByText('2 comments')).toBeVisible(); + expect(screen.getByText('5 comments')).toBeVisible(); + + expect( + screen.getByRole('button', { name: 'Reply — Thread 1' }), + ).toHaveAccessibleDescription('Unresolved 2 comments'); + expect( + screen.getByRole('button', { name: 'Resolve — Thread 1' }), + ).toHaveAccessibleDescription('Unresolved 2 comments'); + }); + + it('fails closed when only one semantic-summary label factory is supplied', () => { + const incompleteLabels = { + region: 'Document review', + thread: () => 'Thread 1', + status: () => 'Unresolved', + reply: 'Reply', + resolve: 'Resolve', + } as unknown as CwlReviewThreadListLabels; + + expect(() => + render( + , + ), + ).toThrow('Review presentation metadata is invalid.'); + }); + + it('normalizes a thrown semantic-summary label failure to the public presentation error', () => { + const privateFailureLabels = { + ...labels, + status: () => { + throw new Error('private host localization failure'); + }, + } as unknown as CwlReviewThreadListLabels; + + expect(() => + render( + , + ), + ).toThrowError(CwlReviewPresentationError); + }); +}); diff --git a/src/review/index.test.ts b/src/review/index.test.ts new file mode 100644 index 00000000..fa86dc3e --- /dev/null +++ b/src/review/index.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest'; +import * as reviewModule from './index.js'; + +interface ReviewTargetFactorySurface { + readonly CwlReviewTargetError: new () => Error & { readonly code: 'invalid_target' }; + readonly createReviewTarget: (source: unknown) => unknown; +} + +function reviewSurface(): ReviewTargetFactorySurface { + return reviewModule as unknown as ReviewTargetFactorySurface; +} + +const digestHex = 'a'.repeat(64); +const revision = Object.freeze({ + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, +}); +const selector = Object.freeze({ + type: 'TextPositionSelector', + start: 2, + end: 5, +}); +const projection = Object.freeze({ + id: 'inkspan-prosemirror-text', + version: 1, +}); + +function validTarget(): Record { + return { + contractVersion: 1, + revision, + selector, + projection, + }; +} + +function expectInvalid(source: unknown): void { + const { createReviewTarget, CwlReviewTargetError } = reviewSurface(); + let failure: unknown; + try { + createReviewTarget(source); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(CwlReviewTargetError); + expect(failure).toMatchObject({ code: 'invalid_target' }); + expect(String(failure)).not.toContain(digestHex); +} + +describe('provider-neutral review target contract', () => { + it('creates one detached deeply frozen exact-revision target', () => { + const { createReviewTarget } = reviewSurface(); + const source = validTarget(); + const target = createReviewTarget(source) as { + readonly contractVersion: 1; + readonly revision: typeof revision; + readonly selector: typeof selector; + readonly projection: typeof projection; + }; + + expect(target).toEqual(source); + expect(target).not.toBe(source); + expect(target.revision).not.toBe(revision); + expect(target.selector).not.toBe(selector); + expect(target.projection).not.toBe(projection); + expect(Object.isFrozen(target)).toBe(true); + expect(Object.isFrozen(target.revision)).toBe(true); + expect(Object.isFrozen(target.selector)).toBe(true); + expect(Object.isFrozen(target.projection)).toBe(true); + }); + + it('rejects malformed contract versions and target shapes', () => { + expectInvalid(null); + expectInvalid({ ...validTarget(), contractVersion: 2 }); + expectInvalid({ ...validTarget(), unexpected: true }); + expectInvalid({ + ...validTarget(), + [Symbol('hidden authority')]: true, + }); + + const sameWidthUnknownKey = validTarget(); + delete sameWidthUnknownKey.contractVersion; + sameWidthUnknownKey.unexpected = 1; + expectInvalid(sameWidthUnknownKey); + + const sameWidthSymbolKey = validTarget(); + delete sameWidthSymbolKey.contractVersion; + Object.defineProperty(sameWidthSymbolKey, Symbol('hidden authority'), { + value: 1, + enumerable: true, + }); + expectInvalid(sameWidthSymbolKey); + + const accessorTarget = validTarget(); + let getterCalls = 0; + Object.defineProperty(accessorTarget, 'revision', { + enumerable: true, + get() { + getterCalls += 1; + return revision; + }, + }); + expectInvalid(accessorTarget); + expect(getterCalls).toBe(0); + + const nonEnumerableTarget = validTarget(); + Object.defineProperty(nonEnumerableTarget, 'projection', { + value: projection, + enumerable: false, + }); + expectInvalid(nonEnumerableTarget); + }); + + it('rejects malformed revision, selector, and projection metadata', () => { + expectInvalid({ + ...validTarget(), + revision: { ...revision, algorithm: 'MD5' }, + }); + expectInvalid({ + ...validTarget(), + revision: { ...revision, digestHex: 'A'.repeat(64) }, + }); + expectInvalid({ + ...validTarget(), + revision: { ...revision, digestHex: 'a'.repeat(63) }, + }); + expectInvalid({ + ...validTarget(), + revision: { ...revision, strongEntityTag: '"sha256-wrong"' }, + }); + expectInvalid({ + ...validTarget(), + selector: { ...selector, type: 'CssSelector' }, + }); + expectInvalid({ + ...validTarget(), + selector: { ...selector, start: -1 }, + }); + expectInvalid({ + ...validTarget(), + selector: { ...selector, start: 1.5 }, + }); + expectInvalid({ + ...validTarget(), + selector: { ...selector, end: Number.MAX_SAFE_INTEGER + 1 }, + }); + expectInvalid({ + ...validTarget(), + selector: { ...selector, start: 6, end: 5 }, + }); + expectInvalid({ + ...validTarget(), + projection: { ...projection, id: 'dom-text' }, + }); + expectInvalid({ + ...validTarget(), + projection: { ...projection, version: 2 }, + }); + }); + + it('rejects malformed nested property shapes without invoking accessors', () => { + for (const key of ['revision', 'selector', 'projection'] as const) { + const source = validTarget(); + const nested = { ...(source[key] as Record) }; + let getterCalls = 0; + const firstKey = Object.keys(nested)[0]!; + Object.defineProperty(nested, firstKey, { + enumerable: true, + get() { + getterCalls += 1; + return undefined; + }, + }); + source[key] = nested; + expectInvalid(source); + expect(getterCalls).toBe(0); + } + }); + + it('normalizes hostile reflection failures without leaking private causes', () => { + const secret = 'private-review-reflection-value'; + const target = new Proxy(validTarget(), { + ownKeys() { + throw new Error(secret); + }, + }); + const { createReviewTarget, CwlReviewTargetError } = reviewSurface(); + let failure: unknown; + try { + createReviewTarget(target); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(CwlReviewTargetError); + expect(String(failure)).not.toContain(secret); + }); +}); diff --git a/src/review/index.ts b/src/review/index.ts new file mode 100644 index 00000000..6c035021 --- /dev/null +++ b/src/review/index.ts @@ -0,0 +1,561 @@ +/** + * React-free review contract surface. + * + * Inkspan owns deterministic review targets and bounded proposal metadata bound + * to an exact canonical document revision and text projection. Hosts own durable + * review records, identity, authorization, tenancy, persistence, retention, + * notifications, audit, and cross-revision re-anchoring policy. + */ + +import type { DocumentEnvelopeLimits } from '../documentEnvelope.js'; +import type { + CwlEditorDocumentRevision, + DocumentEnvelopeDigestProvider, +} from '../documentEnvelopeRevision.js'; +import { + createDocumentEnvelopeTransitionEvidence, + type CwlEditorDocumentTransitionEvidence, +} from '../documentTransitionEvidence.js'; +import { + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + type CwlEditorTextPositionSelector, + type CwlEditorTextProjectionIdentity, +} from '../textPositionSelectorEvidence.js'; + +/** Version of Inkspan's deterministic review-target contract. */ +export const INKSPAN_REVIEW_CONTRACT_VERSION = 1 as const; + +/** Stable redacted failure code for malformed review-target metadata. */ +export type CwlReviewTargetErrorCode = 'invalid_target'; + +/** Raised when untrusted review-target metadata violates the public contract. */ +export class CwlReviewTargetError extends Error { + /** Stable machine-readable failure category. */ + readonly code: CwlReviewTargetErrorCode; + + /** Create one redacted review-target validation error. */ + constructor() { + super('Review target metadata is invalid.'); + this.name = 'CwlReviewTargetError'; + this.code = 'invalid_target'; + } +} + +/** Stable redacted failure code for malformed suggestion proposal metadata. */ +export type CwlReviewSuggestionErrorCode = 'invalid_suggestion'; + +/** Raised when untrusted suggestion proposal metadata violates the contract. */ +export class CwlReviewSuggestionError extends Error { + /** Stable machine-readable failure category. */ + readonly code: CwlReviewSuggestionErrorCode; + + /** Create one redacted suggestion validation error. */ + constructor() { + super('Review suggestion is invalid.'); + this.name = 'CwlReviewSuggestionError'; + this.code = 'invalid_suggestion'; + } +} + +/** Stable redacted failure code for malformed review presentation metadata. */ +export type CwlReviewPresentationErrorCode = 'invalid_presentation'; + +/** Raised when host-supplied thread presentation metadata violates the contract. */ +export class CwlReviewPresentationError extends Error { + /** Stable machine-readable failure category. */ + readonly code: CwlReviewPresentationErrorCode; + + /** Create one payload-redacted presentation validation error. */ + constructor() { + super('Review presentation metadata is invalid.'); + this.name = 'CwlReviewPresentationError'; + this.code = 'invalid_presentation'; + } +} + +/** Stable redacted failure codes for review-operation evidence. */ +export type CwlReviewOperationErrorCode = + | 'invalid_operation' + | 'stale_operation_changed' + | 'accepted_operation_unchanged' + | 'rejected_operation_changed'; + +const REVIEW_OPERATION_ERROR_MESSAGES: Record< + CwlReviewOperationErrorCode, + string +> = { + invalid_operation: 'Review operation is invalid.', + stale_operation_changed: 'Stale review operations must not change the document.', + accepted_operation_unchanged: + 'Accepted review operation must change the document revision.', + rejected_operation_changed: + 'Rejected review operation must preserve the document revision.', +}; + +/** Raised when before/after review-operation evidence violates the contract. */ +export class CwlReviewOperationError extends Error { + /** Stable machine-readable failure category. */ + readonly code: CwlReviewOperationErrorCode; + + /** Create one payload-redacted review-operation error. */ + constructor(code: CwlReviewOperationErrorCode) { + super(REVIEW_OPERATION_ERROR_MESSAGES[code]); + this.name = 'CwlReviewOperationError'; + this.code = code; + } +} + +/** + * Immutable target for a host-owned comment or suggestion. + * + * Positions are W3C TextPositionSelector offsets in Inkspan's canonical text + * projection, never DOM offsets. The revision validator prevents a host from + * silently applying a target to a different document revision. This metadata + * contract validates shape and coordinate ordering only; without the source + * document it cannot prove that an arbitrary caller-supplied `end` offset is + * within the referenced projection. Consumers should create selectors through + * Inkspan's selector APIs and reject revision mismatches before applying them. + */ +export interface CwlReviewTarget { + readonly contractVersion: typeof INKSPAN_REVIEW_CONTRACT_VERSION; + readonly revision: CwlEditorDocumentRevision; + readonly selector: CwlEditorTextPositionSelector; + readonly projection: CwlEditorTextProjectionIdentity; +} + +/** Bounded host-supplied metadata used to render one comment-thread target. */ +export interface CwlReviewThreadPresentation { + readonly contractVersion: typeof INKSPAN_REVIEW_CONTRACT_VERSION; + /** Opaque host-owned key carried for callback correlation, never generated here. */ + readonly threadKey: string; + readonly target: CwlReviewTarget; + readonly state: 'unresolved' | 'resolved'; + readonly commentCount: number; + readonly selected: boolean; + readonly canReply: boolean; + readonly canResolve: boolean; +} + +/** Detached insertion proposal with no host identity or persistence authority. */ +export interface CwlReviewInsertSuggestion { + readonly contractVersion: typeof INKSPAN_REVIEW_CONTRACT_VERSION; + readonly kind: 'insert'; + readonly target: CwlReviewTarget; + readonly text: string; +} + +/** Detached deletion proposal with no copied source text or durable authority. */ +export interface CwlReviewDeleteSuggestion { + readonly contractVersion: typeof INKSPAN_REVIEW_CONTRACT_VERSION; + readonly kind: 'delete'; + readonly target: CwlReviewTarget; +} + +/** Provider-neutral proposal data accepted by Inkspan's review contract. */ +export type CwlReviewSuggestion = + | CwlReviewInsertSuggestion + | CwlReviewDeleteSuggestion; + +/** Review decision whose effect is proven only through exact revision evidence. */ +export interface CwlReviewOperationResult { + readonly contractVersion: typeof INKSPAN_REVIEW_CONTRACT_VERSION; + readonly action: 'accept' | 'reject'; + readonly status: 'accepted' | 'rejected' | 'stale'; + readonly beforeRevision: CwlEditorDocumentRevision; + readonly resultingRevision?: CwlEditorDocumentRevision; + readonly transitionEvidence?: CwlEditorDocumentTransitionEvidence; +} + +const REVIEW_TARGET_KEYS = [ + 'contractVersion', + 'revision', + 'selector', + 'projection', +] as const; +const REVIEW_PRESENTATION_KEYS = [ + 'contractVersion', + 'threadKey', + 'target', + 'state', + 'commentCount', + 'selected', + 'canReply', + 'canResolve', +] as const; +const INSERT_SUGGESTION_KEYS = [ + 'contractVersion', + 'kind', + 'target', + 'text', +] as const; +const DELETE_SUGGESTION_KEYS = ['contractVersion', 'kind', 'target'] as const; +const REVISION_KEYS = ['algorithm', 'digestHex', 'strongEntityTag'] as const; +const SELECTOR_KEYS = ['type', 'start', 'end'] as const; +const PROJECTION_KEYS = ['id', 'version'] as const; +const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/u; +const REVIEW_THREAD_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const MAX_REVIEW_INSERT_TEXT_CODE_UNITS = 65_536; +const MAX_REVIEW_COMMENT_COUNT = 10_000; + +/** Throw one fresh redacted public validation error. */ +function rejectReviewTarget(): never { + throw new CwlReviewTargetError(); +} + +/** Throw one fresh redacted public suggestion validation error. */ +function rejectReviewSuggestion(): never { + throw new CwlReviewSuggestionError(); +} + +/** Throw one fresh redacted public presentation validation error. */ +function rejectReviewPresentation(): never { + throw new CwlReviewPresentationError(); +} + +/** + * Snapshot exactly named enumerable data properties without invoking accessors. + * + * Unknown keys, symbols, accessors, non-enumerable fields, and reflection + * failures are rejected before Inkspan retains any caller-owned object. + */ +function readExactDataRecord( + source: unknown, + expectedKeys: readonly string[], +): Readonly> { + try { + if (typeof source !== 'object' || source === null) rejectReviewTarget(); + const ownKeys = Reflect.ownKeys(source); + if (ownKeys.length !== expectedKeys.length) rejectReviewTarget(); + for (const key of ownKeys) { + if (typeof key !== 'string' || !expectedKeys.includes(key)) { + rejectReviewTarget(); + } + } + + const values: Record = {}; + for (const key of expectedKeys) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if ( + descriptor === undefined || + descriptor.enumerable !== true || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + rejectReviewTarget(); + } + values[key] = descriptor.value; + } + return values; + } catch { + rejectReviewTarget(); + } +} + +/** + * Read only the discriminant needed to choose the exact suggestion shape. + * Accessors and reflection failures are rejected without invoking caller code. + */ +function readSuggestionKind(source: unknown): 'insert' | 'delete' { + try { + if (typeof source !== 'object' || source === null) rejectReviewSuggestion(); + const descriptor = Object.getOwnPropertyDescriptor(source, 'kind'); + if ( + descriptor === undefined || + descriptor.enumerable !== true || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + rejectReviewSuggestion(); + } + if (descriptor.value !== 'insert' && descriptor.value !== 'delete') { + rejectReviewSuggestion(); + } + return descriptor.value; + } catch { + rejectReviewSuggestion(); + } +} + +/** + * Validate and detach untrusted host review-target metadata. + * + * The returned value is deeply frozen across the complete v1 target shape and + * retains no caller-owned nested objects. Reflection/accessor failures and all + * malformed fields collapse to one redacted stable error so private caller data + * is never copied into generic diagnostics. The revision is local SHA-256 + * equality evidence only; this operation grants no identity, authorization, + * tenancy, persistence, timestamp, signature, or durable-write authority. + * + * @param source - Untrusted candidate review-target metadata. + * @returns A detached, deeply frozen v1 review target. + * @throws {CwlReviewTargetError} When any target field or shape is invalid. + */ +export function createReviewTarget(source: unknown): CwlReviewTarget { + const target = readExactDataRecord(source, REVIEW_TARGET_KEYS); + if (target.contractVersion !== INKSPAN_REVIEW_CONTRACT_VERSION) { + rejectReviewTarget(); + } + + const revision = readExactDataRecord(target.revision, REVISION_KEYS); + const digestHex = revision.digestHex; + if ( + revision.algorithm !== 'SHA-256' || + typeof digestHex !== 'string' || + digestHex.length !== 64 || + !SHA256_HEX_PATTERN.test(digestHex) || + revision.strongEntityTag !== `"sha256-${digestHex}"` + ) { + rejectReviewTarget(); + } + + const selector = readExactDataRecord(target.selector, SELECTOR_KEYS); + const start = selector.start; + const end = selector.end; + if ( + selector.type !== 'TextPositionSelector' || + typeof start !== 'number' || + typeof end !== 'number' || + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start < 0 || + end < start + ) { + rejectReviewTarget(); + } + + const projection = readExactDataRecord(target.projection, PROJECTION_KEYS); + if ( + projection.id !== TEXT_POSITION_PROJECTION_ID || + projection.version !== TEXT_POSITION_PROJECTION_VERSION + ) { + rejectReviewTarget(); + } + + const detachedRevision: CwlEditorDocumentRevision = Object.freeze({ + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }); + const detachedSelector: CwlEditorTextPositionSelector = Object.freeze({ + type: 'TextPositionSelector', + start, + end, + }); + const detachedProjection: CwlEditorTextProjectionIdentity = Object.freeze({ + id: TEXT_POSITION_PROJECTION_ID, + version: TEXT_POSITION_PROJECTION_VERSION, + }); + return Object.freeze({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + revision: detachedRevision, + selector: detachedSelector, + projection: detachedProjection, + }); +} + +/** + * Validate and detach host-supplied comment-thread presentation metadata. + * + * The contract deliberately carries no comment body, actor identity, + * authorization assertion, timestamp, persistence state, or durable audit data. + * `threadKey` is an opaque bounded host-owned correlation key only; Inkspan does + * not generate, persist, authenticate, or interpret it. `commentCount`, status, + * selection, and capability booleans are presentation inputs for later + * controlled UI surfaces and grant no host authority by themselves. + * + * @param source - Untrusted host presentation metadata. + * @returns A detached, deeply frozen bounded presentation snapshot. + * @throws {CwlReviewPresentationError} When any field or shape is invalid. + */ +export function createReviewThreadPresentation( + source: unknown, +): CwlReviewThreadPresentation { + try { + const presentation = readExactDataRecord(source, REVIEW_PRESENTATION_KEYS); + if ( + presentation.contractVersion !== INKSPAN_REVIEW_CONTRACT_VERSION || + typeof presentation.threadKey !== 'string' || + !REVIEW_THREAD_KEY_PATTERN.test(presentation.threadKey) || + (presentation.state !== 'unresolved' && presentation.state !== 'resolved') || + typeof presentation.commentCount !== 'number' || + !Number.isSafeInteger(presentation.commentCount) || + presentation.commentCount < 1 || + presentation.commentCount > MAX_REVIEW_COMMENT_COUNT || + typeof presentation.selected !== 'boolean' || + typeof presentation.canReply !== 'boolean' || + typeof presentation.canResolve !== 'boolean' + ) { + rejectReviewPresentation(); + } + + const target = createReviewTarget(presentation.target); + return Object.freeze({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + threadKey: presentation.threadKey, + target, + state: presentation.state, + commentCount: presentation.commentCount, + selected: presentation.selected, + canReply: presentation.canReply, + canResolve: presentation.canResolve, + }); + } catch { + rejectReviewPresentation(); + } +} + +/** + * Validate and detach an untrusted insert/delete suggestion proposal. + * + * Insertions must target an insertion point and carry 1..65,536 UTF-16 code + * units of proposal text. Deletions must target a non-empty projected range and + * deliberately carry no copied source body. The returned proposal and target + * are frozen snapshots. This validator does not apply edits, persist records, + * assign identities, authorize actors, or grant model/provider output any + * authority; hosts must still perform admission and Inkspan revision checks at + * the operation boundary. + * + * @param source - Untrusted candidate suggestion proposal metadata. + * @returns A detached, deeply frozen v1 suggestion proposal. + * @throws {CwlReviewSuggestionError} When any proposal field or shape is invalid. + */ +export function createReviewSuggestion(source: unknown): CwlReviewSuggestion { + try { + const kind = readSuggestionKind(source); + const expectedKeys = + kind === 'insert' ? INSERT_SUGGESTION_KEYS : DELETE_SUGGESTION_KEYS; + const suggestion = readExactDataRecord(source, expectedKeys); + if ( + suggestion.contractVersion !== INKSPAN_REVIEW_CONTRACT_VERSION || + suggestion.kind !== kind + ) { + rejectReviewSuggestion(); + } + + const target = createReviewTarget(suggestion.target); + if (kind === 'insert') { + const text = suggestion.text; + if ( + target.selector.start !== target.selector.end || + typeof text !== 'string' || + text.length === 0 || + text.length > MAX_REVIEW_INSERT_TEXT_CODE_UNITS + ) { + rejectReviewSuggestion(); + } + return Object.freeze({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + kind, + target, + text, + }); + } + + if (target.selector.start === target.selector.end) { + rejectReviewSuggestion(); + } + return Object.freeze({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + kind, + target, + }); + } catch { + rejectReviewSuggestion(); + } +} + +/** + * Bind a host/editor review decision to exact before/after document revisions. + * + * This function does not apply an editor transaction and does not persist a + * review decision. The caller supplies the actual previous and resulting + * document envelopes after its authorized operation. Inkspan validates the + * proposal, derives canonical transition evidence, and refuses to classify an + * accepted operation that changed nothing or a rejected operation that changed + * the document. A stale proposal returns a compact `stale` result only when the + * actual document remained unchanged; stale evidence paired with a mutation is + * rejected fail-closed rather than hiding an out-of-contract document change. + * + * The result contains revisions and transition metadata only; proposal text and + * document bodies are not retained. Host-owned identity, authorization, + * persistence, exact-once durable state, audit, and conflict policy remain out + * of scope. + * + * @param suggestionSource - Untrusted provider-neutral insert/delete proposal. + * @param action - Host-authorized review decision to classify. + * @param previousSource - Exact document envelope observed before the operation. + * @param resultingSource - Exact document envelope observed after the operation. + * @param limits - Optional strict document-envelope resource limits. + * @param digestProvider - Optional SHA-256 provider for deterministic testing. + * @returns Frozen revision-only review-operation evidence. + * @throws {CwlReviewOperationError} When action/change semantics conflict. + */ +export async function createReviewOperationResult( + suggestionSource: unknown, + action: 'accept' | 'reject', + previousSource: unknown, + resultingSource: unknown, + limits?: DocumentEnvelopeLimits, + digestProvider?: DocumentEnvelopeDigestProvider | null, +): Promise { + if (action !== 'accept' && action !== 'reject') { + throw new CwlReviewOperationError('invalid_operation'); + } + const suggestion = createReviewSuggestion(suggestionSource); + const transition = await createDocumentEnvelopeTransitionEvidence( + previousSource, + resultingSource, + limits, + digestProvider, + ); + + if ( + transition.previousRevision.digestHex !== suggestion.target.revision.digestHex + ) { + if (transition.changed) { + throw new CwlReviewOperationError('stale_operation_changed'); + } + return Object.freeze({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + action, + status: 'stale', + beforeRevision: transition.previousRevision, + }); + } + if (action === 'accept' && !transition.changed) { + throw new CwlReviewOperationError('accepted_operation_unchanged'); + } + if (action === 'reject' && transition.changed) { + throw new CwlReviewOperationError('rejected_operation_changed'); + } + + return Object.freeze({ + contractVersion: INKSPAN_REVIEW_CONTRACT_VERSION, + action, + status: action === 'accept' ? 'accepted' : 'rejected', + beforeRevision: transition.previousRevision, + resultingRevision: transition.resultingRevision, + transitionEvidence: transition, + }); +} + +export { + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + TextPositionSelectorEvidenceError, + createTextPositionSelector, +} from '../textPositionSelectorEvidence.js'; +export type { + CwlEditorTextPositionSelector, + CwlEditorTextProjectionIdentity, + TextPositionSelectorEvidenceErrorCode, +} from '../textPositionSelectorEvidence.js'; +export { + DocumentEnvelopeRevisionError, + createDocumentEnvelopeRevision, + createDocumentEnvelopeRevisionBytes, +} from '../documentEnvelopeRevision.js'; +export type { + CwlEditorDocumentRevision, + DocumentEnvelopeDigestProvider, +} from '../documentEnvelopeRevision.js'; +export type { CwlEditorDocumentTransitionEvidence } from '../documentTransitionEvidence.js'; diff --git a/src/review/operation.test.ts b/src/review/operation.test.ts new file mode 100644 index 00000000..60552fdf --- /dev/null +++ b/src/review/operation.test.ts @@ -0,0 +1,292 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { createDocumentEnvelope } from '../documentEnvelope.js'; +import { + createDocumentEnvelopeRevision, + type CwlEditorDocumentRevision, + type DocumentEnvelopeDigestProvider, +} from '../documentEnvelopeRevision.js'; +import * as reviewModule from './index.js'; + +interface ReviewOperationSurface { + readonly CwlReviewOperationError: new ( + code: + | 'invalid_operation' + | 'stale_operation_changed' + | 'accepted_operation_unchanged' + | 'rejected_operation_changed', + ) => Error & { + readonly code: + | 'invalid_operation' + | 'stale_operation_changed' + | 'accepted_operation_unchanged' + | 'rejected_operation_changed'; + }; + readonly createReviewOperationResult: ( + suggestion: unknown, + action: 'accept' | 'reject', + previousSource: unknown, + resultingSource: unknown, + limits?: unknown, + digestProvider?: DocumentEnvelopeDigestProvider | null, + ) => Promise; +} + +function reviewOperationSurface(): ReviewOperationSurface { + return reviewModule as unknown as ReviewOperationSurface; +} + +function toBytes(source: BufferSource): Uint8Array { + return ArrayBuffer.isView(source) + ? new Uint8Array(source.buffer, source.byteOffset, source.byteLength) + : new Uint8Array(source); +} + +function sha256(source: BufferSource): ArrayBuffer { + const digest = createHash('sha256').update(toBytes(source)).digest(); + const result = new Uint8Array(32); + result.set(digest); + return result.buffer; +} + +function digestProvider(): DocumentEnvelopeDigestProvider { + return { + async digest(algorithm, source) { + expect(algorithm).toBe('SHA-256'); + return sha256(source); + }, + }; +} + +function target(revision: CwlEditorDocumentRevision) { + return { + contractVersion: 1, + revision, + selector: { + type: 'TextPositionSelector', + start: 0, + end: 0, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }; +} + +function insertSuggestion(revision: CwlEditorDocumentRevision) { + return { + contractVersion: 1, + kind: 'insert', + target: target(revision), + text: '검토 제안', + }; +} + +const BEFORE_DOCUMENT = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'private before body' }], + }, + ], +}; +const AFTER_DOCUMENT = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'private after body' }], + }, + ], +}; + +describe('provider-neutral review operation evidence', () => { + it('binds an accepted proposal to exact before/after revisions without retaining document bodies', async () => { + const provider = digestProvider(); + const previousEnvelope = createDocumentEnvelope(BEFORE_DOCUMENT); + const resultingEnvelope = createDocumentEnvelope(AFTER_DOCUMENT); + const expectedRevision = await createDocumentEnvelopeRevision( + previousEnvelope, + undefined, + provider, + ); + + const result = (await reviewOperationSurface().createReviewOperationResult( + insertSuggestion(expectedRevision), + 'accept', + previousEnvelope, + resultingEnvelope, + undefined, + provider, + )) as { + readonly contractVersion: 1; + readonly action: 'accept'; + readonly status: 'accepted'; + readonly beforeRevision: CwlEditorDocumentRevision; + readonly resultingRevision: CwlEditorDocumentRevision; + readonly transitionEvidence: { + readonly previousRevision: CwlEditorDocumentRevision; + readonly resultingRevision: CwlEditorDocumentRevision; + readonly changed: boolean; + }; + }; + + expect(result.contractVersion).toBe(1); + expect(result.action).toBe('accept'); + expect(result.status).toBe('accepted'); + expect(result.beforeRevision).toEqual(expectedRevision); + expect(result.resultingRevision).not.toEqual(expectedRevision); + expect(result.transitionEvidence).toMatchObject({ + previousRevision: expectedRevision, + changed: true, + }); + expect(Object.isFrozen(result)).toBe(true); + expect(JSON.stringify(result)).not.toContain('private before body'); + expect(JSON.stringify(result)).not.toContain('private after body'); + expect(JSON.stringify(result)).not.toContain('검토 제안'); + }); + + it('returns a stable stale result only when a mismatched target leaves the document unchanged', async () => { + const provider = digestProvider(); + const previousEnvelope = createDocumentEnvelope(BEFORE_DOCUMENT); + const staleDigest = 'f'.repeat(64); + const staleRevision = Object.freeze({ + algorithm: 'SHA-256' as const, + digestHex: staleDigest, + strongEntityTag: `"sha256-${staleDigest}"`, + }); + + const result = (await reviewOperationSurface().createReviewOperationResult( + insertSuggestion(staleRevision), + 'accept', + previousEnvelope, + previousEnvelope, + undefined, + provider, + )) as Record; + + expect(result.status).toBe('stale'); + expect(result.action).toBe('accept'); + expect(result.beforeRevision).not.toEqual(staleRevision); + expect(result).not.toHaveProperty('resultingRevision'); + expect(result).not.toHaveProperty('transitionEvidence'); + expect(Object.isFrozen(result)).toBe(true); + }); + + it('fails closed when a stale proposal is reported with a changed resulting document', async () => { + const provider = digestProvider(); + const previousEnvelope = createDocumentEnvelope(BEFORE_DOCUMENT); + const resultingEnvelope = createDocumentEnvelope(AFTER_DOCUMENT); + const staleDigest = 'f'.repeat(64); + const staleRevision = Object.freeze({ + algorithm: 'SHA-256' as const, + digestHex: staleDigest, + strongEntityTag: `"sha256-${staleDigest}"`, + }); + + await expect( + reviewOperationSurface().createReviewOperationResult( + insertSuggestion(staleRevision), + 'accept', + previousEnvelope, + resultingEnvelope, + undefined, + provider, + ), + ).rejects.toMatchObject({ + name: 'CwlReviewOperationError', + code: 'stale_operation_changed', + message: 'Stale review operations must not change the document.', + }); + }); + + it('requires accepted operations to change the document and rejected operations to preserve it', async () => { + const provider = digestProvider(); + const previousEnvelope = createDocumentEnvelope(BEFORE_DOCUMENT); + const resultingEnvelope = createDocumentEnvelope(AFTER_DOCUMENT); + const expectedRevision = await createDocumentEnvelopeRevision( + previousEnvelope, + undefined, + provider, + ); + const suggestion = insertSuggestion(expectedRevision); + const { createReviewOperationResult, CwlReviewOperationError } = + reviewOperationSurface(); + + await expect( + createReviewOperationResult( + suggestion, + 'accept', + previousEnvelope, + previousEnvelope, + undefined, + provider, + ), + ).rejects.toMatchObject({ + code: 'accepted_operation_unchanged', + }); + await expect( + createReviewOperationResult( + suggestion, + 'reject', + previousEnvelope, + resultingEnvelope, + undefined, + provider, + ), + ).rejects.toMatchObject({ + code: 'rejected_operation_changed', + }); + + const dynamicOperation = createReviewOperationResult as unknown as ( + suggestion: unknown, + action: unknown, + previousSource: unknown, + resultingSource: unknown, + limits?: unknown, + digestProvider?: DocumentEnvelopeDigestProvider | null, + ) => Promise; + await expect( + dynamicOperation( + suggestion, + 'approve', + previousEnvelope, + resultingEnvelope, + undefined, + provider, + ), + ).rejects.toMatchObject({ code: 'invalid_operation' }); + + const failure = new CwlReviewOperationError('invalid_operation'); + expect(failure.message).not.toContain('private before body'); + expect(failure.message).not.toContain('검토 제안'); + }); + + it('returns a frozen rejected result for an unchanged exact-revision operation', async () => { + const provider = digestProvider(); + const previousEnvelope = createDocumentEnvelope(BEFORE_DOCUMENT); + const expectedRevision = await createDocumentEnvelopeRevision( + previousEnvelope, + undefined, + provider, + ); + + const result = (await reviewOperationSurface().createReviewOperationResult( + insertSuggestion(expectedRevision), + 'reject', + previousEnvelope, + previousEnvelope, + undefined, + provider, + )) as { + readonly status: 'rejected'; + readonly transitionEvidence: { readonly changed: boolean }; + }; + + expect(result.status).toBe('rejected'); + expect(result.transitionEvidence.changed).toBe(false); + expect(Object.isFrozen(result)).toBe(true); + }); +}); diff --git a/src/review/presentation.test.ts b/src/review/presentation.test.ts new file mode 100644 index 00000000..7a5c8af9 --- /dev/null +++ b/src/review/presentation.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; +import * as reviewModule from './index.js'; + +interface ReviewPresentationSurface { + readonly CwlReviewPresentationError: new () => Error & { + readonly code: 'invalid_presentation'; + }; + readonly createReviewThreadPresentation: (source: unknown) => unknown; +} + +function reviewPresentationSurface(): ReviewPresentationSurface { + return reviewModule as unknown as ReviewPresentationSurface; +} + +function target() { + const digestHex = 'a'.repeat(64); + return { + contractVersion: 1, + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { + type: 'TextPositionSelector', + start: 3, + end: 8, + }, + projection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }; +} + +function presentation(overrides: Record = {}) { + return { + contractVersion: 1, + threadKey: 'thread_123', + target: target(), + state: 'unresolved', + commentCount: 2, + selected: true, + canReply: true, + canResolve: true, + ...overrides, + }; +} + +describe('review thread presentation contract', () => { + it('detaches and freezes bounded host presentation metadata without comment bodies', () => { + const { createReviewThreadPresentation } = reviewPresentationSurface(); + const source = presentation(); + const result = createReviewThreadPresentation(source) as { + readonly threadKey: string; + readonly target: { + readonly selector: { readonly start: number; readonly end: number }; + }; + readonly state: string; + readonly commentCount: number; + readonly selected: boolean; + readonly canReply: boolean; + readonly canResolve: boolean; + }; + + expect(result).toEqual(source); + expect(result).not.toBe(source); + expect(result.target).not.toBe(source.target); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.target)).toBe(true); + expect(Object.isFrozen(result.target.selector)).toBe(true); + expect(JSON.stringify(result)).not.toContain('commentBody'); + }); + + it('supports resolved and permission-disabled presentation without inventing actor authority', () => { + const { createReviewThreadPresentation } = reviewPresentationSurface(); + const result = createReviewThreadPresentation( + presentation({ + state: 'resolved', + selected: false, + canReply: false, + canResolve: false, + }), + ) as Record; + + expect(result).toMatchObject({ + state: 'resolved', + selected: false, + canReply: false, + canResolve: false, + }); + expect(result).not.toHaveProperty('actorId'); + expect(result).not.toHaveProperty('authorized'); + }); + + it('fails closed on body-like or otherwise unsupported presentation fields', () => { + const { + createReviewThreadPresentation, + CwlReviewPresentationError, + } = reviewPresentationSurface(); + const privateBody = 'private-review-body-must-not-leak'; + + expect(() => + createReviewThreadPresentation( + presentation({ commentBody: privateBody }), + ), + ).toThrow(CwlReviewPresentationError); + try { + createReviewThreadPresentation(presentation({ commentBody: privateBody })); + } catch (error) { + expect(error).toMatchObject({ code: 'invalid_presentation' }); + expect(String(error)).not.toContain(privateBody); + } + }); + + it('bounds opaque host thread keys and comment counts', () => { + const { createReviewThreadPresentation } = reviewPresentationSurface(); + + expect(() => + createReviewThreadPresentation(presentation({ threadKey: '' })), + ).toThrow(); + expect(() => + createReviewThreadPresentation( + presentation({ threadKey: `thread_${'x'.repeat(122)}` }), + ), + ).toThrow(); + expect(() => + createReviewThreadPresentation(presentation({ commentCount: 0 })), + ).toThrow(); + expect(() => + createReviewThreadPresentation(presentation({ commentCount: 10_001 })), + ).toThrow(); + }); + + it('rejects hostile accessors without invoking them', () => { + const { createReviewThreadPresentation } = reviewPresentationSurface(); + let reads = 0; + const source = presentation(); + Object.defineProperty(source, 'threadKey', { + enumerable: true, + configurable: true, + get() { + reads += 1; + throw new Error('private accessor payload'); + }, + }); + + expect(() => createReviewThreadPresentation(source)).toThrow(); + expect(reads).toBe(0); + }); +}); diff --git a/src/review/suggestion.test.ts b/src/review/suggestion.test.ts new file mode 100644 index 00000000..a5c2daab --- /dev/null +++ b/src/review/suggestion.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from 'vitest'; +import * as reviewModule from './index.js'; + +interface ReviewSuggestionFactorySurface { + readonly CwlReviewSuggestionError: new () => Error & { + readonly code: 'invalid_suggestion'; + }; + readonly createReviewSuggestion: (source: unknown) => unknown; +} + +function reviewSuggestionSurface(): ReviewSuggestionFactorySurface { + return reviewModule as unknown as ReviewSuggestionFactorySurface; +} + +const digestHex = 'b'.repeat(64); +const revision = Object.freeze({ + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, +}); +const projection = Object.freeze({ + id: 'inkspan-prosemirror-text', + version: 1, +}); + +function target(start: number, end: number): Record { + return { + contractVersion: 1, + revision, + selector: { + type: 'TextPositionSelector', + start, + end, + }, + projection, + }; +} + +function insertSuggestion(): Record { + return { + contractVersion: 1, + kind: 'insert', + target: target(2, 2), + text: '제안 👩🏽‍💻', + }; +} + +function deleteSuggestion(): Record { + return { + contractVersion: 1, + kind: 'delete', + target: target(2, 5), + }; +} + +function expectInvalidSuggestion(source: unknown): void { + const { createReviewSuggestion, CwlReviewSuggestionError } = + reviewSuggestionSurface(); + let failure: unknown; + try { + createReviewSuggestion(source); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(CwlReviewSuggestionError); + expect(failure).toMatchObject({ code: 'invalid_suggestion' }); + expect(String(failure)).not.toContain(digestHex); +} + +describe('provider-neutral review suggestion contract', () => { + it('creates detached deeply frozen insertion and deletion proposals', () => { + const { createReviewSuggestion } = reviewSuggestionSurface(); + const insertSource = insertSuggestion(); + const deleteSource = deleteSuggestion(); + + const insert = createReviewSuggestion(insertSource) as { + readonly contractVersion: 1; + readonly kind: 'insert'; + readonly target: { + readonly selector: { readonly start: number; readonly end: number }; + }; + readonly text: string; + }; + const deletion = createReviewSuggestion(deleteSource) as { + readonly contractVersion: 1; + readonly kind: 'delete'; + readonly target: { + readonly selector: { readonly start: number; readonly end: number }; + }; + }; + + expect(insert).toEqual(insertSource); + expect(insert).not.toBe(insertSource); + expect(insert.target).not.toBe(insertSource.target); + expect(insert.text).toBe('제안 👩🏽‍💻'); + expect(Object.isFrozen(insert)).toBe(true); + expect(Object.isFrozen(insert.target)).toBe(true); + expect(Object.isFrozen(insert.target.selector)).toBe(true); + + expect(deletion).toEqual(deleteSource); + expect(deletion).not.toBe(deleteSource); + expect(deletion.target).not.toBe(deleteSource.target); + expect(Object.isFrozen(deletion)).toBe(true); + expect(Object.isFrozen(deletion.target)).toBe(true); + }); + + it('requires exact bounded shapes before retaining suggestion proposal data', () => { + expectInvalidSuggestion(null); + expectInvalidSuggestion('not-a-suggestion'); + expectInvalidSuggestion({ contractVersion: 1, target: target(1, 1) }); + expectInvalidSuggestion({ ...insertSuggestion(), kind: 'replace' }); + expectInvalidSuggestion({ ...insertSuggestion(), contractVersion: 2 }); + expectInvalidSuggestion({ ...insertSuggestion(), unexpected: true }); + expectInvalidSuggestion({ + ...insertSuggestion(), + [Symbol('hidden authority')]: true, + }); + + const accessorKind = insertSuggestion(); + let getterCalls = 0; + Object.defineProperty(accessorKind, 'kind', { + enumerable: true, + get() { + getterCalls += 1; + return 'insert'; + }, + }); + expectInvalidSuggestion(accessorKind); + expect(getterCalls).toBe(0); + + const hiddenKind = insertSuggestion(); + Object.defineProperty(hiddenKind, 'kind', { + value: 'insert', + enumerable: false, + }); + expectInvalidSuggestion(hiddenKind); + }); + + it('requires insertion points and bounded non-empty insertion text', () => { + expectInvalidSuggestion({ + ...insertSuggestion(), + target: target(2, 3), + }); + expectInvalidSuggestion({ ...insertSuggestion(), text: '' }); + expectInvalidSuggestion({ ...insertSuggestion(), text: 1 }); + expectInvalidSuggestion({ + ...insertSuggestion(), + text: 'a'.repeat(65_537), + }); + + const exactLimit = { + ...insertSuggestion(), + text: 'a'.repeat(65_536), + }; + const accepted = reviewSuggestionSurface().createReviewSuggestion( + exactLimit, + ) as { readonly text: string }; + expect(accepted.text).toHaveLength(65_536); + }); + + it('requires deletion suggestions to select existing projected text only', () => { + expectInvalidSuggestion({ + ...deleteSuggestion(), + target: target(2, 2), + }); + expectInvalidSuggestion({ ...deleteSuggestion(), text: 'copied source text' }); + expectInvalidSuggestion({ + ...deleteSuggestion(), + target: { + ...target(2, 5), + revision: { ...revision, algorithm: 'MD5' }, + }, + }); + }); + + it('fails closed when hostile reflection changes or rejects kind evidence', () => { + const privateValue = 'private-suggestion-reflection-value'; + let kindReads = 0; + const changingKind = new Proxy(insertSuggestion(), { + getOwnPropertyDescriptor(source, property) { + const descriptor = Reflect.getOwnPropertyDescriptor(source, property); + if (property !== 'kind' || descriptor === undefined) return descriptor; + kindReads += 1; + return { + ...descriptor, + value: kindReads === 1 ? 'insert' : 'delete', + }; + }, + }); + expectInvalidSuggestion(changingKind); + + const hostileKeys = new Proxy(insertSuggestion(), { + ownKeys() { + throw new Error(privateValue); + }, + }); + const { createReviewSuggestion, CwlReviewSuggestionError } = + reviewSuggestionSurface(); + let failure: unknown; + try { + createReviewSuggestion(hostileKeys); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(CwlReviewSuggestionError); + expect(String(failure)).not.toContain(privateValue); + }); +}); diff --git a/src/reviewPackageContract.test.ts b/src/reviewPackageContract.test.ts new file mode 100644 index 00000000..831b5dcc --- /dev/null +++ b/src/reviewPackageContract.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +interface PackageManifest { + exports?: Record< + string, + { + types?: string; + import?: string; + require?: string; + } + >; + scripts?: Record; +} + +const manifest = JSON.parse( + readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'), +) as PackageManifest; + +describe('review package contract', () => { + it('publishes a dedicated React-free review subpath', () => { + expect(manifest.exports?.['./review']).toEqual({ + types: './dist/review/index.d.ts', + import: './dist/cwl-review.js', + require: './dist/cwl-review.cjs', + }); + expect(manifest.scripts?.build).toContain( + 'vite build --config vite.review.config.ts', + ); + expect(manifest.scripts?.['verify:package']).toContain( + 'scripts/verify-review-package.mjs', + ); + }); + + it('publishes the controlled React review adapter as a separate subpath', () => { + expect(manifest.exports?.['./review-react']).toEqual({ + types: './dist/review-react/index.d.ts', + import: './dist/cwl-review-react.js', + require: './dist/cwl-review-react.cjs', + }); + expect(manifest.scripts?.build).toContain( + 'vite build --config vite.review-react.config.ts', + ); + expect(manifest.scripts?.['verify:package']).toContain( + 'scripts/verify-review-react-package.mjs', + ); + }); +}); diff --git a/vite.review-react.config.ts b/vite.review-react.config.ts new file mode 100644 index 00000000..ac606bf0 --- /dev/null +++ b/vite.review-react.config.ts @@ -0,0 +1,40 @@ +import { resolve } from 'node:path'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +/** + * Build the controlled React presentation adapter separately from the + * framework-free review contract. React remains host-supplied peer authority. + */ +export default defineConfig({ + plugins: [ + react(), + dts({ + include: [ + 'src/review-react', + 'src/review', + 'src/documentEnvelopeRevision.ts', + 'src/textPositionSelectorEvidence.ts', + ], + exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts'], + rollupTypes: false, + entryRoot: 'src', + }), + ], + build: { + emptyOutDir: false, + lib: { + entry: resolve(__dirname, 'src/review-react/index.tsx'), + name: 'InkspanReviewReact', + fileName: (format) => + format === 'es' ? 'cwl-review-react.js' : 'cwl-review-react.cjs', + formats: ['es', 'cjs'], + }, + sourcemap: true, + rollupOptions: { + external: ['react', 'react-dom', 'react/jsx-runtime'], + output: { interop: 'auto' }, + }, + }, +}); diff --git a/vite.review.config.ts b/vite.review.config.ts new file mode 100644 index 00000000..420c5ff7 --- /dev/null +++ b/vite.review.config.ts @@ -0,0 +1,31 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +// React-free review contract build: deterministic revision/selector evidence only. +// No React UI, network, credential, persistence, tenancy, model, or provider authority. +export default defineConfig({ + plugins: [ + dts({ + include: [ + 'src/review', + 'src/documentEnvelopeRevision.ts', + 'src/textPositionSelectorEvidence.ts', + ], + exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts'], + rollupTypes: false, + entryRoot: 'src', + }), + ], + build: { + emptyOutDir: false, + lib: { + entry: resolve(__dirname, 'src/review/index.ts'), + name: 'InkspanReview', + fileName: (format) => + format === 'es' ? 'cwl-review.js' : 'cwl-review.cjs', + formats: ['es', 'cjs'], + }, + sourcemap: true, + }, +});