Files
gitmost/apps/server/src/integrations/git-sync/git-sync.controller.ts
T
claude code agent 227 906733b5c8 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>
2026-06-28 15:39:12 +03:00

110 lines
3.8 KiB
TypeScript

import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
Get,
UseGuards,
} from '@nestjs/common';
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,
WorkspaceCaslSubject,
} from '../../core/casl/interfaces/workspace-ability.type';
import { EnvironmentService } from '../environment/environment.service';
import { IsUUID } from 'class-validator';
import {
GitSyncOrchestrator,
GitSyncRunStatus,
} from './services/git-sync.orchestrator';
/** Body for the manual one-shot trigger. */
class TriggerGitSyncDto {
// The global ValidationPipe runs with whitelist:true, which STRIPS any field
// lacking a validation decorator — without this @IsUUID the spaceId would be
// dropped and arrive as undefined.
@IsUUID()
spaceId: string;
}
/**
* Ops/testing endpoints for the git-sync control plane. Admin-guarded
* (workspace Manage/Settings, mirroring WorkspaceController) so only workspace
* admins can force a cycle. Mounted under the global `/api` prefix:
* - POST /api/git-sync/trigger { spaceId } — run one cycle now (await result),
* - GET /api/git-sync/status — report whether sync is enabled + config.
*/
@UseGuards(JwtAuthGuard)
@Controller('git-sync')
export class GitSyncController {
constructor(
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). */
private assertAdmin(user: User, workspace: Workspace): void {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
}
@HttpCode(HttpStatus.OK)
@Post('trigger')
async trigger(
@Body() dto: TriggerGitSyncDto,
@AuthUser() user: User,
@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);
}
@HttpCode(HttpStatus.OK)
@Get('status')
async status(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<{
enabled: boolean;
dataDir: string;
pollIntervalMs: number;
debounceMs: number;
serviceUserConfigured: boolean;
}> {
this.assertAdmin(user, workspace);
return {
enabled: this.environmentService.isGitSyncEnabled(),
dataDir: this.environmentService.getGitSyncDataDir(),
pollIntervalMs: this.environmentService.getGitSyncPollIntervalMs(),
debounceMs: this.environmentService.getGitSyncDebounceMs(),
serviceUserConfigured: Boolean(
this.environmentService.getGitSyncServiceUserId(),
),
};
}
}