Files
arboretum/packages/server/src/routes/git-connections.ts
Johan LEROY 08695a707d feat(p12a): services git distants (PAT/app_password) + clone HTTPS
Modèle de données :
- migration #11 git_credentials (secrets chiffrés SecretBox : secret_encrypted ; colonnes ssh/oauth posées pour P12b/P12c) ; #12 repos ALTER remote_url/git_service/credential_id (pas de FK)
- types partagés api.ts (GitCredentialSummary sans secret + hasSecret/secretLast4, CRUD, RemoteRepoSummary, Clone*) ; protocole additif : topic 'clones' + message clone_update (CloneOperation)

Backend :
- core/git-credentials.ts (GitCredentialsManager(db, box)) : CRUD chiffré, test() (GET /user), getSecret()/authContext() internes, NULLification de repos.credential_id à la suppression
- core/git-clients/ (github/gitlab/gitea) via fetch, sans dépendance : verify()+listRepos() paginés, erreurs typées AUTH_FAILED/RATE_LIMITED/UNREACHABLE, SSRF base_url http(s)
- core/git-auth.ts : withGitAuth (GIT_ASKPASS éphémère 0o700, secret par env, GIT_TERMINAL_PROMPT=0, jamais dans l'URL/.git/config, nettoyage finally)
- core/git.ts cloneRepo (spawn git clone --progress, parse progression, timeout)
- core/clone-manager.ts (EventEmitter) : clone async, dest confiné sous scanRoots + non existant, auto-enregistrement via addRepo + métadonnées de provenance, nettoyage du clone partiel, events topic 'clones'
- routes/git-connections.ts (CRUD + /test + /:id/repos + POST /repos/clone 202 + GET /repos/clone/:id) ; app.ts câble box→GitCredentialsManager + CloneManager→gateway ; gateway relaie 'clones'

Frontend :
- ws-client subscribeClones ; stores git-connections + clone (suivi WS)
- components/settings/GitConnectionsSection (liste + formulaire pat/app_password, secret jamais ré-affiché) inséré dans SettingsView ; CloneRepoModal (connexion → repos distants paginés → dest scanRoots[0] → barre de progression WS → redirection) ; bouton « Cloner » dans DashboardView ; i18n EN+FR

Tests : git-credentials (round-trip SecretBox, résumé sans secret, NULLification) ; acceptance-p12.mjs (clone bare local file:// → clone_update done + repo enregistré + secret ABSENT de l'API, de la DB et du .git/config)

Sous-phases restantes : P12b (SSH), P12c (OAuth device flow).
2026-06-27 14:27:18 +02:00

120 lines
5.4 KiB
TypeScript

// Routes des services git distants (P12) : CRUD des credentials (secrets JAMAIS renvoyés), test de
// connexion, listing des dépôts distants, et lancement/suivi d'un clone. Authentifiées par le hook
// preValidation global. Les secrets sont chiffrés par SecretBox dans le manager.
import type { FastifyInstance, FastifyReply } from 'fastify';
import type {
CloneRequest,
CloneStartResponse,
CloneStatusResponse,
CreateGitCredentialRequest,
GitCredentialResponse,
GitCredentialsListResponse,
RemoteReposResponse,
UpdateGitCredentialRequest,
} from '@arboretum/shared';
import type { Db } from '../db/index.js';
import type { GitCredentialsManager } from '../core/git-credentials.js';
import type { CloneManager } from '../core/clone-manager.js';
import { getGitClient, GitServiceError } from '../core/git-clients/index.js';
import { recordAudit } from '../core/audit-log.js';
function sendError(reply: FastifyReply, err: unknown): FastifyReply {
const e = err as { statusCode?: number; code?: string; message?: string };
const status = typeof e.statusCode === 'number' ? e.statusCode : 500;
return reply.status(status).send({ error: { code: e.code ?? 'INTERNAL', message: e.message ?? 'Internal error' } });
}
const GIT_SERVICE_ERROR_STATUS: Record<string, number> = { AUTH_FAILED: 401, RATE_LIMITED: 429, UNREACHABLE: 502, BAD_BASE_URL: 400 };
export function registerGitConnectionRoutes(
app: FastifyInstance,
credentials: GitCredentialsManager,
clones: CloneManager,
db: Db,
): void {
const actor = (req: { authContext?: { tokenId: string } | null }): string => req.authContext?.tokenId ?? 'unknown';
app.get('/api/v1/git-connections', async (): Promise<GitCredentialsListResponse> => ({ credentials: credentials.list() }));
app.post('/api/v1/git-connections', async (req, reply) => {
try {
const cred = credentials.create((req.body ?? {}) as CreateGitCredentialRequest);
recordAudit(db, { actor: actor(req), action: 'git_credential.create', resourceId: cred.id, details: { service: cred.service, authType: cred.authType } });
const res: GitCredentialResponse = { credential: cred };
return reply.status(201).send(res);
} catch (err) {
return sendError(reply, err);
}
});
app.patch('/api/v1/git-connections/:id', async (req, reply) => {
try {
const cred = credentials.update((req.params as { id: string }).id, (req.body ?? {}) as UpdateGitCredentialRequest);
recordAudit(db, { actor: actor(req), action: 'git_credential.update', resourceId: cred.id });
const res: GitCredentialResponse = { credential: cred };
return reply.send(res);
} catch (err) {
return sendError(reply, err);
}
});
app.delete('/api/v1/git-connections/:id', async (req, reply) => {
const { id } = req.params as { id: string };
if (!credentials.remove(id)) {
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No credential with this id' } });
}
recordAudit(db, { actor: actor(req), action: 'git_credential.delete', resourceId: id });
return reply.send({ ok: true });
});
app.post('/api/v1/git-connections/:id/test', async (req, reply) => {
try {
const result = await credentials.test((req.params as { id: string }).id);
recordAudit(db, { actor: actor(req), action: 'git_credential.test', resourceId: (req.params as { id: string }).id, details: { ok: result.ok } });
return reply.send(result);
} catch (err) {
return sendError(reply, err);
}
});
app.get('/api/v1/git-connections/:id/repos', async (req, reply) => {
const { id } = req.params as { id: string };
const q = req.query as { page?: string; search?: string };
const ctx = credentials.authContext(id);
if (!ctx) return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No usable credential with this id' } });
const page = Math.max(1, Number(q.page) || 1);
try {
const { repos, nextPage } = await getGitClient(ctx.service, ctx.baseUrl).listRepos(ctx.auth, page, q.search);
const res: RemoteReposResponse = { repos, nextPage };
return reply.send(res);
} catch (err) {
if (err instanceof GitServiceError) {
return reply.status(GIT_SERVICE_ERROR_STATUS[err.errorCode] ?? 502).send({ error: { code: err.errorCode, message: err.message } });
}
return sendError(reply, err);
}
});
// ---- Clone (asynchrone : 202 + suivi WS/REST) ----
app.post('/api/v1/repos/clone', async (req, reply) => {
const body = (req.body ?? {}) as Partial<CloneRequest>;
if (typeof body.credentialId !== 'string' || typeof body.remoteUrl !== 'string' || typeof body.dest !== 'string') {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'credentialId, remoteUrl and dest are required' } });
}
try {
const operationId = await clones.start({ credentialId: body.credentialId, remoteUrl: body.remoteUrl, dest: body.dest }, actor(req));
const res: CloneStartResponse = { operationId };
return reply.status(202).send(res);
} catch (err) {
return sendError(reply, err);
}
});
app.get('/api/v1/repos/clone/:id', async (req, reply) => {
const op = clones.get((req.params as { id: string }).id);
if (!op) return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No clone operation with this id' } });
const res: CloneStatusResponse = { operation: op };
return reply.send(res);
});
}