feat(git-sync): serve spaces over smart-HTTP (gitmost as a two-way git host)

Expose each git-sync-enabled space as a clonable/pushable git repo over HTTP,
so `git clone https://<user>:<pass>@<host>/git/<spaceId>.git` works and external
pushes flow back into Docmost pages — gitmost itself acts as the git host (no
external GitHub/Gitea, no SSH).

Transport: shell out to `git http-backend` (CGI; git is already in the runtime
image) which implements the full smart-HTTP protocol (info/refs, upload-pack,
receive-pack, protocol v2). A raw Fastify route `/git/*` (mounted at the root,
outside the `/api` prefix) bridges the request/response to the CGI; passthrough
content-type parsers for the git media types stream the raw body to stdin.

Reuse the existing engine: clients push the vault's `main` branch, whose commits
beyond `refs/docmost/last-pushed` the engine already reconciles into Docmost.

- http/git-http.service.ts — auth (HTTP Basic -> AuthService.verifyUserCredentials),
  self-resolved workspace (DomainMiddleware does not run for this raw route),
  per-space gating (global + per-space gitSync flags, 404 hides existence),
  CASL authz (Read=fetch, Manage=push), dispatch.
- http/git-http-backend.service.ts — spawn `git http-backend`, binary-safe CGI
  response parsing (Status/headers/body), stream to the socket.
- http/git-http.helpers.ts — pure path parse, service->kind mapping, gate decision
  (unit-tested); rejects literal and percent-encoded path traversal.
- orchestrator: extract reusable withSpaceLock (CAS-guarded lock heartbeat so a
  long push cannot let the lock expire mid-cycle) and add ingestExternalPush
  (receive-pack + Docmost cycle under one lock; 503 on contention).
- vault-registry: ensureServable() — ensureRepo + idempotent receive.denyCurrentBranch
  =updateInstead / denyNonFastForwards / http.receivepack / http.uploadpack.
