fix(git-sync): address PR #119 review — close 403/404 space-existence leak + warnings/tests/arch
Security (must-fix):
- /git smart-HTTP gate: an authenticated NON-member of a git-sync space now gets
404 (not 403), so the 403<->404 difference can no longer be used to brute-force
which spaces exist / have git-sync enabled. 403 is reserved for a MEMBER who
lacks the required role (existence already known). New gate input
userIsSpaceMember; decision-table + service specs extended.
Config (must-fix):
- Remove the dead GIT_SYNC_SSH_KEY_PATH knob (getter + validation field + two
.env.example lines) — it had zero consumers and advertised a nonexistent push
capability.
Stability/docs (warnings):
- Wire the lost-lock AbortSignal into runReceivePack -> git http-backend so the
receive-pack child is killed if the per-space lock lapses mid-write.
- Raise the divergent-`docmost` (invariant §5) push refusal from info -> warn and
surface divergentDocmost in the run status (/status).
- Comment the stale read-after-debounced-collab-write updatedAt in
importPageMarkdown (deferred §10 loop-guard must not trust it).
- Fix the Dockerfile comment: the loader uses require.resolve + dynamic import(),
it deliberately does NOT require('@docmost/git-sync').
- Merge the two near-identical space toggle handlers into one parameterized
handler; add the 2 missing en-US i18n keys for the auto-merge switch (ru-RU not
maintained for these git-sync strings, mirrored).
Tests:
- isGitSyncHttpEnabled() default-branch (unset -> isGitSyncEnabled fallback).
- agentSourceFields 'git-sync' case (source stamped, chat key omitted).
- editor-ext name-level schema contract (vendored mirror superset of editor-ext
node/mark types) + the new shared resolver + non-member 404 gate cases.
Architecture:
- Extract resolveRequestWorkspace shared by DomainMiddleware + GitHttpService
(the two real self-hosted/cloud copies; McpService has no cloud branch).
- Document the in-process setInterval multi-replica limitation + BullMQ/fencing
future direction (deferred, not implemented).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -227,9 +227,6 @@ MCP_DOCMOST_PASSWORD=
|
||||
# Leave unset to keep vaults local-only.
|
||||
# GIT_SYNC_REMOTE_TEMPLATE=
|
||||
#
|
||||
# Path to the SSH private key used when pushing to GIT_SYNC_REMOTE_TEMPLATE.
|
||||
# GIT_SYNC_SSH_KEY_PATH=
|
||||
#
|
||||
# Poll-safety interval in ms — the cadence of the background reconcile cycle
|
||||
# (default: 15000).
|
||||
# GIT_SYNC_POLL_INTERVAL_MS=15000
|
||||
|
||||
@@ -39,9 +39,12 @@ COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
||||
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
||||
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
||||
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
||||
# git-sync: the server requires @docmost/git-sync at runtime; without these the
|
||||
# image starts and crashes on `require('@docmost/git-sync')`. Built fresh by the
|
||||
# builder's `pnpm build` (nx builds the package's tsc `build` target).
|
||||
# git-sync: the server loads @docmost/git-sync at runtime via the loader
|
||||
# (git-sync.loader.ts), which deliberately does NOT `require()` it — the package is
|
||||
# ESM-only, so the loader uses `require.resolve` + a dynamic `import()`. Without
|
||||
# these copied build artifacts that resolve/import fails and the server crashes on
|
||||
# first use. Built fresh by the builder's `pnpm build` (nx builds the package's tsc
|
||||
# `build` target).
|
||||
COPY --from=builder /app/packages/git-sync/build /app/packages/git-sync/build
|
||||
COPY --from=builder /app/packages/git-sync/package.json /app/packages/git-sync/package.json
|
||||
|
||||
|
||||
@@ -1245,6 +1245,8 @@
|
||||
"Enable MCP server": "Enable MCP server",
|
||||
"Enable Git sync": "Enable Git sync",
|
||||
"Sync this space's pages to a Git repository.": "Sync this space's pages to a Git repository.",
|
||||
"Auto-merge conflicts on push": "Auto-merge conflicts on push",
|
||||
"When off (recommended), a page whose content still has unresolved Git conflict markers is skipped on push until you resolve the conflict in Git. When on, the markers are stripped and both sides' content is pushed.": "When off (recommended), a page whose content still has unresolved Git conflict markers is skipped on push until you resolve the conflict in Git. When on, the markers are stripped and both sides' content is pushed.",
|
||||
"Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.": "Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.",
|
||||
"Resolves to {{url}}": "Resolves to {{url}}",
|
||||
"Model": "Model",
|
||||
|
||||
@@ -46,33 +46,26 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
||||
space?.settings?.gitSync?.autoMergeConflicts ?? false,
|
||||
);
|
||||
|
||||
const handleGitSyncToggle = async (value: boolean) => {
|
||||
const previous = gitSyncEnabled;
|
||||
setGitSyncEnabled(value); // optimistic update
|
||||
// One parameterized handler for both git-sync space toggles: they differ only by
|
||||
// the local state setter, the mutation payload field, and the error label. The
|
||||
// update is optimistic and reverts the local state on failure (the mutation
|
||||
// surfaces a toast via onError; the raw error is still logged per AGENTS.md).
|
||||
const handleToggle = async (
|
||||
field: "gitSyncEnabled" | "autoMergeConflicts",
|
||||
value: boolean,
|
||||
previous: boolean,
|
||||
setLocal: (next: boolean) => void,
|
||||
errorLabel: string,
|
||||
) => {
|
||||
setLocal(value); // optimistic update
|
||||
try {
|
||||
await updateSpaceMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
gitSyncEnabled: value,
|
||||
[field]: value,
|
||||
});
|
||||
} catch (err) {
|
||||
setGitSyncEnabled(previous); // revert on failure
|
||||
// The mutation surfaces a toast via onError; still log the raw error so it
|
||||
// is not silently swallowed (AGENTS.md).
|
||||
console.error("Failed to toggle git-sync for space", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoMergeConflictsToggle = async (value: boolean) => {
|
||||
const previous = autoMergeConflicts;
|
||||
setAutoMergeConflicts(value); // optimistic update
|
||||
try {
|
||||
await updateSpaceMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
autoMergeConflicts: value,
|
||||
});
|
||||
} catch (err) {
|
||||
setAutoMergeConflicts(previous); // revert on failure
|
||||
console.error("Failed to toggle git-sync auto-merge-conflicts", err);
|
||||
setLocal(previous); // revert on failure
|
||||
console.error(errorLabel, err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -160,7 +153,13 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
||||
checked={gitSyncEnabled}
|
||||
disabled={readOnly || updateSpaceMutation.isPending}
|
||||
onChange={(event) =>
|
||||
handleGitSyncToggle(event.currentTarget.checked)
|
||||
handleToggle(
|
||||
"gitSyncEnabled",
|
||||
event.currentTarget.checked,
|
||||
gitSyncEnabled,
|
||||
setGitSyncEnabled,
|
||||
"Failed to toggle git-sync for space",
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -173,7 +172,13 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
||||
checked={autoMergeConflicts}
|
||||
disabled={readOnly || updateSpaceMutation.isPending}
|
||||
onChange={(event) =>
|
||||
handleAutoMergeConflictsToggle(event.currentTarget.checked)
|
||||
handleToggle(
|
||||
"autoMergeConflicts",
|
||||
event.currentTarget.checked,
|
||||
autoMergeConflicts,
|
||||
setAutoMergeConflicts,
|
||||
"Failed to toggle git-sync auto-merge-conflicts",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -73,6 +73,32 @@ describe('agentSourceFields', () => {
|
||||
).toEqual({ lastUpdatedSource: 'agent', lastUpdatedAiChatId: null });
|
||||
});
|
||||
|
||||
it("stamps ONLY the source column 'git-sync' (no chat key) for a git-sync write", () => {
|
||||
// The git-sync data plane (issue #194 §8.1) has no internal ai_chats row, so
|
||||
// it stamps the *Source column 'git-sync' and OMITS the chat key entirely
|
||||
// (unlike the agent branch, which also writes aiChatId). Pinned directly here
|
||||
// because the page.service.spec only exercises it indirectly.
|
||||
expect(
|
||||
agentSourceFields(
|
||||
{ actor: 'git-sync', aiChatId: null },
|
||||
'lastUpdatedSource',
|
||||
'lastUpdatedAiChatId',
|
||||
),
|
||||
).toEqual({ lastUpdatedSource: 'git-sync' });
|
||||
});
|
||||
|
||||
it("ignores any aiChatId on a git-sync write (chat key never written)", () => {
|
||||
// Even if a non-null aiChatId is present, the git-sync branch must not emit
|
||||
// the chat key.
|
||||
expect(
|
||||
agentSourceFields(
|
||||
{ actor: 'git-sync', aiChatId: 'should-be-ignored' },
|
||||
'createdSource',
|
||||
'aiChatId',
|
||||
),
|
||||
).toEqual({ createdSource: 'git-sync' });
|
||||
});
|
||||
|
||||
it('returns {} for a user write so the column keeps its default', () => {
|
||||
expect(
|
||||
agentSourceFields(
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { resolveRequestWorkspace } from './resolve-request-workspace';
|
||||
|
||||
// Unit tests for the shared self-hosted/cloud workspace resolver deduplicated out
|
||||
// of DomainMiddleware + GitHttpService (architecture #11). They must behave
|
||||
// identically, so this pins the single source of truth.
|
||||
|
||||
type AnyMock = jest.Mock;
|
||||
|
||||
function build(opts: {
|
||||
selfHosted: boolean;
|
||||
first?: { id: string } | null;
|
||||
byHostname?: { id: string } | null;
|
||||
}) {
|
||||
const env = {
|
||||
isSelfHosted: jest.fn(() => opts.selfHosted),
|
||||
isCloud: jest.fn(() => !opts.selfHosted),
|
||||
};
|
||||
const repo = {
|
||||
findFirst: jest.fn(async () => opts.first ?? null) as AnyMock,
|
||||
findByHostname: jest.fn(async () => opts.byHostname ?? null) as AnyMock,
|
||||
};
|
||||
return { env, repo };
|
||||
}
|
||||
|
||||
describe('resolveRequestWorkspace', () => {
|
||||
it('self-hosted: returns the first/default workspace, ignoring the host', async () => {
|
||||
const { env, repo } = build({ selfHosted: true, first: { id: 'ws-1' } });
|
||||
const ws = await resolveRequestWorkspace(
|
||||
env as any,
|
||||
repo as any,
|
||||
'anything.example.com',
|
||||
);
|
||||
expect(ws).toEqual({ id: 'ws-1' });
|
||||
expect(repo.findFirst).toHaveBeenCalledTimes(1);
|
||||
expect(repo.findByHostname).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('self-hosted: returns null when no workspace is configured', async () => {
|
||||
const { env, repo } = build({ selfHosted: true, first: null });
|
||||
expect(await resolveRequestWorkspace(env as any, repo as any, 'h')).toBeNull();
|
||||
});
|
||||
|
||||
it('cloud: resolves by the host-header subdomain', async () => {
|
||||
const { env, repo } = build({
|
||||
selfHosted: false,
|
||||
byHostname: { id: 'ws-acme' },
|
||||
});
|
||||
const ws = await resolveRequestWorkspace(
|
||||
env as any,
|
||||
repo as any,
|
||||
'acme.example.com',
|
||||
);
|
||||
expect(ws).toEqual({ id: 'ws-acme' });
|
||||
expect(repo.findByHostname).toHaveBeenCalledWith('acme');
|
||||
expect(repo.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cloud: returns null for a blank/missing host (no throw)', async () => {
|
||||
const { env, repo } = build({ selfHosted: false, byHostname: { id: 'x' } });
|
||||
expect(await resolveRequestWorkspace(env as any, repo as any, undefined)).toBeNull();
|
||||
expect(await resolveRequestWorkspace(env as any, repo as any, '')).toBeNull();
|
||||
expect(repo.findByHostname).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cloud: returns null when the subdomain matches no workspace', async () => {
|
||||
const { env, repo } = build({ selfHosted: false, byHostname: null });
|
||||
expect(
|
||||
await resolveRequestWorkspace(env as any, repo as any, 'ghost.example.com'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
35
apps/server/src/common/helpers/resolve-request-workspace.ts
Normal file
35
apps/server/src/common/helpers/resolve-request-workspace.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { Workspace } from '@docmost/db/types/entity.types';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
|
||||
/**
|
||||
* The ONE canonical way to resolve the workspace for an incoming request:
|
||||
* - self-hosted (single workspace) -> the first/default workspace;
|
||||
* - cloud (multi-tenant) -> resolved by the host-header subdomain.
|
||||
* Returns null when none resolves (no workspace configured, or a blank/unknown
|
||||
* subdomain on cloud). `isSelfHosted()` is `!isCloud()`, so exactly one branch is
|
||||
* always taken.
|
||||
*
|
||||
* Extracted so the self-hosted/cloud branch is not hand-duplicated. Shared by
|
||||
* `DomainMiddleware` (the normal /api request path) and `GitHttpService` (the raw
|
||||
* root-mounted /git smart-HTTP host, which Nest middleware does NOT run for) so
|
||||
* the two cannot drift.
|
||||
*
|
||||
* This helper does NOT catch DB errors — callers decide: DomainMiddleware lets a
|
||||
* throw bubble (as before); GitHttpService wraps it to log + treat as
|
||||
* unresolvable (-> 404). A blank/missing host on cloud resolves to null rather
|
||||
* than throwing.
|
||||
*/
|
||||
export async function resolveRequestWorkspace(
|
||||
environmentService: EnvironmentService,
|
||||
workspaceRepo: WorkspaceRepo,
|
||||
hostHeader: string | undefined,
|
||||
): Promise<Workspace | null> {
|
||||
if (environmentService.isSelfHosted()) {
|
||||
return (await workspaceRepo.findFirst()) ?? null;
|
||||
}
|
||||
// Cloud (isSelfHosted === !isCloud, so this is the only remaining branch).
|
||||
const subdomain = hostHeader ? hostHeader.split('.')[0] : '';
|
||||
if (!subdomain) return null;
|
||||
return (await workspaceRepo.findByHostname(subdomain)) ?? null;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable, NestMiddleware, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { resolveRequestWorkspace } from '../helpers/resolve-request-workspace';
|
||||
|
||||
@Injectable()
|
||||
export class DomainMiddleware implements NestMiddleware {
|
||||
@@ -14,30 +15,19 @@ export class DomainMiddleware implements NestMiddleware {
|
||||
res: FastifyReply['raw'],
|
||||
next: () => void,
|
||||
) {
|
||||
if (this.environmentService.isSelfHosted()) {
|
||||
const workspace = await this.workspaceRepo.findFirst();
|
||||
if (!workspace) {
|
||||
//throw new NotFoundException('Workspace not found');
|
||||
(req as any).workspaceId = null;
|
||||
return next();
|
||||
}
|
||||
// Shared self-hosted/cloud resolution (the SAME branch the /git host uses),
|
||||
// so the logic cannot drift between the two.
|
||||
const workspace = await resolveRequestWorkspace(
|
||||
this.environmentService,
|
||||
this.workspaceRepo,
|
||||
req.headers.host,
|
||||
);
|
||||
|
||||
// TODO: unify
|
||||
if (workspace) {
|
||||
(req as any).workspaceId = workspace.id;
|
||||
(req as any).workspace = workspace;
|
||||
} else if (this.environmentService.isCloud()) {
|
||||
const header = req.headers.host;
|
||||
const subdomain = header.split('.')[0];
|
||||
|
||||
const workspace = await this.workspaceRepo.findByHostname(subdomain);
|
||||
|
||||
if (!workspace) {
|
||||
} else {
|
||||
(req as any).workspaceId = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
(req as any).workspaceId = workspace.id;
|
||||
(req as any).workspace = workspace;
|
||||
}
|
||||
|
||||
next();
|
||||
|
||||
@@ -122,4 +122,54 @@ describe('EnvironmentService', () => {
|
||||
expect(withEnv('1').isGitSyncEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// isGitSyncHttpEnabled is the master gate of the /git smart-HTTP trust boundary.
|
||||
// When GIT_SYNC_HTTP_ENABLED is UNSET it FALLS BACK to isGitSyncEnabled(); when
|
||||
// set it is honored verbatim ('true' -> on, anything else -> off). The fallback
|
||||
// (default) branch is what these tests pin.
|
||||
describe('isGitSyncHttpEnabled', () => {
|
||||
const withEnv = (values: Record<string, string | undefined>) =>
|
||||
new EnvironmentService({
|
||||
get: (key: string, fallback?: string) => values[key] ?? fallback,
|
||||
} as any);
|
||||
|
||||
it('DEFAULT branch: unset -> falls back to isGitSyncEnabled() === true', () => {
|
||||
expect(
|
||||
withEnv({ GIT_SYNC_ENABLED: 'true' }).isGitSyncHttpEnabled(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('DEFAULT branch: unset -> falls back to isGitSyncEnabled() === false', () => {
|
||||
// Neither key set: the fallback resolves to isGitSyncEnabled() which is
|
||||
// false by default.
|
||||
expect(withEnv({}).isGitSyncHttpEnabled()).toBe(false);
|
||||
expect(
|
||||
withEnv({ GIT_SYNC_ENABLED: 'false' }).isGitSyncHttpEnabled(),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('explicit "true" enables the host regardless of GIT_SYNC_ENABLED', () => {
|
||||
expect(
|
||||
withEnv({
|
||||
GIT_SYNC_HTTP_ENABLED: 'true',
|
||||
GIT_SYNC_ENABLED: 'false',
|
||||
}).isGitSyncHttpEnabled(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('explicit non-"true" disables the host even when sync is enabled', () => {
|
||||
expect(
|
||||
withEnv({
|
||||
GIT_SYNC_HTTP_ENABLED: 'false',
|
||||
GIT_SYNC_ENABLED: 'true',
|
||||
}).isGitSyncHttpEnabled(),
|
||||
).toBe(false);
|
||||
expect(
|
||||
withEnv({
|
||||
GIT_SYNC_HTTP_ENABLED: 'maybe',
|
||||
GIT_SYNC_ENABLED: 'true',
|
||||
}).isGitSyncHttpEnabled(),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -419,9 +419,4 @@ export class EnvironmentService {
|
||||
getGitSyncServiceUserId(): string | undefined {
|
||||
return this.configService.get<string>('GIT_SYNC_SERVICE_USER_ID');
|
||||
}
|
||||
|
||||
/** Optional path to the SSH key used for git remote access. */
|
||||
getGitSyncSshKeyPath(): string | undefined {
|
||||
return this.configService.get<string>('GIT_SYNC_SSH_KEY_PATH');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,10 +216,6 @@ export class EnvironmentVariables {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
GIT_SYNC_SERVICE_USER_ID: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
GIT_SYNC_SSH_KEY_PATH: string;
|
||||
}
|
||||
|
||||
export function validate(config: Record<string, any>) {
|
||||
|
||||
@@ -146,11 +146,18 @@ export class GitHttpBackendService {
|
||||
* child exited and its output was flushed), or after a 500 was sent on an
|
||||
* early failure. Never rejects — push ingestion relies on this resolving so
|
||||
* the lock-held cycle body can run afterwards.
|
||||
*
|
||||
* `signal` (optional) is the git-sync per-space lock's lost-lock abort signal.
|
||||
* A receive-pack writes `main`'s working tree, so if the lock lapses mid-push
|
||||
* (heartbeat CAS miss / Redis outage) the signal fires and we kill the child —
|
||||
* preventing it from continuing to write the working tree while another replica
|
||||
* may have taken over the lock and started a cycle (warning #3).
|
||||
*/
|
||||
async run(
|
||||
parsed: GitHttpBackendRequest,
|
||||
rawReq: IncomingMessage,
|
||||
rawRes: ServerResponse,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const { vaultGitEnv } = await loadGitSync();
|
||||
const projectRoot = this.environmentService.getGitSyncDataDir();
|
||||
@@ -162,12 +169,33 @@ export class GitHttpBackendService {
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
// Set once the child exists so the abort handler can target it.
|
||||
let onAbort: (() => void) | null = null;
|
||||
const done = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
// Detach the abort listener so a later lock loss does not fire into a
|
||||
// request that already finished.
|
||||
if (onAbort) {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
onAbort = null;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
// Reject early if the lock was already lost before we even spawned: do not
|
||||
// start writing the working tree after a possible lock takeover.
|
||||
if (signal?.aborted) {
|
||||
if (!rawRes.headersSent) this.send500(rawRes, 'lock-lost');
|
||||
else
|
||||
try {
|
||||
rawRes.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return done();
|
||||
}
|
||||
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn('git', ['http-backend'], { env });
|
||||
@@ -176,6 +204,39 @@ export class GitHttpBackendService {
|
||||
return done();
|
||||
}
|
||||
|
||||
// Lost-lock abort: the per-space lock lapsed mid-request. Kill the child so
|
||||
// a receive-pack stops writing `main`'s working tree before another replica
|
||||
// (which may now hold the lock) starts a cycle. Mirrors the watchdog kill.
|
||||
onAbort = () => {
|
||||
this.logger.warn(
|
||||
'git http-backend aborted (git-sync lock lost mid-request); killing child',
|
||||
);
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
const sigkill = setTimeout(() => {
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 2000);
|
||||
sigkill.unref?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (!headerParsed && !rawRes.headersSent) {
|
||||
this.send500(rawRes, 'lock-lost');
|
||||
} else {
|
||||
try {
|
||||
rawRes.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
done();
|
||||
};
|
||||
signal?.addEventListener('abort', onAbort);
|
||||
|
||||
// Watchdog: a client that opens git-receive-pack and stalls keeps the
|
||||
// child alive forever, so run() never resolves and (because this runs
|
||||
// inside withSpaceLock) the per-space lock is held + heartbeat-refreshed
|
||||
|
||||
@@ -111,6 +111,7 @@ describe('decideGitHttpGate', () => {
|
||||
gitHttpEnabled: true,
|
||||
spaceExists: true,
|
||||
spaceGitSyncEnabled: true,
|
||||
userIsSpaceMember: true,
|
||||
permissionGranted: true,
|
||||
};
|
||||
|
||||
@@ -160,16 +161,43 @@ describe('decideGitHttpGate', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('403 when authenticated but lacking the required permission (reader on write)', () => {
|
||||
it('403 when a MEMBER lacks the required permission (reader on write)', () => {
|
||||
// A member of the space (existence already known to them) who lacks the role:
|
||||
// 403 leaks nothing new.
|
||||
expect(
|
||||
decideGitHttpGate({
|
||||
...base,
|
||||
serviceKind: 'write',
|
||||
userIsSpaceMember: true,
|
||||
permissionGranted: false,
|
||||
}),
|
||||
).toEqual({ kind: 'forbidden' });
|
||||
});
|
||||
|
||||
it('404 (NOT 403) when an authenticated NON-member hits a git-sync space', () => {
|
||||
// SECURITY: a non-member must be indistinguishable from a missing/disabled
|
||||
// space. If this returned 403, the 403↔404 difference would let any
|
||||
// authenticated workspace user brute-force slugs to discover which spaces
|
||||
// exist and which have git-sync enabled.
|
||||
expect(
|
||||
decideGitHttpGate({
|
||||
...base,
|
||||
serviceKind: 'write',
|
||||
userIsSpaceMember: false,
|
||||
permissionGranted: false,
|
||||
}),
|
||||
).toEqual({ kind: 'not-found' });
|
||||
// Same for a read by a non-member.
|
||||
expect(
|
||||
decideGitHttpGate({
|
||||
...base,
|
||||
serviceKind: 'read',
|
||||
userIsSpaceMember: false,
|
||||
permissionGranted: false,
|
||||
}),
|
||||
).toEqual({ kind: 'not-found' });
|
||||
});
|
||||
|
||||
it('still 401 (not 404) for missing creds against a disabled space', () => {
|
||||
// Anonymous probe must always get 401 first, regardless of space state.
|
||||
expect(
|
||||
|
||||
@@ -111,12 +111,24 @@ export type GitHttpGateDecision =
|
||||
* 2. credentials present but invalid -> 401.
|
||||
* 3. unparseable git request shape -> 400.
|
||||
* 4. git-sync globally disabled, or git-http disabled, or the space is missing
|
||||
* / not git-sync-enabled -> 404 (never reveal existence).
|
||||
* 5. authenticated but lacking the required perm -> 403.
|
||||
* / not git-sync-enabled, OR the authenticated user is NOT a member of the
|
||||
* space (has no role at all) -> 404 (never reveal existence).
|
||||
* 5. a MEMBER of the space who lacks the required perm (e.g. a reader trying to
|
||||
* push) -> 403.
|
||||
* 6. otherwise -> proceed.
|
||||
*
|
||||
* Note (4) is checked AFTER (1)/(2): an anonymous probe always gets 401 first;
|
||||
* an authenticated user hitting a hidden/disabled space gets 404 (not 403).
|
||||
* an authenticated user hitting a hidden/disabled space — OR a space they are not
|
||||
* a member of — gets 404 (not 403). Folding non-membership into the 404 branch is
|
||||
* a SECURITY requirement: if a non-member got 403 here (as a "permission denied")
|
||||
* while a non-existent / sync-disabled space got 404, the 403↔404 difference would
|
||||
* let any authenticated workspace user brute-force slugs to discover which spaces
|
||||
* exist and which have git-sync enabled — including spaces they cannot see. 403 is
|
||||
* therefore reserved for the one case where existence is ALREADY known to the
|
||||
* caller because they ARE a member (so it leaks nothing new): a member without the
|
||||
* required role. `userIsSpaceMember` is the resolved "the user has SOME role in
|
||||
* this space" boolean (false when SpaceAbilityFactory.createForUser throws
|
||||
* NotFound / the user has no role).
|
||||
*/
|
||||
export function decideGitHttpGate(input: {
|
||||
hasCredentials: boolean;
|
||||
@@ -126,6 +138,8 @@ export function decideGitHttpGate(input: {
|
||||
gitHttpEnabled: boolean;
|
||||
spaceExists: boolean;
|
||||
spaceGitSyncEnabled: boolean;
|
||||
/** The user has SOME role in the space (false = non-member -> 404, not 403). */
|
||||
userIsSpaceMember: boolean;
|
||||
permissionGranted: boolean;
|
||||
}): GitHttpGateDecision {
|
||||
if (!input.hasCredentials) return { kind: 'unauthorized' };
|
||||
@@ -136,7 +150,10 @@ export function decideGitHttpGate(input: {
|
||||
!input.gitSyncEnabled ||
|
||||
!input.gitHttpEnabled ||
|
||||
!input.spaceExists ||
|
||||
!input.spaceGitSyncEnabled
|
||||
!input.spaceGitSyncEnabled ||
|
||||
// A non-member must be indistinguishable from a missing/disabled space: 404,
|
||||
// never 403 (otherwise the 403↔404 split leaks space existence — see above).
|
||||
!input.userIsSpaceMember
|
||||
) {
|
||||
return { kind: 'not-found' };
|
||||
}
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
// come from WorkspaceRepo. If the handler regressed to reading
|
||||
// `req.raw.workspaceId`, the happy-path fetch test below would fail (the repo
|
||||
// would not be consulted and the request would 401).
|
||||
import { Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
@@ -297,6 +301,30 @@ describe('GitHttpService.handle', () => {
|
||||
expect(built.backend.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('an authenticated NON-member of a git-sync space -> 404, NOT 403 (no existence leak)', async () => {
|
||||
// createForUser throws NotFound when the user holds no role in the space (a
|
||||
// non-member). The gate must return 404 — the SAME response a missing /
|
||||
// sync-disabled space gives — so a 403↔404 difference cannot be used to
|
||||
// brute-force which spaces exist / have git-sync enabled (the security fix).
|
||||
const built = build({ abilityCan: false });
|
||||
built.abilityFactory.createForUser.mockRejectedValue(
|
||||
new NotFoundException('Space permissions not found'),
|
||||
);
|
||||
const { reply, state } = fakeReply();
|
||||
const req = fakeRequest({
|
||||
url: '/git/secret-space.git/info/refs?service=git-upload-pack',
|
||||
method: 'GET',
|
||||
authorization: basic('dev@example.com', 'pw'),
|
||||
});
|
||||
|
||||
await built.service.handle(req, reply);
|
||||
|
||||
expect(built.abilityFactory.createForUser).toHaveBeenCalledTimes(1);
|
||||
expect(state.statusCode).toBe(404);
|
||||
expect(built.backend.run).not.toHaveBeenCalled();
|
||||
expect(built.orchestrator.ingestExternalPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a space that is not git-sync-enabled -> 404 (existence never revealed)', async () => {
|
||||
const built = build({
|
||||
space: { id: 'space-1', settings: { gitSync: { enabled: false } } },
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import { parseBasicAuth } from '../../mcp/mcp-auth.helpers';
|
||||
import { resolveRequestWorkspace } from '../../../common/helpers/resolve-request-workspace';
|
||||
import { EnvironmentService } from '../../environment/environment.service';
|
||||
import { VaultRegistryService } from '../services/vault-registry.service';
|
||||
import {
|
||||
@@ -57,7 +58,8 @@ export class GitHttpService {
|
||||
* Resolve the workspace for a /git request the SAME way DomainMiddleware does,
|
||||
* because Nest middleware does NOT run for this raw root-mounted route (it is
|
||||
* registered under the global '/api' router), so `req.raw.workspaceId` is never
|
||||
* populated here. We replicate DomainMiddleware / McpService:
|
||||
* populated here. Delegates to the shared `resolveRequestWorkspace` helper (the
|
||||
* SAME self-hosted/cloud branch DomainMiddleware uses) and returns just the id:
|
||||
* - self-hosted (single workspace) -> workspaceRepo.findFirst();
|
||||
* - cloud (multi-tenant) -> resolve by the host-header subdomain.
|
||||
* Returns null when no workspace resolves; the gate then 404s (after the
|
||||
@@ -65,17 +67,14 @@ export class GitHttpService {
|
||||
*/
|
||||
private async resolveWorkspaceId(req: FastifyRequest): Promise<string | null> {
|
||||
try {
|
||||
if (this.environmentService.isSelfHosted()) {
|
||||
const workspace = await this.workspaceRepo.findFirst();
|
||||
// Same self-hosted/cloud resolution DomainMiddleware uses — shared so the
|
||||
// branch cannot drift between the two call sites.
|
||||
const workspace = await resolveRequestWorkspace(
|
||||
this.environmentService,
|
||||
this.workspaceRepo,
|
||||
this.headerValue(req.headers['host']),
|
||||
);
|
||||
return workspace?.id ?? null;
|
||||
}
|
||||
if (this.environmentService.isCloud()) {
|
||||
const host = this.headerValue(req.headers['host']);
|
||||
const subdomain = host ? host.split('.')[0] : '';
|
||||
if (!subdomain) return null;
|
||||
const workspace = await this.workspaceRepo.findByHostname(subdomain);
|
||||
return workspace?.id ?? null;
|
||||
}
|
||||
} catch (err) {
|
||||
// A DB error resolving the workspace must not leak details; treat as
|
||||
// unresolvable (the gate will 404, unless creds are missing -> 401 first).
|
||||
@@ -150,6 +149,12 @@ export class GitHttpService {
|
||||
let spaceExists = false;
|
||||
let spaceGitSyncEnabled = false;
|
||||
let spaceId: string | undefined;
|
||||
// The user has SOME role in the space. SECURITY: a non-member must get the
|
||||
// SAME 404 a missing/disabled space gets — never a 403 — or the 403↔404 split
|
||||
// would let any authenticated user brute-force slugs to learn which spaces
|
||||
// exist / have sync enabled (the leak this gate's contract forbids). 403 is
|
||||
// reserved for a MEMBER who lacks the required role (existence already known).
|
||||
let userIsSpaceMember = false;
|
||||
let permissionGranted = false;
|
||||
if (credentialsValid && user && workspaceId && parsedPath && serviceKind) {
|
||||
const space = await this.spaceRepo.findById(
|
||||
@@ -170,6 +175,11 @@ export class GitHttpService {
|
||||
user,
|
||||
space.id,
|
||||
);
|
||||
// createForUser RESOLVED -> the user holds a role in this space (it
|
||||
// throws NotFound for a non-member). Record membership BEFORE the
|
||||
// permission check: a member lacking the role -> 403; a non-member ->
|
||||
// 404 (handled by the gate via userIsSpaceMember=false below).
|
||||
userIsSpaceMember = true;
|
||||
const action =
|
||||
serviceKind === 'write'
|
||||
? SpaceCaslAction.Manage
|
||||
@@ -177,7 +187,12 @@ export class GitHttpService {
|
||||
permissionGranted = ability.can(action, SpaceCaslSubject.Page);
|
||||
} catch {
|
||||
// createForUser throws NotFoundException when the user has no role in
|
||||
// the space — that is simply "no permission" here.
|
||||
// the space (a non-member). Leave userIsSpaceMember=false so the gate
|
||||
// returns 404, NOT 403 — a non-member must not be able to tell this
|
||||
// space apart from a non-existent one. (Any other error also falls
|
||||
// here and is treated as non-member -> 404, the safe default that
|
||||
// never reveals existence.)
|
||||
userIsSpaceMember = false;
|
||||
permissionGranted = false;
|
||||
}
|
||||
}
|
||||
@@ -193,6 +208,7 @@ export class GitHttpService {
|
||||
gitHttpEnabled: this.environmentService.isGitSyncHttpEnabled(),
|
||||
spaceExists,
|
||||
spaceGitSyncEnabled,
|
||||
userIsSpaceMember,
|
||||
permissionGranted,
|
||||
});
|
||||
|
||||
@@ -268,8 +284,12 @@ export class GitHttpService {
|
||||
|
||||
// Push: run the receive-pack under the space lock, then a Docmost cycle.
|
||||
try {
|
||||
await this.orchestrator.ingestExternalPush(spaceId, workspaceId, () =>
|
||||
this.backend.run(backendRequest, rawReq, rawRes),
|
||||
await this.orchestrator.ingestExternalPush(
|
||||
spaceId,
|
||||
workspaceId,
|
||||
// The lock's lost-lock signal is threaded into the backend so the
|
||||
// receive-pack child is killed if the lock lapses mid-write (warning #3).
|
||||
(signal) => this.backend.run(backendRequest, rawReq, rawRes, signal),
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof GitSyncLockHeldError) {
|
||||
|
||||
@@ -48,6 +48,13 @@ export interface GitSyncRunStatus {
|
||||
| 'merge-in-progress';
|
||||
pull?: { written: number; deleted: number; conflict: boolean };
|
||||
push?: { mode: string; failures: number };
|
||||
/**
|
||||
* True when the push REFUSED to fast-forward a divergent `docmost` mirror
|
||||
* (invariant §5 broken — `docmost` no longer mirrors what Docmost contains).
|
||||
* Surfaced here (not just logged) so /status can report it. No data is lost,
|
||||
* but it signals an operator-visible drift that needs attention.
|
||||
*/
|
||||
divergentDocmost?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -200,11 +207,18 @@ export class GitSyncOrchestrator implements OnModuleInit, OnModuleDestroy {
|
||||
* request behind a potentially long cycle). The receive-pack is NOT run when
|
||||
* the lock is held — we never write to the working tree concurrently with a
|
||||
* cycle.
|
||||
*
|
||||
* `runReceivePack` receives the per-space lock's lost-lock `AbortSignal`: a
|
||||
* receive-pack writes `main`'s working tree (receive.denyCurrentBranch=
|
||||
* updateInstead), so if the lock is lost mid-push (a long Redis outage drops the
|
||||
* heartbeat CAS) the signal fires and the receive-pack's `git http-backend`
|
||||
* child is killed — closing the window where another replica could grab the lock
|
||||
* and start a cycle while this child is still writing the working tree.
|
||||
*/
|
||||
async ingestExternalPush(
|
||||
spaceId: string,
|
||||
workspaceId: string,
|
||||
runReceivePack: () => Promise<void>,
|
||||
runReceivePack: (signal: AbortSignal) => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (!this.environmentService.isGitSyncEnabled()) {
|
||||
// The HTTP gate already checks this, but be defensive: never run a cycle
|
||||
@@ -215,7 +229,9 @@ export class GitSyncOrchestrator implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
const result = await this.spaceLock.withSpaceLock(spaceId, async (signal) => {
|
||||
// 1) Stream the receive-pack to the client (durable commits land on main).
|
||||
await runReceivePack();
|
||||
// Pass the lost-lock signal so the receive-pack child is killed if the lock
|
||||
// lapses mid-write (no concurrent working-tree writer across replicas).
|
||||
await runReceivePack(signal);
|
||||
|
||||
// 2) Reconcile the new commits into Docmost. A service user is required to
|
||||
// attribute the writes; without one we cannot run the cycle — the commits
|
||||
@@ -292,6 +308,18 @@ export class GitSyncOrchestrator implements OnModuleInit, OnModuleDestroy {
|
||||
log: (line: string) => this.logger.log(`git-sync[${spaceId}] ${line}`),
|
||||
});
|
||||
|
||||
// §5 invariant breach: the push refused to fast-forward a divergent `docmost`
|
||||
// mirror. No data is lost (the refusal is the safety), but the mirror no
|
||||
// longer reflects Docmost and the next push will keep refusing until an
|
||||
// operator reconciles it — so escalate from the engine's info `log` to a
|
||||
// WARN with the spaceId, and surface the flag in the returned status (/status).
|
||||
if (result.divergentDocmost) {
|
||||
this.logger.warn(
|
||||
`git-sync[${spaceId}] push refused to fast-forward a DIVERGENT 'docmost' ` +
|
||||
`mirror (invariant §5 broken); manual reconciliation required`,
|
||||
);
|
||||
}
|
||||
|
||||
return { spaceId, ...result };
|
||||
}
|
||||
|
||||
@@ -309,6 +337,18 @@ export class GitSyncOrchestrator implements OnModuleInit, OnModuleDestroy {
|
||||
* ScheduleModule: forRoot() is registered ONCE globally by TelemetryModule;
|
||||
* GitSyncModule imports the plain ScheduleModule so SchedulerRegistry is
|
||||
* injectable without a duplicate forRoot.
|
||||
*
|
||||
* KNOWN MULTI-REPLICA LIMITATION (deferred — do not silently lose this):
|
||||
* This is an IN-PROCESS `setInterval` running on EVERY replica. Cross-replica
|
||||
* single-writer safety currently rests on the per-space Redis lock
|
||||
* (SpaceLockService) plus best-effort abort-on-failed-heartbeat — NOT on true
|
||||
* fencing. Under an adversarial schedule (lock TTL lapse during a GC/IO pause)
|
||||
* two replicas could still briefly believe they hold a space's lock. The
|
||||
* intended future direction is to move this orchestration to a BullMQ queue
|
||||
* (one durable, deduplicated job per space instead of N independent interval
|
||||
* timers) and add FENCING TOKENS so a stale writer's writes are rejected by the
|
||||
* store. The author deferred fencing tokens; this comment is the breadcrumb so
|
||||
* the gap is tracked rather than forgotten. See SpaceLockService.liveLocks.
|
||||
*/
|
||||
onModuleInit(): void {
|
||||
if (!this.environmentService.isGitSyncEnabled()) return;
|
||||
|
||||
@@ -185,6 +185,13 @@ export class GitmostDataSourceService {
|
||||
|
||||
await this.writeBody(pageId, doc, ctx.userId, baseDoc);
|
||||
|
||||
// CAVEAT: writeBody merges through collab, whose persistence is DEBOUNCED, so
|
||||
// this `updatedAt` read can be STALE — it may reflect the row BEFORE the
|
||||
// debounced flush lands. Currently harmless: the only consumer is the deferred
|
||||
// §10 loop-guard, which is not yet wired. When that loop-guard is implemented
|
||||
// it MUST NOT trust this timestamp as a read-after-write of the body change
|
||||
// (it would misfire on the pre-flush value); it needs a post-flush read (or to
|
||||
// key off the collab flush completion) instead.
|
||||
const page = await this.pageRepo.findById(pageId);
|
||||
return {
|
||||
updatedAt: page ? new Date(page.updatedAt).toISOString() : undefined,
|
||||
|
||||
79
packages/git-sync/test/schema-editor-ext-contract.test.ts
Normal file
79
packages/git-sync/test/schema-editor-ext-contract.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getSchema } from "@tiptap/core";
|
||||
|
||||
import { docmostExtensions } from "../src/lib/docmost-schema.js";
|
||||
import * as editorExt from "@docmost/editor-ext";
|
||||
|
||||
// CROSS-PACKAGE SCHEMA CONTRACT (data-loss-sensitive).
|
||||
//
|
||||
// `src/lib/docmost-schema.ts` is a hand-synced VENDORED MIRROR of the canonical
|
||||
// Docmost schema in `@docmost/editor-ext`. The sibling `schema-surface-snapshot`
|
||||
// test pins the mirror's FULL surface (names + attrs) against an inline
|
||||
// reference, but that reference is hand-curated and does not mechanically tie to
|
||||
// editor-ext. This test closes that gap from the other side: it reads the ACTUAL
|
||||
// Tiptap node/mark definitions exported by `@docmost/editor-ext` and asserts the
|
||||
// vendored mirror is a SUPERSET of their type NAMES — so a Docmost-specific node
|
||||
// or mark added upstream that the mirror forgets to vendor fails CI loudly
|
||||
// (otherwise it is silently dropped on the markdown <-> ProseMirror round-trip).
|
||||
//
|
||||
// LIMITATION (intentional, see schema-surface-snapshot.test.ts): this is a
|
||||
// NAME-LEVEL contract only, not a full attribute-level structural compare.
|
||||
// editor-ext's Tiptap representation (node views, commands, suggestion plugins,
|
||||
// addGlobalAttributes spread across separate extensions) differs from this
|
||||
// minimal mirror, so a mechanical attribute-by-attribute equality would be
|
||||
// fragile and produce false drift. Attribute parity is guarded by the inline
|
||||
// surface snapshot (reviewed in every diff); this test guards that no canonical
|
||||
// node/mark TYPE goes unmirrored. StarterKit-provided types (paragraph, bold,
|
||||
// heading, …) are contributed by @tiptap/starter-kit in the mirror rather than
|
||||
// by editor-ext, so they are naturally covered by the mirror's superset.
|
||||
|
||||
/** Tiptap Node/Mark instances expose a `.name` and a `.type` of 'node'|'mark'. */
|
||||
function isTiptapNodeOrMark(
|
||||
value: unknown,
|
||||
): value is { name: string; type: "node" | "mark" } {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"name" in value &&
|
||||
typeof (value as { name: unknown }).name === "string" &&
|
||||
"type" in value &&
|
||||
((value as { type: unknown }).type === "node" ||
|
||||
(value as { type: unknown }).type === "mark")
|
||||
);
|
||||
}
|
||||
|
||||
/** The set of node/mark type names the vendored mirror actually registers. */
|
||||
function vendoredNames(): Set<string> {
|
||||
const schema = getSchema(docmostExtensions as never);
|
||||
return new Set([
|
||||
...Object.keys(schema.nodes),
|
||||
...Object.keys(schema.marks),
|
||||
]);
|
||||
}
|
||||
|
||||
/** The Docmost-specific node/mark type names exported by @docmost/editor-ext. */
|
||||
function editorExtNames(): Set<string> {
|
||||
const names = new Set<string>();
|
||||
for (const value of Object.values(editorExt)) {
|
||||
if (isTiptapNodeOrMark(value)) names.add(value.name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
describe("docmost schema vs @docmost/editor-ext (name-level contract)", () => {
|
||||
it("exposes Tiptap node/mark definitions from editor-ext (guards against the import going dark)", () => {
|
||||
// If editor-ext ever stops exporting concrete node/mark objects (e.g. a
|
||||
// barrel refactor), this contract would vacuously pass — assert it found a
|
||||
// meaningful set so the test cannot silently become a no-op.
|
||||
expect(editorExtNames().size).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it("vendors every Docmost-specific node/mark type defined in editor-ext (no silently-dropped types)", () => {
|
||||
const vendored = vendoredNames();
|
||||
const missing = [...editorExtNames()].filter((n) => !vendored.has(n)).sort();
|
||||
// missing must be empty: any name here exists in editor-ext but NOT in the
|
||||
// vendored mirror, so documents using it would lose that node/mark on a
|
||||
// git-sync round-trip. Re-sync src/lib/docmost-schema.ts before clearing.
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user