// 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 = { 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 => ({ 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; 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); }); }