- env: GIT_SYNC_HTTP_ENABLED (defaults to GIT_SYNC_ENABLED) + validation.
- main.ts: register the /git/* route and the git content-type parsers.

Tests: pure helpers, CGI parsing, and the GitHttpService handler (auth/gate/authz
+ workspace resolution). Server tsc + git-sync/env suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-21 19:55:25 +03:00
committed by claude code agent 227
parent d9d1d54aaa
commit 04032ae677
12 changed files with 1655 additions and 14 deletions

View File

@@ -0,0 +1,183 @@
// Unit tests for the pure /git smart-HTTP helpers: URL parsing, service->kind
// mapping (read vs write), and the gating/auth decision precedence.
import {
decideGitHttpGate,
parseGitPath,
resolveServiceKind,
} from './git-http.helpers';
describe('parseGitPath', () => {
it('parses spaceId + subpath, stripping the trailing .git', () => {
expect(parseGitPath('abc123.git/info/refs')).toEqual({
spaceId: 'abc123',
subpath: 'info/refs',
});
});
it('tolerates a leading slash', () => {
expect(parseGitPath('/abc.git/git-receive-pack')).toEqual({
spaceId: 'abc',
subpath: 'git-receive-pack',
});
});
it('returns an empty subpath for the bare repo root', () => {
expect(parseGitPath('abc.git')).toEqual({ spaceId: 'abc', subpath: '' });
});
it('returns null when the first segment lacks .git', () => {
expect(parseGitPath('abc/info/refs')).toBeNull();
});
it('returns null on an empty space id', () => {
expect(parseGitPath('.git/info/refs')).toBeNull();
});
it('rejects path traversal', () => {
expect(parseGitPath('abc.git/../../etc/passwd')).toBeNull();
expect(parseGitPath('..git/x')).toBeNull();
});
it('rejects percent-encoded dot/slash traversal in the subpath (case-insensitive)', () => {
expect(parseGitPath('abc.git/%2e%2e%2fetc/passwd')).toBeNull();
expect(parseGitPath('abc.git/%2E%2E/secret')).toBeNull();
expect(parseGitPath('abc.git/objects/%2fabsolute')).toBeNull();
});
});
describe('resolveServiceKind', () => {
it('GET info/refs?service=git-upload-pack -> read', () => {
expect(
resolveServiceKind({
method: 'GET',
subpath: 'info/refs',
service: 'git-upload-pack',
}),
).toBe('read');
});
it('GET info/refs?service=git-receive-pack -> write', () => {
expect(
resolveServiceKind({
method: 'GET',
subpath: 'info/refs',
service: 'git-receive-pack',
}),
).toBe('write');
});
it('POST git-upload-pack -> read', () => {
expect(
resolveServiceKind({ method: 'POST', subpath: 'git-upload-pack' }),
).toBe('read');
});
it('POST git-receive-pack -> write', () => {
expect(
resolveServiceKind({ method: 'POST', subpath: 'git-receive-pack' }),
).toBe('write');
});
it('a dumb-protocol GET (HEAD / objects) -> read', () => {
expect(resolveServiceKind({ method: 'GET', subpath: 'HEAD' })).toBe('read');
expect(
resolveServiceKind({ method: 'GET', subpath: 'objects/12/abcdef' }),
).toBe('read');
});
it('info/refs with no/unknown service -> read (dumb discovery)', () => {
expect(resolveServiceKind({ method: 'GET', subpath: 'info/refs' })).toBe(
'read',
);
});
it('an unknown POST endpoint -> null', () => {
expect(resolveServiceKind({ method: 'POST', subpath: 'whatever' })).toBeNull();
});
it('an unsupported method -> null', () => {
expect(
resolveServiceKind({ method: 'DELETE', subpath: 'git-receive-pack' }),
).toBeNull();
});
});
describe('decideGitHttpGate', () => {
const base = {
hasCredentials: true,
credentialsValid: true,
serviceKind: 'read' as const,
gitSyncEnabled: true,
gitHttpEnabled: true,
spaceExists: true,
spaceGitSyncEnabled: true,
permissionGranted: true,
};
it('proceeds on the happy path', () => {
expect(decideGitHttpGate(base)).toEqual({ kind: 'proceed' });
});
it('401 when credentials are missing (even for a valid space)', () => {
expect(
decideGitHttpGate({ ...base, hasCredentials: false }),
).toEqual({ kind: 'unauthorized' });
});
it('401 when credentials are present but invalid', () => {
expect(
decideGitHttpGate({ ...base, credentialsValid: false }),
).toEqual({ kind: 'unauthorized' });
});
it('400 on an unparseable service kind', () => {
expect(decideGitHttpGate({ ...base, serviceKind: null })).toEqual({
kind: 'bad-request',
});
});
it('404 when the space is not git-sync-enabled (never reveals existence)', () => {
expect(
decideGitHttpGate({ ...base, spaceGitSyncEnabled: false }),
).toEqual({ kind: 'not-found' });
});
it('404 when the space does not exist', () => {
expect(decideGitHttpGate({ ...base, spaceExists: false })).toEqual({
kind: 'not-found',
});
});
it('404 when git-sync is globally disabled', () => {
expect(decideGitHttpGate({ ...base, gitSyncEnabled: false })).toEqual({
kind: 'not-found',
});
});
it('404 when the git-http host is disabled', () => {
expect(decideGitHttpGate({ ...base, gitHttpEnabled: false })).toEqual({
kind: 'not-found',
});
});
it('403 when authenticated but lacking the required permission (reader on write)', () => {
expect(
decideGitHttpGate({
...base,
serviceKind: 'write',
permissionGranted: false,
}),
).toEqual({ kind: 'forbidden' });
});
it('still 401 (not 404) for missing creds against a disabled space', () => {
// Anonymous probe must always get 401 first, regardless of space state.
expect(
decideGitHttpGate({
...base,
hasCredentials: false,
spaceGitSyncEnabled: false,
}),
).toEqual({ kind: 'unauthorized' });
});
});