fix(git-sync): address PR #119 review #4 — symlink guard, dead-code cull, changelog + warnings/suggestions

Blocking (review id 2514):
- [security] Forbid symlinks in vaults. ensureServable now sets
  core.symlinks=false in each vault's local git config (a pushed symlink is
  checked out as a plain file, never a real link), and the engine cycle wraps
  every read/write/mkdir in an lstat/realpath guard (new path-guard.ts) that
  refuses a path that is — or traverses — a symlink, or whose realpath escapes
  the vault root. Prevents a writer from publishing /etc/passwd or the server
  .env, or writing outside the vault. Adds unit tests (path-guard.test.ts) +
  a read-guard integration test (cycle.test.ts) + real lstat/realpath in the
  roundtrip integration test.
- [simplification] Delete dead lib/diff.ts + test/diff.test.ts and drop the
  now-unused @fellow/prosemirror-recreate-transform dependency.
- [documentation] Add a CHANGELOG [Unreleased] → Added entry for git-sync.

Warnings:
- [test-coverage] Cover the CREATE-branch conflict-markers guard (a new .md with
  markers and no gitmost_id is recorded as a create failure, never created).

Suggestions:
- [stability] Bound each `git config` in ensureServable with a timeout.
- [authz] Trigger endpoint resolves spaceId workspace-scoped and 404s a foreign
  space before any vault directory is created.
- [stability] Attribute git-initiated moves to the service account
  (lastUpdatedById), via an optional actor param on PageService.movePage.
- [documentation] Document the per-space autoMergeConflicts toggle in AGENTS.md.
- [test-coverage] Cover the unterminated `:::` callout fence fallback.
- [simplification] Move test-only roundtrip-helpers.ts out of src/ into test/.

Architecture:
- Move the Yjs/ProseMirror merge primitives (yjs-body-merge, three-way-merge,
  lcs + specs) into collaboration/merge/, breaking the collaboration →
  integrations/git-sync dependency cycle this PR introduced.
- Port the schema-surface drift gate to packages/mcp (the mcp schema mirror had
  none); pins 52 entries.

Deferred (with rationale in the review thread): the incremental-pull perf
warning (correctness-neutral; needs a high-water-mark design + its own tests on
the data-loss-critical path) and the redis-sync rolling-deploy mixed-version
edge (the deficient behavior is in already-released old-instance code; the new
code is correct on both sides; impact is a transient rollout-window artifact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-28 15:39:12 +03:00
parent f020739bfd
commit 906733b5c8
37 changed files with 675 additions and 731 deletions

View File

@@ -264,7 +264,7 @@ Two routes are mounted **outside** the `/api` prefix at the root, as raw Fastify
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
### Git-sync (native two-way Docmost ↔ git Markdown sync)
`integrations/git-sync/` (`GitSyncModule`) + the vendored pure engine in `packages/git-sync`. Off by default; gated by the `GIT_SYNC_ENABLED` master switch (and `GIT_SYNC_SERVICE_USER_ID`, the account git-originated writes are attributed to). Per-space opt-in via `space.settings.gitSync.enabled`. Each enabled space gets an on-disk working "vault" repo; the `GitSyncOrchestrator` runs a debounced + poll-backstop reconcile cycle (PULL Docmost→vault, PUSH vault→Docmost) under a per-space Redis leader lock + in-process mutex (`SpaceLockService`). Writes go through the collaboration layer (so concurrent human edits aren't clobbered) and are stamped `lastUpdatedSource = 'git-sync'` for the listener loop-guard. The in-process `setInterval` orchestration + best-effort lock (no fencing tokens) is a known multi-replica limitation — BullMQ + fencing is the documented future direction.
`integrations/git-sync/` (`GitSyncModule`) + the vendored pure engine in `packages/git-sync`. Off by default; gated by the `GIT_SYNC_ENABLED` master switch (and `GIT_SYNC_SERVICE_USER_ID`, the account git-originated writes are attributed to). Per-space opt-in via `space.settings.gitSync.enabled`, with a second per-space toggle `space.settings.gitSync.autoMergeConflicts` that changes PUSH behavior for a still-conflicted page (one carrying `<<<<<<<`/`>>>>>>>` markers): **off (the safe default)** records a per-page failure and holds the refs so the user resolves the git conflict first (markers never reach Docmost); **on** strips the marker lines and pushes both sides' content. Each enabled space gets an on-disk working "vault" repo; the `GitSyncOrchestrator` runs a debounced + poll-backstop reconcile cycle (PULL Docmost→vault, PUSH vault→Docmost) under a per-space Redis leader lock + in-process mutex (`SpaceLockService`). Writes go through the collaboration layer (so concurrent human edits aren't clobbered) and are stamped `lastUpdatedSource = 'git-sync'` for the listener loop-guard. The in-process `setInterval` orchestration + best-effort lock (no fencing tokens) is a known multi-replica limitation — BullMQ + fencing is the documented future direction.
- **`/git` smart-HTTP host** (`integrations/git-sync/http/`, gated additionally by `GIT_SYNC_HTTP_ENABLED`, which defaults to `GIT_SYNC_ENABLED`): a raw root-mounted Fastify route `/git/<spaceId>.git/...` (registered in `main.ts`, NOT under `/api`) that bridges `git clone`/`fetch`/`push` to `git http-backend`. It authenticates HTTP Basic against `AuthService` (throttled by a `FailedLoginLimiter` mirroring the `/mcp` path), authorizes via `SpaceAbilityFactory` (read = fetch, Manage = push), and gates existence so a non-member gets the SAME 404 as a missing/sync-disabled space (never 403 — that would leak space existence). A push runs the receive-pack under the space lock, then a reconcile cycle.
- **Schema mirror:** `packages/git-sync/src/lib/docmost-schema.ts` is one of the **three** hand-synced copies of the Tiptap document schema (see Client structure) — keep it in lockstep with `editor-ext` (canonical) and `packages/mcp`.

View File

@@ -12,6 +12,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Native two-way Docmost ↔ git Markdown sync.** Opt-in per space (Space
settings → a git-sync toggle, plus an `autoMergeConflicts` toggle that controls
whether a still-conflicted page is held back or pushed with its conflict
markers stripped): each enabled space is mirrored to an on-disk git "vault" of
Markdown files and reconciled in both directions (Docmost → vault and vault →
Docmost) on a debounced + poll-backstop cycle, under a per-space lock, writing
through the collaboration layer so concurrent human edits aren't clobbered.
Git-originated changes are attributed to a configurable service account and
carry a "git-sync" provenance badge in page history. Optionally exposes a `/git`
smart-HTTP host so you can `git clone`/`fetch`/`push` a space directly (HTTP
Basic auth, space-permission authorized). Off by default and configured via the
`GIT_SYNC_*` environment variables, including `GIT_SYNC_ENABLED`,
`GIT_SYNC_SERVICE_USER_ID`, and `GIT_SYNC_HTTP_ENABLED` (see `.env.example`).
(#119)
- **Quick-create regular and temporary notes from the Home and Space screens.**
The Home screen now shows a second action next to "New note" that creates a
*temporary* note (one that auto-moves to Trash after the workspace lifetime),

View File

@@ -11,7 +11,7 @@ import { User } from '@docmost/db/types/entity.types';
import {
mergeXmlFragments,
mergeXmlFragments3Way,
} from '../integrations/git-sync/services/yjs-body-merge';
} from './merge/yjs-body-merge';
export type CollabEventHandlers = ReturnType<
CollaborationHandler['getHandlers']

View File

@@ -5,7 +5,7 @@ import {
convertProseMirrorToMarkdown,
} from '@docmost/git-sync';
import { tiptapExtensions } from '../../../collaboration/collaboration.util';
import { tiptapExtensions } from '../collaboration.util';
import { mergeXmlFragments, mergeXmlFragments3Way } from './yjs-body-merge';
/**

View File

@@ -1,7 +1,7 @@
import { TiptapTransformer } from '@hocuspocus/transformer';
import * as Y from 'yjs';
import { tiptapExtensions } from '../../../collaboration/collaboration.util';
import { tiptapExtensions } from '../collaboration.util';
import { mergeXmlFragments, mergeXmlFragments3Way } from './yjs-body-merge';
/**

View File

@@ -2,7 +2,7 @@ import * as Y from 'yjs';
import { getSchema } from '@tiptap/core';
import type { Schema } from '@tiptap/pm/model';
import { tiptapExtensions } from '../../../collaboration/collaboration.util';
import { tiptapExtensions } from '../collaboration.util';
import { diff3Plan } from './three-way-merge';
import { buildLcsTable } from './lcs';

View File

@@ -948,6 +948,12 @@ export class PageService {
// Optional agent-edit provenance (from the signed access claim). Stamps the
// source marker when the agent moves a page via REST (§6.6 REST path).
provenance?: AuthProvenanceData,
// Optional responsible author. When set (git-sync), the move is ATTRIBUTED
// to that account via `lastUpdatedById` — parity with create/delete/rename,
// which all stamp the service user. A normal user move omits it, leaving
// `lastUpdatedById` untouched (a reparent is not a content edit, so the
// existing author is preserved — unchanged behavior).
actorUserId?: string,
) {
// validate position value by attempting to generate a key
try {
@@ -1017,6 +1023,9 @@ export class PageService {
{
position: dto.position,
parentPageId: parentPageId,
// Attribute a git-initiated move to the service account (parity with
// create/delete/rename). Omitted for normal user moves -> unchanged.
...(actorUserId ? { lastUpdatedById: actorUserId } : {}),
// Agent-edit provenance: annotate the source on an agent move. A
// normal user request leaves the existing source value unchanged.
...agentSourceFields(

View File

@@ -3,7 +3,7 @@
// guard (non-admin -> ForbiddenException, no orchestrator call), that trigger
// uses the workspace from request context (never the body), and that status
// returns the env-derived object.
import { ForbiddenException } from '@nestjs/common';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
@@ -18,10 +18,11 @@ interface Built {
env: Record<string, AnyMock>;
workspaceAbility: { createForUser: AnyMock };
ability: { cannot: AnyMock };
spaceRepo: { findById: AnyMock };
}
function build(opts: { cannot?: boolean } = {}): Built {
const { cannot = false } = opts;
function build(opts: { cannot?: boolean; spaceFound?: boolean } = {}): Built {
const { cannot = false, spaceFound = true } = opts;
const ability = { cannot: jest.fn(() => cannot) };
const workspaceAbility = { createForUser: jest.fn(() => ability) };
@@ -35,13 +36,17 @@ function build(opts: { cannot?: boolean } = {}): Built {
getGitSyncDebounceMs: jest.fn(() => 2000),
getGitSyncServiceUserId: jest.fn(() => 'svc-user'),
};
const spaceRepo = {
findById: jest.fn(async () => (spaceFound ? { id: 'space-1' } : undefined)),
};
const controller = new GitSyncController(
orchestrator as any,
env as any,
workspaceAbility as any,
spaceRepo as any,
);
return { controller, orchestrator, env, workspaceAbility, ability };
return { controller, orchestrator, env, workspaceAbility, ability, spaceRepo };
}
const USER = { id: 'user-1' } as any;
@@ -68,7 +73,7 @@ describe('GitSyncController', () => {
});
it('admin: calls runOnce(dto.spaceId, workspace.id) using the workspace from context', async () => {
const { controller, orchestrator } = build({ cannot: false });
const { controller, orchestrator, spaceRepo } = build({ cannot: false });
// The body carries an attacker-controlled workspaceId that must be ignored.
const res = await controller.trigger(
@@ -77,9 +82,27 @@ describe('GitSyncController', () => {
WORKSPACE,
);
// The space is resolved workspace-scoped (context workspace, not the body).
expect(spaceRepo.findById).toHaveBeenCalledWith('space-1', 'ctx-ws');
expect(orchestrator.runOnce).toHaveBeenCalledWith('space-1', 'ctx-ws');
expect(res).toEqual({ spaceId: 'space-1', ran: true });
});
it('admin: 404s a spaceId that is not in the workspace and never calls runOnce', async () => {
// A foreign/non-existent space must be rejected BEFORE buildSettings runs
// (which would otherwise create an empty per-space vault directory).
const { controller, orchestrator, spaceRepo } = build({
cannot: false,
spaceFound: false,
});
await expect(
controller.trigger({ spaceId: 'foreign' } as any, USER, WORKSPACE),
).rejects.toBeInstanceOf(NotFoundException);
expect(spaceRepo.findById).toHaveBeenCalledWith('foreign', 'ctx-ws');
expect(orchestrator.runOnce).not.toHaveBeenCalled();
});
});
describe('status', () => {

View File

@@ -4,6 +4,7 @@ import {
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
Get,
UseGuards,
@@ -12,6 +13,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import WorkspaceAbilityFactory from '../../core/casl/abilities/workspace-ability.factory';
import {
WorkspaceCaslAction,
@@ -47,6 +49,7 @@ export class GitSyncController {
private readonly orchestrator: GitSyncOrchestrator,
private readonly environmentService: EnvironmentService,
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly spaceRepo: SpaceRepo,
) {}
/** Throw unless the caller is a workspace admin (Manage Settings). */
@@ -67,6 +70,15 @@ export class GitSyncController {
@AuthWorkspace() workspace: Workspace,
): Promise<GitSyncRunStatus> {
this.assertAdmin(user, workspace);
// Verify the client-supplied spaceId BELONGS to this workspace before doing
// any work (review): without this, `runOnce` -> `buildSettings` reads the
// raw `spaces` row and creates an empty per-space vault directory for a
// foreign/non-existent space before the content read finally 404s. Resolve
// it workspace-scoped and 404 early.
const space = await this.spaceRepo.findById(dto.spaceId, workspace.id);
if (!space) {
throw new NotFoundException('Space not found');
}
// Use the workspace from the request context (never client-supplied).
return this.orchestrator.runOnce(dto.spaceId, workspace.id);
}

View File

@@ -1,6 +1,6 @@
import * as Y from 'yjs';
import { mergeXmlFragments3Way } from './yjs-body-merge';
import { mergeXmlFragments3Way } from '../../../collaboration/merge/yjs-body-merge';
/**
* Convergence repro for the git-ingest "silent revert" data-loss bug.

View File

@@ -5,7 +5,14 @@ import {
OnModuleInit,
} from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import {
lstat,
mkdir,
readFile,
realpath,
rm,
writeFile,
} from 'node:fs/promises';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { sql } from 'kysely';
@@ -303,11 +310,31 @@ export class GitSyncOrchestrator implements OnModuleInit, OnModuleDestroy {
vault,
settings,
// ABSOLUTE-path fs primitives the engine cycle injects (it stays IO-free).
// `lstat`/`realpath` back the engine's symlink guard: both MUST yield
// `null` on ENOENT (a not-yet-created file is the normal write case) so the
// guard can tell "absent" (safe to create) from "is a symlink" (refuse).
// `lstat` does NOT follow the final link; `realpath` resolves it.
fs: {
readFile: (absPath) => readFile(absPath, 'utf8'),
writeFile: (absPath, text) => writeFile(absPath, text, 'utf8'),
mkdir: (absDir) => mkdir(absDir, { recursive: true }).then(() => undefined),
rm: (absPath) => rm(absPath, { force: true }),
lstat: (absPath) =>
lstat(absPath).then(
(st) => ({ isSymbolicLink: st.isSymbolicLink() }),
(err: NodeJS.ErrnoException) => {
if (err && err.code === 'ENOENT') return null;
throw err;
},
),
realpath: (absPath) =>
realpath(absPath).then(
(p) => p,
(err: NodeJS.ErrnoException) => {
if (err && err.code === 'ENOENT') return null;
throw err;
},
),
},
// Every cycle logs its full push plan + per-action lines + completion
// counts (created/updated/deleted/skipped/failures) through this `log`, so

View File

@@ -362,13 +362,17 @@ describe('GitmostDataSourceService', () => {
await service.bind(CTX).movePage('p1', 'parent-1');
expect(mocks.pageService.movePage).toHaveBeenCalledTimes(1);
const [dto, page, provenance] = mocks.pageService.movePage.mock.calls[0];
const [dto, page, provenance, actorUserId] =
mocks.pageService.movePage.mock.calls[0];
expect(dto.pageId).toBe('p1');
expect(dto.parentPageId).toBe('parent-1');
expect(typeof dto.position).toBe('string');
expect(dto.position.length).toBeGreaterThan(0);
expect(page).toEqual({ id: 'p1', spaceId: 'space-1' });
expect(provenance).toEqual({ actor: 'git-sync', aiChatId: null });
// The git-initiated move is attributed to the service user (lastUpdatedById
// parity with create/delete/rename).
expect(actorUserId).toBe('svc-user');
});
it('passes through an explicit position unchanged', async () => {

View File

@@ -76,7 +76,7 @@ export class GitmostDataSourceService {
this.createPage(ctx, title, content, spaceId, parentPageId),
deletePage: (pageId) => this.deletePage(ctx, pageId),
movePage: (pageId, parentPageId, position) =>
this.movePage(pageId, parentPageId, position),
this.movePage(ctx, pageId, parentPageId, position),
renamePage: (pageId, title) => this.renamePage(ctx, pageId, title),
listRecentSince: (spaceId, sinceIso, hardPageCap) =>
this.listRecentSince(spaceId, sinceIso, hardPageCap),
@@ -252,6 +252,7 @@ export class GitmostDataSourceService {
* §3.2 / §14.4).
*/
private async movePage(
ctx: GitSyncBindContext,
pageId: string,
parentPageId: string | null,
position?: string,
@@ -268,6 +269,10 @@ export class GitmostDataSourceService {
{ pageId, parentPageId: parentPageId ?? null, position: resolvedPosition },
page,
GIT_SYNC_PROVENANCE,
// Attribute the git-initiated move to the service user (lastUpdatedById),
// matching create/delete/rename — the contract is "git-operations are
// attributed to the service account".
ctx.userId,
);
return { id: pageId };
}

View File

@@ -16,8 +16,8 @@ jest.mock('node:fs/promises', () => ({
// ensureServable shells out via `promisify(execFile)`; mock execFile with a
// callback-style fn so promisify resolves. Each `git config <key> <value>` call
// is recorded so the four config writes (incl. the security-critical
// receive.denyNonFastForwards=true) can be asserted.
// is recorded so the config writes (incl. the security-critical
// receive.denyNonFastForwards=true and core.symlinks=false) can be asserted.
jest.mock('node:child_process', () => ({
execFile: jest.fn((_cmd: string, _args: string[], _opts: any, cb: any) =>
cb(null, { stdout: '', stderr: '' }),
@@ -54,6 +54,7 @@ void loadGitSync;
function build(dataDir: string): { service: VaultRegistryService } {
const env = {
getGitSyncDataDir: jest.fn(() => dataDir),
getGitSyncBackendTimeoutMs: jest.fn(() => 120000),
};
const service = new VaultRegistryService(env as any);
return { service };
@@ -96,7 +97,7 @@ describe('VaultRegistryService', () => {
});
describe('ensureServable', () => {
it('ensures the repo then writes the four force-push-protection git configs', async () => {
it('ensures the repo then writes the force-push-protection + symlink-guard git configs', async () => {
const { service } = build('/vaults');
const path = await service.ensureServable('space-1');
@@ -117,12 +118,18 @@ describe('VaultRegistryService', () => {
['receive.denyNonFastForwards', 'true'],
['http.receivepack', 'true'],
['http.uploadpack', 'true'],
// Security-critical (PR #119 review): a pushed symlink is checked out as
// a plain file, never a real link, so it cannot be followed to leak/
// overwrite a file outside the vault.
['core.symlinks', 'false'],
]);
// Every config write targets THIS vault's cwd.
// Every config write targets THIS vault's cwd and is time-bounded so a
// wedged git cannot hang the request path.
for (const [cmd, args, opts] of execFileMock.mock.calls) {
if (cmd === 'git' && args[0] === 'config') {
expect(opts.cwd).toBe('/vaults/space-1');
expect(opts.timeout).toBe(120000);
}
}
});

View File

@@ -62,10 +62,20 @@ export class VaultRegistryService {
* rewrite the engine's history on `main`.
* - http.receivepack=true / http.uploadpack=true — explicitly allow the
* receive/upload services over HTTP.
* - core.symlinks=false — SECURITY (PR #119 review). A writer could push a
* `.md` entry that is a SYMLINK (e.g. `leak.md -> /etc/passwd` or
* `-> .env`); with symlinks enabled `updateInstead` would materialize a
* real link in the working tree, and the next push cycle would follow it
* and PUBLISH the target's contents as a Docmost page (server-file
* disclosure), or use a symlinked directory to write OUTSIDE the vault on
* pull. With `core.symlinks=false` git checks out such a blob as a PLAIN
* FILE containing the link text, never a real link, defusing the primitive
* at the git layer. (The engine's per-access lstat/realpath guard is the
* second layer — see path-guard.ts.)
*
* All four are set idempotently (plain `git config` overwrites the local
* value). Returns the absolute vault path. Idempotent and safe to call before
* every request.
* All are set idempotently (plain `git config` overwrites the local value).
* Returns the absolute vault path. Idempotent and safe to call before every
* request.
*/
async ensureServable(spaceId: string): Promise<string> {
const { vaultGitEnv } = await loadGitSync();
@@ -81,13 +91,21 @@ export class VaultRegistryService {
['receive.denyNonFastForwards', 'true'],
['http.receivepack', 'true'],
['http.uploadpack', 'true'],
['core.symlinks', 'false'],
];
// Bound each `git config` (review suggestion): this runs in the request path
// BEFORE the watchdog, so a wedged git (a stale `.git/config.lock`) would
// otherwise hang the request indefinitely. Mirror the engine's GIT_EXEC
// bound via the configured backend timeout.
const timeout = this.environmentService.getGitSyncBackendTimeoutMs();
for (const [key, value] of configs) {
await execFileAsync('git', ['config', key, value], {
cwd: path,
// Use the engine's cwd-isolated env (strips GIT_DIR / GIT_WORK_TREE) so
// the config is written to THIS vault's local config, nothing else.
env: vaultGitEnv(),
timeout,
maxBuffer: 10 * 1024 * 1024,
});
}

View File

@@ -20,7 +20,6 @@
},
"license": "MIT",
"dependencies": {
"@fellow/prosemirror-recreate-transform": "^1.2.3",
"@tiptap/core": "3.20.4",
"@tiptap/extension-highlight": "3.20.4",
"@tiptap/extension-image": "3.20.4",

View File

@@ -3,13 +3,19 @@ import { GitSyncClient } from "./client.types.js";
import { Settings } from "./settings.js";
import { readExisting, computePullActions, applyPullActions } from "./pull.js";
import { runPush } from "./push.js";
import { assertVaultPathSafe, type PathGuardIo } from "./path-guard.js";
/**
* Absolute-path filesystem primitives the cycle needs. Injected (not imported)
* so the engine stays IO-free and unit-testable. `mkdir` is recursive; `rm` is
* force (a missing file is a no-op).
*
* `lstat`/`realpath` back the SYMLINK GUARD (see ./path-guard.ts): every
* read/write/mkdir is screened so a pushed symlink (e.g. `leak.md -> /etc/passwd`
* or `-> .env`) cannot be followed to publish or overwrite a file outside the
* vault. Both MUST resolve to `null` on ENOENT and reject on any other error.
*/
export interface CycleFs {
export interface CycleFs extends PathGuardIo {
readFile: (absPath: string) => Promise<string>;
writeFile: (absPath: string, text: string) => Promise<void>;
mkdir: (absDir: string) => Promise<void>;
@@ -80,6 +86,31 @@ export async function runCycle(deps: RunCycleDeps): Promise<RunCycleResult> {
const vaultRoot = settings.vaultPath;
const abs = (relPath: string) => `${vaultRoot}/${relPath}`;
// SYMLINK GUARD (defense-in-depth, see ./path-guard.ts). Wrap the injected
// read/write/mkdir primitives so EVERY engine file access is screened: a path
// that is — or traverses — a symlink, or whose realpath escapes the vault, is
// refused. `rm` is deliberately NOT wrapped: removing a path only deletes the
// link itself (force, non-recursive), never the target, and we WANT to be able
// to clean up a stray pushed symlink. A refusal THROWS; the pull/push loops
// already isolate per-file errors (skip + log), so a single poisoned entry is
// skipped while the rest of the space keeps syncing.
const guard = (p: string) => assertVaultPathSafe(fs, vaultRoot, p);
const safeFs = {
readFile: async (p: string): Promise<string> => {
await guard(p);
return fs.readFile(p);
},
writeFile: async (p: string, text: string): Promise<void> => {
await guard(p);
return fs.writeFile(p, text);
},
mkdir: async (p: string): Promise<void> => {
await guard(p);
return fs.mkdir(p);
},
rm: (p: string): Promise<void> => fs.rm(p),
};
// 1. The engine state store is git: make sure the repo + branches exist
// before any tracked-file listing or diff.
await vault.assertGitAvailable();
@@ -118,7 +149,7 @@ export async function runCycle(deps: RunCycleDeps): Promise<RunCycleResult> {
// 4. PULL --------------------------------------------------------------------
const existing = await readExisting({
listTracked: () => vault.listTrackedFiles("*.md"),
readFile: (relPath) => fs.readFile(abs(relPath)),
readFile: (relPath) => safeFs.readFile(abs(relPath)),
});
const tree = await client.listSpaceTree(spaceId);
@@ -135,9 +166,9 @@ export async function runCycle(deps: RunCycleDeps): Promise<RunCycleResult> {
{
client,
git: vault,
writeFile: (absPath, text) => fs.writeFile(absPath, text),
mkdir: (absDir) => fs.mkdir(absDir),
rm: (absPath) => fs.rm(absPath),
writeFile: (absPath, text) => safeFs.writeFile(absPath, text),
mkdir: (absDir) => safeFs.mkdir(absDir),
rm: (absPath) => safeFs.rm(absPath),
log,
},
pullActions,
@@ -149,8 +180,8 @@ export async function runCycle(deps: RunCycleDeps): Promise<RunCycleResult> {
settings,
git: vault,
makeClient: () => client,
readFile: (relPath: string) => fs.readFile(abs(relPath)),
writeFile: (relPath: string, text: string) => fs.writeFile(abs(relPath), text),
readFile: (relPath: string) => safeFs.readFile(abs(relPath)),
writeFile: (relPath: string, text: string) => safeFs.writeFile(abs(relPath), text),
log,
};

View File

@@ -0,0 +1,132 @@
/**
* Vault path guard (security, defense-in-depth).
*
* A user with push access to a git-sync space could commit a `.md` entry that is
* a SYMLINK (e.g. `leak.md -> /etc/passwd` or `-> <server>/.env`). On the next
* cycle a naive `fs.readFile` would follow the link and PUBLISH the target's
* contents as a Docmost page (a read primitive that escalates a writer to
* arbitrary server-file disclosure — including the JWT secret / DB creds in
* `.env`); a symlinked DIRECTORY gives the inverse write-outside-the-vault
* primitive on pull. The primary defense is `core.symlinks=false` in each
* vault's git config (git then materializes a pushed symlink as a PLAIN FILE
* holding the link text, never a real link). This module is the second layer:
* before every engine read/write/mkdir we reject a path that IS — or traverses —
* a symlink, or whose real location escapes the vault root.
*
* IO-free by construction: the `lstat`/`realpath` primitives are injected
* (mirroring the rest of the engine) so the rules are unit-testable with fakes
* and the engine never imports `node:fs`. Path math uses `node:path`, which is
* pure.
*/
import { isAbsolute, relative, resolve, sep } from "node:path";
/** Why a path was refused. */
export type VaultPathUnsafeReason = "symlink" | "escape";
/**
* Thrown when a path is refused by the guard. Engine read/write loops already
* isolate per-file errors (skip + log), so throwing here yields the review's
* required "skip+log" behavior without a separate control channel.
*/
export class VaultPathUnsafeError extends Error {
constructor(
readonly absPath: string,
readonly reason: VaultPathUnsafeReason,
readonly vaultRoot: string,
) {
super(
reason === "symlink"
? `git-sync: refusing to access '${absPath}' — it is (or traverses) a ` +
`symlink under vault '${vaultRoot}' (symlink guard)`
: `git-sync: refusing to access '${absPath}' — it resolves outside ` +
`vault '${vaultRoot}' (symlink guard)`,
);
this.name = "VaultPathUnsafeError";
}
}
/**
* The injected IO the guard needs. Both MUST resolve to `null` on ENOENT (the
* normal case for a not-yet-created file on a write/mkdir) and reject on any
* other error.
*/
export interface PathGuardIo {
/** lstat WITHOUT following the final symlink. `null` when the path is absent. */
lstat: (absPath: string) => Promise<{ isSymbolicLink: boolean } | null>;
/** realpath (follows symlinks). `null` when the path is absent. */
realpath: (absPath: string) => Promise<string | null>;
}
/**
* Lexical containment: is `target` EQUAL to, or NESTED under, `root`? Catches a
* `..` traversal baked into a relPath before any IO. Both operands are resolved
* first so `.`/`..` segments are normalized.
*/
export function isWithinRoot(root: string, target: string): boolean {
const r = resolve(root);
const t = resolve(target);
if (t === r) return true;
const rel = relative(r, t);
return rel.length > 0 && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
}
/**
* Reject `absPath` (resolving silently when it is safe) if it:
* - escapes `vaultRoot` lexically (a `..` traversal), OR
* - IS, or traverses, a symlink at any EXISTING segment from the root down
* (a symlinked ancestor dir, or the target file/dir itself), OR
* - resolves (realpath of its deepest existing ancestor) outside the vault.
*
* Absent leaf segments — the normal case when writing/mkdir'ing a NEW file — are
* safe: the walk stops at the first non-existent segment (nothing to follow).
*/
export async function assertVaultPathSafe(
io: PathGuardIo,
vaultRoot: string,
absPath: string,
): Promise<void> {
const root = resolve(vaultRoot);
const target = resolve(absPath);
// 1. Lexical containment — a `..` in a relPath never even reaches an lstat.
if (!isWithinRoot(root, target)) {
throw new VaultPathUnsafeError(absPath, "escape", vaultRoot);
}
// 2. lstat-walk: reject a symlink at ANY existing level between the root and
// the target (inclusive). A symlinked ancestor or a symlinked target both
// let a follow-the-link read/write escape; rejecting the link itself is the
// surgical guard.
if (target !== root) {
const segments = relative(root, target)
.split(sep)
.filter((s) => s.length > 0);
let cur = root;
for (const segment of segments) {
cur = resolve(cur, segment);
const st = await io.lstat(cur);
if (st === null) break; // absent from here down — nothing left to follow
if (st.isSymbolicLink) {
throw new VaultPathUnsafeError(cur, "symlink", vaultRoot);
}
}
}
// 3. realpath belt-and-suspenders: the deepest EXISTING ancestor must resolve
// inside the vault root's realpath. Catches an ancestor relocated via a
// symlink the lexical check would miss (e.g. the data dir itself being a
// link farm) and bounds the lstat→use TOCTOU window.
const realRoot = await io.realpath(root);
if (realRoot === null) return; // root absent — ensureRepo creates it first
let probe = target;
let realProbe = await io.realpath(probe);
while (realProbe === null && probe !== root) {
const parent = resolve(probe, "..");
if (parent === probe) break; // reached the filesystem root
probe = parent;
realProbe = await io.realpath(probe);
}
if (realProbe !== null && !isWithinRoot(realRoot, realProbe)) {
throw new VaultPathUnsafeError(absPath, "escape", vaultRoot);
}
}

View File

@@ -116,4 +116,11 @@ export type {
CycleFs,
} from "./engine/cycle.js";
export {
assertVaultPathSafe,
isWithinRoot,
VaultPathUnsafeError,
} from "./engine/path-guard.js";
export type { PathGuardIo, VaultPathUnsafeReason } from "./engine/path-guard.js";
export { parsePageFile, serializePageFile } from "./lib/page-file.js";

View File

@@ -1,319 +0,0 @@
/**
* Headless, Docmost-equivalent document diff.
*
* Docmost's history editor computes a change set with the exact pipeline below
* (recreateTransform -> ChangeSet.addSteps -> simplifyChanges) and renders it as
* editor decorations. This module runs the SAME computation but serializes the
* result to text + integrity counts instead of decorations, so a diff can be
* previewed without a browser.
*
* recreateTransform here comes from @fellow/prosemirror-recreate-transform, the
* maintained published fork of the MIT prosemirror-recreate-steps source that
* Docmost vendors in @docmost/editor-ext; it exposes the identical
* recreateTransform(fromDoc, toDoc, { complexSteps, wordDiffs, simplifyDiff })
* signature.
*
* If recreateTransform / the changeset throws on a pathological document pair,
* we fall back to a coarse block-level text diff so the tool never hard-fails.
*/
import { getSchema } from "@tiptap/core";
import { Node } from "@tiptap/pm/model";
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
import { recreateTransform } from "@fellow/prosemirror-recreate-transform";
import { docmostExtensions } from "./docmost-schema.js";
/** A single inserted/deleted change with its containing-block context. */
export interface DiffChange {
op: "insert" | "delete";
/** Lead (plain) text of the block that contains the change, for context. */
block: string;
/** The inserted or deleted text. */
text: string;
}
/** Integrity counts as [old, new] tuples; footnoteMarkers as [oldList, newList]. */
export interface DiffIntegrity {
images: [number, number];
links: [number, number];
tables: [number, number];
callouts: [number, number];
footnoteMarkers: [number[], number[]];
}
export interface DiffResult {
summary: { inserted: number; deleted: number; blocksChanged: number };
integrity: DiffIntegrity;
changes: DiffChange[];
/** Human-readable unified-ish summary. */
markdown: string;
}
/** Build the schema once; it is pure and reused across calls. */
const schema = getSchema(docmostExtensions);
/** Recursively concatenate the plain text of a JSON node. */
function plainText(node: any): string {
if (!node || typeof node !== "object") return "";
let out = "";
if (typeof node.text === "string") out += node.text;
if (Array.isArray(node.content)) {
for (const child of node.content) out += plainText(child);
}
return out;
}
/** Count nodes in a JSON doc that satisfy `pred` (recursive). */
function countNodes(doc: any, pred: (node: any) => boolean): number {
let n = 0;
const visit = (node: any): void => {
if (!node || typeof node !== "object") return;
if (pred(node)) n++;
if (Array.isArray(node.content)) for (const c of node.content) visit(c);
};
visit(doc);
return n;
}
/**
* Count UNIQUE links in a JSON doc by their `href`. A single link can be split
* across several adjacent text runs (e.g. a "link+bold" run followed by a "link"
* run); counting link-bearing runs would over-count it. Walking the tree and
* collecting hrefs into a Set keys each distinct link once. Link marks with a
* missing/empty href are bucketed under a single "" key so a malformed link is
* still counted as one.
*/
function countUniqueLinks(doc: any): number {
const hrefs = new Set<string>();
const visit = (node: any): void => {
if (!node || typeof node !== "object") return;
if (node.type === "text" && Array.isArray(node.marks)) {
for (const m of node.marks) {
if (m && m.type === "link") {
const href = m.attrs && typeof m.attrs.href === "string" ? m.attrs.href : "";
hrefs.add(href);
}
}
}
if (Array.isArray(node.content)) for (const c of node.content) visit(c);
};
visit(doc);
return hrefs.size;
}
/**
* Parse the ordered list of integers from `[N]` footnote markers found in the
* BODY only (every top-level block before the first notes heading; if no such
* heading, the whole doc). Returned in reading order.
*/
function footnoteMarkers(doc: any, notesHeading: string): number[] {
const top: any[] = Array.isArray(doc?.content) ? doc.content : [];
const notesIdx = top.findIndex(
(n) =>
n &&
n.type === "heading" &&
plainText(n).trim() === notesHeading,
);
const bodyBlocks = notesIdx >= 0 ? top.slice(0, notesIdx) : top;
const markers: number[] = [];
const re = /\[(\d+)\]/g;
for (const block of bodyBlocks) {
const text = plainText(block);
let m: RegExpExecArray | null;
re.lastIndex = 0;
while ((m = re.exec(text)) !== null) {
markers.push(Number(m[1]));
}
}
return markers;
}
/** Compute the [old,new] integrity tuples for two JSON docs. */
function computeIntegrity(
oldDoc: any,
newDoc: any,
notesHeading: string,
): DiffIntegrity {
const images: [number, number] = [
countNodes(oldDoc, (n) => n.type === "image"),
countNodes(newDoc, (n) => n.type === "image"),
];
const links: [number, number] = [
countUniqueLinks(oldDoc),
countUniqueLinks(newDoc),
];
const tables: [number, number] = [
countNodes(oldDoc, (n) => n.type === "table"),
countNodes(newDoc, (n) => n.type === "table"),
];
const callouts: [number, number] = [
countNodes(oldDoc, (n) => n.type === "callout"),
countNodes(newDoc, (n) => n.type === "callout"),
];
const fns: [number[], number[]] = [
footnoteMarkers(oldDoc, notesHeading),
footnoteMarkers(newDoc, notesHeading),
];
return { images, links, tables, callouts, footnoteMarkers: fns };
}
/**
* Resolve the lead text of the top-level block in a ProseMirror Node that
* contains the given document position. Returns "" when out of range.
*/
function blockContextAt(node: Node, pos: number): string {
try {
const clamped = Math.max(0, Math.min(pos, node.content.size));
const $pos = node.resolve(clamped);
// depth 1 is the top-level block in a doc node.
const block = $pos.depth >= 1 ? $pos.node(1) : $pos.node(0);
const text = block.textContent || "";
return text.length > 80 ? text.slice(0, 77) + "..." : text;
} catch {
return "";
}
}
/** Truncate a string for the markdown summary. */
function truncate(s: string, n = 120): string {
return s.length > n ? s.slice(0, n - 3) + "..." : s;
}
/**
* Coarse fallback: a block-by-block plain-text diff. Used only when the precise
* changeset pipeline throws, so the tool degrades gracefully instead of failing.
*/
function coarseDiff(oldDoc: any, newDoc: any): DiffChange[] {
const oldBlocks: any[] = Array.isArray(oldDoc?.content) ? oldDoc.content : [];
const newBlocks: any[] = Array.isArray(newDoc?.content) ? newDoc.content : [];
const oldTexts = oldBlocks.map(plainText);
const newTexts = newBlocks.map(plainText);
const oldSet = new Set(oldTexts);
const newSet = new Set(newTexts);
const changes: DiffChange[] = [];
for (const t of oldTexts) {
if (!newSet.has(t) && t.trim() !== "") {
changes.push({ op: "delete", block: truncate(t, 80), text: t });
}
}
for (const t of newTexts) {
if (!oldSet.has(t) && t.trim() !== "") {
changes.push({ op: "insert", block: truncate(t, 80), text: t });
}
}
return changes;
}
/** Build the human-readable unified-ish markdown summary. */
function renderMarkdown(
result: Omit<DiffResult, "markdown">,
fellBack: boolean,
): string {
const lines: string[] = [];
const { summary, integrity, changes } = result;
lines.push(
`# Diff: ${summary.inserted} inserted / ${summary.deleted} deleted (${summary.blocksChanged} blocks changed)`,
);
if (fellBack) {
lines.push("");
lines.push("> note: precise diff failed; coarse block-level diff shown.");
}
lines.push("");
lines.push("## Integrity (old -> new)");
lines.push(`- images: ${integrity.images[0]} -> ${integrity.images[1]}`);
lines.push(`- links: ${integrity.links[0]} -> ${integrity.links[1]}`);
lines.push(`- tables: ${integrity.tables[0]} -> ${integrity.tables[1]}`);
lines.push(`- callouts: ${integrity.callouts[0]} -> ${integrity.callouts[1]}`);
lines.push(
`- footnoteMarkers: [${integrity.footnoteMarkers[0].join(", ")}] -> [${integrity.footnoteMarkers[1].join(", ")}]`,
);
lines.push("");
lines.push("## Changes");
if (changes.length === 0) {
lines.push("(no textual changes)");
} else {
for (const c of changes) {
const sign = c.op === "insert" ? "+" : "-";
const ctx = c.block ? ` @ ${truncate(c.block, 60)}` : "";
lines.push(`${sign} ${truncate(c.text)}${ctx}`);
}
}
return lines.join("\n");
}
/**
* Diff two ProseMirror JSON documents the way Docmost's history editor does and
* serialize the result to text + integrity counts.
*
* @param oldDocJson the earlier document
* @param newDocJson the later document
* @param notesHeading heading delimiting body from notes for footnote counting
*/
export function diffDocs(
oldDocJson: any,
newDocJson: any,
notesHeading: string = "Примечания переводчика",
): DiffResult {
const integrity = computeIntegrity(oldDocJson, newDocJson, notesHeading);
let changes: DiffChange[] = [];
let inserted = 0;
let deleted = 0;
let fellBack = false;
const changedBlocks = new Set<string>();
try {
const oldNode = Node.fromJSON(schema, oldDocJson);
const newNode = Node.fromJSON(schema, newDocJson);
const tr = recreateTransform(oldNode, newNode, {
complexSteps: false,
wordDiffs: true,
simplifyDiff: true,
});
const changeSet = ChangeSet.create(oldNode).addSteps(
tr.doc,
tr.mapping.maps,
[],
);
const simplified = simplifyChanges(changeSet.changes, newNode);
for (const change of simplified) {
// Deleted text lives in the OLD doc coordinate range [fromA, toA).
if (change.toA > change.fromA) {
const text = oldNode.textBetween(change.fromA, change.toA, "\n", " ");
if (text.length > 0) {
deleted += text.length;
const block = blockContextAt(oldNode, change.fromA);
changes.push({ op: "delete", block, text });
if (block) changedBlocks.add("d:" + block);
}
}
// Inserted text lives in the NEW doc coordinate range [fromB, toB).
if (change.toB > change.fromB) {
const text = newNode.textBetween(change.fromB, change.toB, "\n", " ");
if (text.length > 0) {
inserted += text.length;
const block = blockContextAt(newNode, change.fromB);
changes.push({ op: "insert", block, text });
if (block) changedBlocks.add("i:" + block);
}
}
}
} catch {
// Pathological pair: degrade to a coarse block-level diff so we never throw.
fellBack = true;
changes = coarseDiff(oldDocJson, newDocJson);
for (const c of changes) {
if (c.op === "insert") inserted += c.text.length;
else deleted += c.text.length;
if (c.block) changedBlocks.add(c.op[0] + ":" + c.block);
}
}
const partial: Omit<DiffResult, "markdown"> = {
summary: { inserted, deleted, blocksChanged: changedBlocks.size },
integrity,
changes,
};
return { ...partial, markdown: renderMarkdown(partial, fellBack) };
}

View File

@@ -54,6 +54,26 @@ const nodeFs: CycleFs = {
const fs = await import("node:fs/promises");
await fs.rm(absPath, { force: true });
},
// Real symlink-guard primitives (ENOENT -> null), mirroring the server wiring.
lstat: async (absPath) => {
const fs = await import("node:fs/promises");
try {
const st = await fs.lstat(absPath);
return { isSymbolicLink: st.isSymbolicLink() };
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return null;
throw err;
}
},
realpath: async (absPath) => {
const fs = await import("node:fs/promises");
try {
return await fs.realpath(absPath);
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return null;
throw err;
}
},
};
/** A minimal recording client; empty Docmost so the pull is a no-op. */

View File

@@ -61,6 +61,10 @@ function baseDeps(vault: any, over: Partial<RunCycleDeps> = {}): RunCycleDeps {
writeFile: vi.fn(async () => undefined),
mkdir: vi.fn(async () => undefined),
rm: vi.fn(async () => undefined),
// Default: nothing is a symlink and everything resolves in place (no
// escape). The symlink-guard tests below override these.
lstat: vi.fn(async () => ({ isSymbolicLink: false })),
realpath: vi.fn(async (p: string) => p),
},
log: vi.fn(),
...over,
@@ -154,6 +158,32 @@ describe("runCycle (composition)", () => {
expect(vault.diffNameStatus).not.toHaveBeenCalled();
});
it("SYMLINK GUARD: never reads a tracked .md that is a symlink (no .env/passwd disclosure)", async () => {
// Security regression (PR #119 review): a writer who pushes `leak.md` as a
// SYMLINK to a server file (e.g. `.env`) must NOT have its target read and
// published. readExisting reads each tracked .md to recover its gitmost_id;
// the guard refuses the symlink BEFORE the raw read, so the target's bytes
// are never touched and the cycle keeps running for the rest of the space.
const vault = fakeVault({
listTrackedFiles: vi.fn(async () => ["leak.md"]),
});
const deps = baseDeps(vault);
const rawReadFile = vi.fn(async () => "GIT_SYNC_SECRET=topsecret");
deps.fs.readFile = rawReadFile as any;
// `/vault/leak.md` is reported as a symlink by lstat.
deps.fs.lstat = vi.fn(async (p: string) =>
p === "/vault/leak.md"
? { isSymbolicLink: true }
: { isSymbolicLink: false },
) as any;
const res = await runCycle(deps);
expect(res.ran).toBe(true);
// The poisoned symlink's target was NEVER read (the guard short-circuited).
expect(rawReadFile).not.toHaveBeenCalled();
});
it("throws BEFORE the push apply when the signal aborts during the pull phase", async () => {
// Abort mid-cycle: the signal fires while listSpaceTree (the pull read)
// runs, so the SECOND checkpoint (before runPush) trips and the push apply

View File

@@ -1,377 +0,0 @@
import { describe, expect, it } from 'vitest';
import { diffDocs } from '../src/lib/diff.js';
// ---------------------------------------------------------------------------
// ProseMirror JSON builders. diffDocs accepts plain JSON docs (it parses them
// through the Docmost schema internally), so we only need minimal node shapes.
// ---------------------------------------------------------------------------
/** A paragraph; omit `text` for an empty paragraph (no content array entries). */
const para = (text?: string) => ({
type: 'paragraph',
content: text ? [{ type: 'text', text }] : [],
});
/** A heading (level 2 by default) carrying a single text run. */
const heading = (text: string, level = 2) => ({
type: 'heading',
attrs: { level },
content: [{ type: 'text', text }],
});
/** A top-level doc node wrapping the given blocks. */
const doc = (...content: any[]) => ({ type: 'doc', content });
/** An image node (atom). */
const image = () => ({ type: 'image', attrs: {} });
/** A callout node wrapping one paragraph. */
const callout = (text = 'note') => ({
type: 'callout',
attrs: { type: 'info' },
content: [para(text)],
});
/** A 1x1 table. */
const table = (cell = 'c') => ({
type: 'table',
content: [
{ type: 'tableRow', content: [{ type: 'tableCell', content: [para(cell)] }] },
],
});
/** A paragraph carrying a text run that bears a link mark with the given href. */
const linkPara = (text: string, href: string | undefined, extraMarks: any[] = []) => ({
type: 'paragraph',
content: [
{
type: 'text',
text,
marks: [{ type: 'link', attrs: href === undefined ? {} : { href } }, ...extraMarks],
},
],
});
/** The diff.ts default for the notes-heading argument. */
const DEFAULT_NOTES_HEADING = 'Примечания переводчика';
describe('diffDocs', () => {
describe('textual changes (precise path)', () => {
it('reports no changes for two identical docs', () => {
const d = doc(para('hello world'));
const result = diffDocs(d, d);
expect(result.changes).toHaveLength(0);
expect(result.summary).toEqual({ inserted: 0, deleted: 0, blocksChanged: 0 });
// The Changes section renders the sentinel line for an empty change list.
expect(result.markdown).toContain('(no textual changes)');
});
it('counts a pure insertion ("abc" -> "abcXY") and captures the inserted substring', () => {
const result = diffDocs(doc(para('abc')), doc(para('abcXY')));
expect(result.summary.inserted).toBe(2);
expect(result.summary.deleted).toBe(0);
// Exactly one insert change whose text equals the inserted substring.
const inserts = result.changes.filter((c) => c.op === 'insert');
expect(inserts).toHaveLength(1);
expect(inserts[0].text).toBe('XY');
// No deletions on a pure insertion.
expect(result.changes.filter((c) => c.op === 'delete')).toHaveLength(0);
});
it('counts a pure deletion ("abcXY" -> "abc") and captures the deleted substring', () => {
const result = diffDocs(doc(para('abcXY')), doc(para('abc')));
expect(result.summary.deleted).toBe(2);
expect(result.summary.inserted).toBe(0);
const deletes = result.changes.filter((c) => c.op === 'delete');
expect(deletes).toHaveLength(1);
expect(deletes[0].text).toBe('XY');
expect(result.changes.filter((c) => c.op === 'insert')).toHaveLength(0);
});
it('reports a word modification as a matched delete + insert with exact substrings', () => {
const result = diffDocs(doc(para('hello world')), doc(para('hello there')));
// "world" (5) removed, "there" (5) added.
expect(result.summary.inserted).toBe(5);
expect(result.summary.deleted).toBe(5);
const deletes = result.changes.filter((c) => c.op === 'delete');
const inserts = result.changes.filter((c) => c.op === 'insert');
expect(deletes.map((c) => c.text)).toContain('world');
expect(inserts.map((c) => c.text)).toContain('there');
});
it('handles two empty docs without error', () => {
const result = diffDocs({ type: 'doc', content: [] }, { type: 'doc', content: [] });
expect(result.changes).toHaveLength(0);
expect(result.summary).toEqual({ inserted: 0, deleted: 0, blocksChanged: 0 });
expect(result.markdown).toContain('(no textual changes)');
});
it('reports an insertion into an empty doc', () => {
const result = diffDocs({ type: 'doc', content: [] }, doc(para('brand new')));
expect(result.summary.inserted).toBeGreaterThan(0);
const inserts = result.changes.filter((c) => c.op === 'insert');
expect(inserts.length).toBeGreaterThan(0);
// The inserted text is the new paragraph's content.
expect(inserts.map((c) => c.text).join('')).toContain('brand new');
});
});
describe('integrity counting', () => {
it('counts images, tables and callouts as old -> new tuples', () => {
// old: 1 image, 1 callout, 1 table new: 2 images, 0 callouts, 1 table
const oldDoc = doc(image(), callout(), table());
const newDoc = doc(image(), image(), table());
const { integrity } = diffDocs(oldDoc, newDoc);
expect(integrity.images).toEqual([1, 2]);
expect(integrity.callouts).toEqual([1, 0]);
expect(integrity.tables).toEqual([1, 1]);
});
it('renders the integrity section verbatim in the markdown', () => {
const oldDoc = doc(image(), callout(), table());
const newDoc = doc(image(), image(), table());
const { markdown } = diffDocs(oldDoc, newDoc);
// The integrity block is our own formatting, so exact lines are asserted.
expect(markdown).toContain('## Integrity (old -> new)');
expect(markdown).toContain('- images: 1 -> 2');
expect(markdown).toContain('- callouts: 1 -> 0');
expect(markdown).toContain('- tables: 1 -> 1');
});
it('counts a single link split across two adjacent runs (shared href) as one link', () => {
// Two text runs, both bearing a link to the SAME href; one also bold.
const d = doc({
type: 'paragraph',
content: [
{ type: 'text', text: 'foo', marks: [{ type: 'link', attrs: { href: 'http://x' } }, { type: 'bold' }] },
{ type: 'text', text: 'bar', marks: [{ type: 'link', attrs: { href: 'http://x' } }] },
],
});
const { integrity } = diffDocs(d, d);
// Counting by unique href collapses the two runs into one link.
expect(integrity.links).toEqual([1, 1]);
});
it('counts distinct hrefs separately', () => {
const d = doc({
type: 'paragraph',
content: [
{ type: 'text', text: 'one', marks: [{ type: 'link', attrs: { href: 'http://a' } }] },
{ type: 'text', text: 'two', marks: [{ type: 'link', attrs: { href: 'http://b' } }] },
],
});
const { integrity } = diffDocs(d, d);
expect(integrity.links).toEqual([2, 2]);
});
it('counts a link mark with a missing href once (bucketed under "")', () => {
// Per source: a missing/empty href is collected under a single "" key, so a
// malformed link is still counted exactly once.
const d = linkPara('orphan', undefined);
const { integrity } = diffDocs(d, d);
expect(integrity.links).toEqual([1, 1]);
});
});
describe('footnoteMarkers', () => {
it('excludes markers after the default notes heading and preserves reading order', () => {
// Body has [1] then [2]; the [99] sits AFTER the notes heading and must be
// excluded from both old and new marker lists.
const d = doc(
para('intro [1] middle [2]'),
heading(DEFAULT_NOTES_HEADING),
para('[99] footnote body'),
);
const { integrity } = diffDocs(d, d);
expect(integrity.footnoteMarkers).toEqual([
[1, 2],
[1, 2],
]);
// Reading order: [1] precedes [2].
expect(integrity.footnoteMarkers[1]).toEqual([1, 2]);
});
it('honors a custom notesHeading argument', () => {
const d = doc(para('a [1]'), heading('Notes'), para('[5] excluded'));
const { integrity } = diffDocs(d, d, 'Notes');
// With the matching custom heading, [5] is excluded.
expect(integrity.footnoteMarkers).toEqual([[1], [1]]);
});
it('includes every marker when no notes heading is present', () => {
// No heading equals the notesHeading -> the whole doc is the body.
const d = doc(para('a [1] b [2]'), para('[3]'));
const { integrity } = diffDocs(d, d);
expect(integrity.footnoteMarkers).toEqual([
[1, 2, 3],
[1, 2, 3],
]);
});
it('renders the footnoteMarkers integrity line verbatim', () => {
const d = doc(para('x [1] y [2]'), heading(DEFAULT_NOTES_HEADING), para('[9]'));
const { markdown } = diffDocs(d, d);
expect(markdown).toContain('- footnoteMarkers: [1, 2] -> [1, 2]');
});
});
describe('coarse fallback', () => {
// An unknown node type makes Node.fromJSON reject the doc, which throws
// inside the precise pipeline and triggers the coarse block-level fallback.
// (Confirmed by running the module: `{ type: '___nope' }` is not in the
// schema, so parsing throws and `fellBack` becomes true.)
it('degrades to a coarse block-level diff instead of throwing', () => {
const oldDoc = doc(para('keep this'), { type: '___nope' });
const newDoc = doc(para('keep this'), para('new block'));
// Must not throw.
const result = diffDocs(oldDoc, newDoc);
// The fallback note appears in the markdown header area.
expect(result.markdown).toContain('precise diff failed; coarse block-level diff shown.');
// Only the genuinely new block is reported; the unchanged "keep this"
// block is not.
const inserts = result.changes.filter((c) => c.op === 'insert');
expect(inserts).toHaveLength(1);
expect(inserts[0].text).toBe('new block');
});
it('does not report whitespace-only blocks in the fallback path', () => {
// New doc adds a block whose plain text is only whitespace; coarseDiff
// skips blocks whose trimmed text is empty.
const oldDoc = doc({ type: '___nope' }, para('kept'));
const newDoc = doc(para('kept'), para(' '));
const result = diffDocs(oldDoc, newDoc);
// Fallback was taken (precise path threw on the unknown node).
expect(result.markdown).toContain('coarse block-level diff shown.');
// No change is reported: "kept" is unchanged and " " is whitespace-only.
expect(result.changes).toHaveLength(0);
expect(result.summary).toEqual({ inserted: 0, deleted: 0, blocksChanged: 0 });
});
it('still computes integrity (images/tables/callouts/footnotes) in the coarse-fallback branch', () => {
// Regression guard: integrity is computed BEFORE the try/catch, so a
// pathological pair that forces the fallback must NOT zero the integrity
// counts. The unknown node forces the precise path to throw (fellBack).
const oldDoc = doc(image(), callout(), table(), para('a [1]'), { type: '___nope' });
const newDoc = doc(image(), image(), table(), para('b [2] [3]'));
const result = diffDocs(oldDoc, newDoc);
// The fallback was taken...
expect(result.markdown).toContain('coarse block-level diff shown.');
// ...yet every integrity tuple is the real count, not [0,0].
expect(result.integrity.images).toEqual([1, 2]);
expect(result.integrity.callouts).toEqual([1, 0]);
expect(result.integrity.tables).toEqual([1, 1]);
// Footnote markers are counted from both docs even under the fallback.
expect(result.integrity.footnoteMarkers).toEqual([[1], [2, 3]]);
});
it('reports both a deletion and an insertion in the fallback path', () => {
const oldDoc = doc(para('old paragraph'), { type: '___nope' });
const newDoc = doc(para('new paragraph'));
const result = diffDocs(oldDoc, newDoc);
expect(result.markdown).toContain('coarse block-level diff shown.');
const deletes = result.changes.filter((c) => c.op === 'delete');
const inserts = result.changes.filter((c) => c.op === 'insert');
// "old paragraph" no longer present -> deletion; "new paragraph" -> insertion.
expect(deletes.map((c) => c.text)).toContain('old paragraph');
expect(inserts.map((c) => c.text)).toContain('new paragraph');
// Character counts accumulate from the reported texts.
expect(result.summary.deleted).toBe('old paragraph'.length);
expect(result.summary.inserted).toBe('new paragraph'.length);
});
});
describe('blockContextAt (DiffChange.block)', () => {
it('truncates a >80-char block context with an ellipsis and keeps it non-empty', () => {
// A 100-char paragraph with a one-char edit; the block context guards a
// swallowed catch and must produce a truncated, non-empty string.
const longText = 'X'.repeat(100);
const result = diffDocs(doc(para(longText)), doc(para(longText + 'Z')));
const inserts = result.changes.filter((c) => c.op === 'insert');
expect(inserts).toHaveLength(1);
const block = inserts[0].block;
expect(block.length).toBeGreaterThan(0);
// Truncation rule: 77 chars + "..." = length 80, ending with "...".
expect(block.endsWith('...')).toBe(true);
expect(block).toHaveLength(80);
});
it('keeps a short block context untruncated', () => {
const result = diffDocs(doc(para('abc')), doc(para('abcXY')));
const inserts = result.changes.filter((c) => c.op === 'insert');
expect(inserts[0].block).toBe('abcXY');
expect(inserts[0].block.endsWith('...')).toBe(false);
});
it('dedups blocksChanged by op + block context (multiple edits in one block count once per op)', () => {
// Two separate word edits inside a single paragraph produce 4 changes
// (2 deletes + 2 inserts) but only 2 distinct block keys:
// "d:the quick brown fox" and "i:the slow brown wolf".
const result = diffDocs(
doc(para('the quick brown fox')),
doc(para('the slow brown wolf')),
);
expect(result.changes.length).toBe(4);
expect(result.summary.blocksChanged).toBe(2);
});
it('counts one block key per op for edits spread across two blocks', () => {
// Edits in two different paragraphs -> 4 distinct block keys.
const result = diffDocs(
doc(para('first line here'), para('second line here')),
doc(para('first line HERE'), para('second line HERE')),
);
expect(result.summary.blocksChanged).toBe(4);
});
});
describe('markdown rendering', () => {
it('puts the summary counts in the markdown header', () => {
const result = diffDocs(doc(para('abc')), doc(para('abcXY')));
expect(result.markdown).toContain(
'# Diff: 2 inserted / 0 deleted (1 blocks changed)',
);
});
it('renders each change with its op sign (loose membership, library-controlled order)', () => {
const result = diffDocs(doc(para('hello world')), doc(para('hello there')));
// The Changes section is ordered by the diff library; assert membership,
// not an exact ordered string. Scope to lines AFTER the "## Changes"
// heading, since integrity lines also begin with "- ".
const lines = result.markdown.split('\n');
const changesIdx = lines.indexOf('## Changes');
expect(changesIdx).toBeGreaterThanOrEqual(0);
const changeLines = lines
.slice(changesIdx + 1)
.filter((l) => l.startsWith('+ ') || l.startsWith('- '));
expect(changeLines.some((l) => l.startsWith('- ') && l.includes('world'))).toBe(true);
expect(changeLines.some((l) => l.startsWith('+ ') && l.includes('there'))).toBe(true);
// One delete line and one insert line.
expect(changeLines.filter((l) => l.startsWith('- '))).toHaveLength(1);
expect(changeLines.filter((l) => l.startsWith('+ '))).toHaveLength(1);
});
});
});

View File

@@ -4,7 +4,7 @@ import type { ApplyPushDeps, PushActions } from '../src/engine/push';
import { planReconciliation } from '../src/engine/reconcile';
import { buildVaultLayout, type PageNode } from '../src/engine/layout';
import { sanitizeTitle } from '../src/engine/sanitize';
import { firstDivergence } from '../src/engine/roundtrip-helpers';
import { firstDivergence } from './roundtrip-helpers';
import { applyPullActions } from '../src/engine/pull';
import type { PullActions, ApplyPullActionsDeps } from '../src/engine/pull';
import type { DeletionDecision } from '../src/engine/reconcile';

View File

@@ -14,7 +14,7 @@ import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
// global DOM via jsdom at module load time — this is expected and required for
// @tiptap/html's generateJSON to run under Node.
import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js';
import { stripBlockIds } from '../src/engine/roundtrip-helpers.js';
import { stripBlockIds } from './roundtrip-helpers.js';
// ---------------------------------------------------------------------------
// WHY THIS TEST EXISTS (SPEC §11 / "Задача №0")

View File

@@ -157,6 +157,25 @@ describe('preprocessCallouts: nested callouts + code-fenced ":::"', () => {
expect(allText(callout)).toContain('before code');
expect(allText(callout)).toContain('after code');
});
it('(c) an UNCLOSED ":::" opener is treated as a literal line, not a callout', async () => {
// Realistic input: a hand-edited vault file with a `:::info` opener and no
// matching closing `:::`. The fallback emits the opener as a LITERAL line
// rather than swallowing the rest of the document into a phantom callout —
// previously uncovered (markdown-to-prosemirror.ts).
const md = [':::info', 'orphan body line', 'another line'].join('\n');
const docNode = await markdownToProseMirror(md);
// No callout node was created (the opener never closed).
expect(findAll(docNode, 'callout')).toHaveLength(0);
// The opener survives as literal text and the body lines are preserved (the
// rest of the document was NOT eaten by an unterminated callout).
const text = allText(docNode);
expect(text).toContain(':::info');
expect(text).toContain('orphan body line');
expect(text).toContain('another line');
});
});
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,110 @@
import { describe, it, expect, vi } from "vitest";
import {
assertVaultPathSafe,
isWithinRoot,
VaultPathUnsafeError,
type PathGuardIo,
} from "../src/engine/path-guard";
const VAULT = "/srv/git-sync/space-1";
/**
* Build a fake PathGuardIo from a model of the filesystem:
* - `symlinks`: absolute paths that ARE symlinks (lstat -> isSymbolicLink).
* - `existing`: absolute paths that EXIST (anything not listed is ENOENT/null).
* The vault root is always treated as existing.
* - `realpaths`: optional realpath overrides (default: identity for existing).
*/
function fakeIo(model: {
symlinks?: string[];
existing?: string[];
realpaths?: Record<string, string>;
}): PathGuardIo {
const symlinks = new Set(model.symlinks ?? []);
const existing = new Set([VAULT, ...(model.existing ?? []), ...symlinks]);
return {
lstat: vi.fn(async (p: string) =>
existing.has(p) ? { isSymbolicLink: symlinks.has(p) } : null,
),
realpath: vi.fn(async (p: string) =>
existing.has(p) ? (model.realpaths?.[p] ?? p) : null,
),
};
}
describe("isWithinRoot", () => {
it("accepts the root itself and nested paths", () => {
expect(isWithinRoot(VAULT, VAULT)).toBe(true);
expect(isWithinRoot(VAULT, `${VAULT}/a/b.md`)).toBe(true);
});
it("rejects siblings, ancestors and `..` traversal", () => {
expect(isWithinRoot(VAULT, "/srv/git-sync/space-2/x.md")).toBe(false);
expect(isWithinRoot(VAULT, "/srv/git-sync")).toBe(false);
expect(isWithinRoot(VAULT, `${VAULT}/../space-2/x.md`)).toBe(false);
expect(isWithinRoot(VAULT, "/etc/passwd")).toBe(false);
});
});
describe("assertVaultPathSafe", () => {
it("allows a normal nested file with no symlinks on its chain", async () => {
const io = fakeIo({ existing: [`${VAULT}/Folder`, `${VAULT}/Folder/Page.md`] });
await expect(
assertVaultPathSafe(io, VAULT, `${VAULT}/Folder/Page.md`),
).resolves.toBeUndefined();
});
it("allows a NOT-YET-EXISTING leaf (the normal write/mkdir case)", async () => {
// Folder exists, the .md does not yet — the walk stops at the absent leaf.
const io = fakeIo({ existing: [`${VAULT}/Folder`] });
await expect(
assertVaultPathSafe(io, VAULT, `${VAULT}/Folder/New.md`),
).resolves.toBeUndefined();
});
it("rejects a TARGET that is itself a symlink (the leak.md -> /etc/passwd attack)", async () => {
const io = fakeIo({ symlinks: [`${VAULT}/leak.md`] });
await expect(
assertVaultPathSafe(io, VAULT, `${VAULT}/leak.md`),
).rejects.toBeInstanceOf(VaultPathUnsafeError);
await expect(
assertVaultPathSafe(io, VAULT, `${VAULT}/leak.md`),
).rejects.toMatchObject({ reason: "symlink" });
});
it("rejects a path whose ANCESTOR directory is a symlink (write-outside-vault primitive)", async () => {
// `escape` is a symlinked dir; writing `escape/x.md` would land outside.
const io = fakeIo({
symlinks: [`${VAULT}/escape`],
existing: [`${VAULT}/escape/x.md`],
});
await expect(
assertVaultPathSafe(io, VAULT, `${VAULT}/escape/x.md`),
).rejects.toMatchObject({ reason: "symlink" });
});
it("rejects a `..` traversal lexically, before any IO", async () => {
const io = fakeIo({});
await expect(
assertVaultPathSafe(io, VAULT, `${VAULT}/../space-2/steal.md`),
).rejects.toMatchObject({ reason: "escape" });
expect(io.lstat).not.toHaveBeenCalled();
});
it("rejects when the deepest existing ancestor's realpath escapes the vault", async () => {
// No symlink flagged by lstat (e.g. the data dir was relocated under a link
// the lexical/lstat checks below the root cannot see), but realpath resolves
// the existing ancestor outside the vault's realpath.
const io = fakeIo({
existing: [`${VAULT}/sub`],
realpaths: { [VAULT]: VAULT, [`${VAULT}/sub`]: "/elsewhere/sub" },
});
await expect(
assertVaultPathSafe(io, VAULT, `${VAULT}/sub/page.md`),
).rejects.toMatchObject({ reason: "escape" });
});
it("allows the vault root path itself", async () => {
const io = fakeIo({});
await expect(assertVaultPathSafe(io, VAULT, VAULT)).resolves.toBeUndefined();
});
});

View File

@@ -1,5 +1,10 @@
import { describe, expect, it, vi } from 'vitest';
import { runPush, LAST_PUSHED_REF, DOCMOST_BRANCH } from '../src/engine/push';
import {
runPush,
LAST_PUSHED_REF,
DOCMOST_BRANCH,
CONFLICT_MARKERS_FAILURE_REASON,
} from '../src/engine/push';
import type { PushDeps } from '../src/engine/push';
import type { Settings } from '../src/engine/settings';
import { runCycle, type RunCycleDeps } from '../src/engine/cycle';
@@ -139,6 +144,59 @@ describe('#13 conflict markers reach Docmost', () => {
expect(pushedBody).toContain('my line');
expect(pushedBody).toContain('their line');
});
it('CREATE branch (autoMergeConflicts off): does NOT create a page from a conflicted NEW file; records a create failure', async () => {
// The conflict-markers guard is DUPLICATED on the CREATE path (a brand-new
// .md with NO gitmost_id, status 'A') and was previously untested — only the
// UPDATE branch had coverage. Without this, a regression would SILENTLY push
// `<<<<<<<`/`>>>>>>>` into a freshly-created page. Assert the create path
// isolates it exactly like update: no createPage, a kind:'create' failure
// with the conflict reason, and the refs held.
const { git, calls } = makePushGit({
changes: [{ status: 'A', path: 'New.md' }],
});
const createPage = vi.fn(async () => ({ data: { id: 'new-1' } }));
const client = {
listSpaceTree: vi.fn(async () => ({ pages: [], complete: true })),
importPageMarkdown: vi.fn(),
createPage,
deletePage: vi.fn(),
movePage: vi.fn(),
renamePage: vi.fn(),
};
const deps: PushDeps = {
// makeSettings() leaves autoMergeConflicts undefined -> the SAFE default.
settings: makeSettings(),
git,
makeClient: () => client as any,
// Raw conflict body with NO gitmost_id frontmatter -> classified as CREATE.
readFile: vi.fn(async (path: string) => {
if (path === 'New.md') return conflictBody;
throw new Error(`no such file: ${path}`);
}),
writeFile: vi.fn(async () => {}),
log: () => {},
};
const res = await runPush(deps, { dryRun: false });
expect(res.mode).toBe('apply');
// No page was created from the conflicted content.
expect(createPage).not.toHaveBeenCalled();
// Recorded as a CREATE failure with the conflict-markers reason.
expect(res.applied?.failures).toEqual([
expect.objectContaining({
kind: 'create',
path: 'New.md',
error: CONFLICT_MARKERS_FAILURE_REASON,
}),
]);
// A failure prevents advancing the last-pushed ref.
expect(res.applied?.lastPushedAdvanced).toBe(false);
expect(calls.updateRef).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,118 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { getSchema } from "@tiptap/core";
import { docmostExtensions } from "../../build/lib/docmost-schema.js";
// SCHEMA-DRIFT GUARD (must-review gate).
//
// `src/lib/docmost-schema.ts` is a VENDORED MIRROR of the canonical Docmost
// document schema defined in `@docmost/editor-ext`. The MCP server uses it to
// convert pages to/from ProseMirror JSON (and through Yjs); any node, mark, or
// attribute that exists in the canonical schema but is missing here is silently
// dropped on a round-trip (data loss). The reverse — a node/mark/attr here that
// no longer exists in the canonical schema — is dead surface that can mask drift.
//
// This test derives a stable, sorted "schema surface" (every node/mark name and
// its sorted attribute keys) and pins it against an INLINE expected constant.
// It is intentionally a LOUD must-review gate rather than an automatic
// editor-ext diff: editor-ext's Tiptap representation differs from this
// vendored copy, so a cross-representation compare would be fragile. The
// reference lives in this file so it is reviewed in the diff of every change.
//
// This is the MCP twin of git-sync's
// `packages/git-sync/test/schema-surface-snapshot.test.ts`. The two vendored
// copies are NOT identical (see PROVENANCE in docmost-schema.ts): the MCP copy
// does not vendor every node git-sync does, so the surfaces legitimately differ.
// Keep both gates honest against `@docmost/editor-ext` independently.
//
// WHEN THIS TEST FAILS: do NOT blindly update `expectedSurface`. First confirm
// the change matches `@docmost/editor-ext` (the canonical schema) so the
// markdown <-> ProseMirror round-trip stays lossless, THEN copy the new surface
// into the expected constant below.
/** Derive the deterministic schema surface from the vendored extension set. */
function deriveSurface() {
const schema = getSchema(docmostExtensions);
const surface = [];
for (const [name, type] of Object.entries(schema.nodes)) {
surface.push({
name,
kind: "node",
attrs: Object.keys(type.spec?.attrs ?? {}).sort(),
});
}
for (const [name, type] of Object.entries(schema.marks)) {
surface.push({
name,
kind: "mark",
attrs: Object.keys(type.spec?.attrs ?? {}).sort(),
});
}
// Sort by name, then by kind, for a representation-independent ordering.
surface.sort((a, b) =>
a.name === b.name ? a.kind.localeCompare(b.kind) : a.name.localeCompare(b.name),
);
return surface;
}
// The committed reference surface. Built from the ACTUAL current schema; review
// every change to this constant against `@docmost/editor-ext`.
const expectedSurface = [
{ name: "attachment", kind: "node", attrs: ["attachmentId", "mime", "name", "placeholder", "size", "url"] },
{ name: "audio", kind: "node", attrs: ["attachmentId", "placeholder", "size", "src"] },
{ name: "blockquote", kind: "node", attrs: [] },
{ name: "bold", kind: "mark", attrs: [] },
{ name: "bulletList", kind: "node", attrs: [] },
{ name: "callout", kind: "node", attrs: ["icon", "type"] },
{ name: "code", kind: "mark", attrs: [] },
{ name: "codeBlock", kind: "node", attrs: ["language"] },
{ name: "column", kind: "node", attrs: ["width"] },
{ name: "columns", kind: "node", attrs: ["layout", "widthMode"] },
{ name: "comment", kind: "mark", attrs: ["commentId", "resolved"] },
{ name: "details", kind: "node", attrs: ["open"] },
{ name: "detailsContent", kind: "node", attrs: [] },
{ name: "detailsSummary", kind: "node", attrs: [] },
{ name: "doc", kind: "node", attrs: [] },
{ name: "drawio", kind: "node", attrs: ["align", "alt", "aspectRatio", "attachmentId", "height", "size", "src", "title", "width"] },
{ name: "embed", kind: "node", attrs: ["align", "height", "provider", "src", "width"] },
{ name: "excalidraw", kind: "node", attrs: ["align", "alt", "aspectRatio", "attachmentId", "height", "size", "src", "title", "width"] },
{ name: "footnoteDefinition", kind: "node", attrs: ["id"] },
{ name: "footnoteReference", kind: "node", attrs: ["id"] },
{ name: "footnotesList", kind: "node", attrs: [] },
{ name: "hardBreak", kind: "node", attrs: [] },
{ name: "heading", kind: "node", attrs: ["id", "indent", "level", "textAlign"] },
{ name: "highlight", kind: "mark", attrs: ["color"] },
{ name: "horizontalRule", kind: "node", attrs: [] },
{ name: "htmlEmbed", kind: "node", attrs: ["height", "source"] },
{ name: "image", kind: "node", attrs: ["align", "alt", "aspectRatio", "attachmentId", "height", "placeholder", "size", "src", "title", "width"] },
{ name: "italic", kind: "mark", attrs: [] },
{ name: "link", kind: "mark", attrs: ["class", "href", "internal", "rel", "target", "title"] },
{ name: "listItem", kind: "node", attrs: [] },
{ name: "mathBlock", kind: "node", attrs: ["text"] },
{ name: "mathInline", kind: "node", attrs: ["text"] },
{ name: "mention", kind: "node", attrs: ["anchorId", "creatorId", "entityId", "entityType", "id", "label", "slugId"] },
{ name: "orderedList", kind: "node", attrs: ["start", "type"] },
{ name: "pageBreak", kind: "node", attrs: [] },
{ name: "paragraph", kind: "node", attrs: ["id", "indent", "textAlign"] },
{ name: "pdf", kind: "node", attrs: ["attachmentId", "height", "name", "placeholder", "size", "src", "width"] },
{ name: "strike", kind: "mark", attrs: [] },
{ name: "subpages", kind: "node", attrs: [] },
{ name: "subscript", kind: "mark", attrs: [] },
{ name: "superscript", kind: "mark", attrs: [] },
{ name: "table", kind: "node", attrs: [] },
{ name: "tableCell", kind: "node", attrs: ["align", "backgroundColor", "backgroundColorName", "colspan", "colwidth", "rowspan"] },
{ name: "tableHeader", kind: "node", attrs: ["align", "backgroundColor", "backgroundColorName", "colspan", "colwidth", "rowspan"] },
{ name: "tableRow", kind: "node", attrs: [] },
{ name: "taskItem", kind: "node", attrs: ["checked"] },
{ name: "taskList", kind: "node", attrs: [] },
{ name: "text", kind: "node", attrs: [] },
{ name: "textStyle", kind: "mark", attrs: ["color"] },
{ name: "underline", kind: "mark", attrs: [] },
{ name: "video", kind: "node", attrs: ["align", "alt", "aspectRatio", "attachmentId", "height", "placeholder", "size", "src", "width"] },
{ name: "youtube", kind: "node", attrs: ["align", "height", "src", "width"] },
];
test("docmost schema surface matches the committed reference (re-verify against @docmost/editor-ext on change)", () => {
assert.deepEqual(deriveSurface(), expectedSurface);
});

3
pnpm-lock.yaml generated
View File

@@ -889,9 +889,6 @@ importers:
packages/git-sync:
dependencies:
'@fellow/prosemirror-recreate-transform':
specifier: ^1.2.3
version: 1.2.3
'@tiptap/core':
specifier: 3.20.4
version: 3.20.4(@tiptap/pm@3.20.4)