/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/api/v1.mjs * Purpose : JSON API — public reads, PAT-gated writes * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { z } from 'zod'; import { isValidRepoName } from '../lib/util.mjs'; import { makeRequireAuth } from '../auth/token.mjs'; import { installHooks } from '../git/hooks.mjs'; import { repoOverview, allOverviews, siteStats } from '../lib/overview.mjs'; import { computeLanguages } from '../stats/languages.mjs'; import { isValidAssetName, isValidTagName } from '../git/releases.mjs'; const ASSET_BODY_LIMIT = 4 * 1024 * 1024 * 1024 + 1024; // 4 GiB + header slack const TOPIC_RE = /^[a-z0-9][a-z0-9-]{0,34}$/; const createSchema = z.object({ name: z.string().min(1).max(64), description: z.string().max(350).optional().default(''), topics: z.array(z.string().regex(TOPIC_RE)).max(10).optional().default([]), homepage: z.union([z.literal(''), z.string().url().max(300)]).optional().default(''), pinned: z.boolean().optional().default(false), defaultBranch: z.string().regex(/^[\w./-]{1,100}$/).optional().default('main'), }); const patchSchema = z.object({ description: z.string().max(350).optional(), topics: z.array(z.string().regex(TOPIC_RE)).max(10).optional(), homepage: z.union([z.literal(''), z.string().url().max(300)]).optional(), pinned: z.boolean().optional(), defaultBranch: z.string().regex(/^[\w./-]{1,100}$/).optional(), }).strict(); const tokenSchema = z.object({ label: z.string().min(1).max(80).optional().default('unnamed'), }); /** Uniform error payload — CLAUDE.md §7 envelope. */ function apiError(reply, status, code, message) { return reply.code(status).send({ error: { code, message } }); } /** Zod-parse a body or reply 400. Returns null on failure. */ function parseBody(schema, request, reply) { const result = schema.safeParse(request.body ?? {}); if (!result.success) { apiError(reply, 400, 'validation_failed', result.error.issues.map((i) => `${i.path.join('.') || 'body'}: ${i.message}`).join('; ')); return null; } return result.data; } /** * Register /api/v1 routes. * @param {import('fastify').FastifyInstance} app * @param {{config, repos, meta, tokens, cache, activity}} ctx */ export function registerApi(app, ctx) { const requireAuth = makeRequireAuth(ctx.tokens); app.get('/api/v1/repos', async () => { const overviews = await allOverviews(ctx); return { repos: overviews }; }); app.get('/api/v1/repos/:name', async (request, reply) => { const overview = await repoOverview(ctx, request.params.name); if (!overview) return apiError(reply, 404, 'not_found', 'repository not found'); return overview; }); app.get('/api/v1/repos/:name/languages', async (request, reply) => { const { name } = request.params; if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found'); const head = await ctx.repos.head(name); if (!head) return { languages: [], totalBytes: 0 }; const cachePath = ctx.cache.repoPath(name, head, 'languages.json'); return ctx.cache.remember(cachePath, async () => computeLanguages(await ctx.repos.allFiles(name, head))); }); app.get('/api/v1/repos/:name/commits', async (request, reply) => { const { name } = request.params; if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found'); const refInput = String(request.query.ref ?? '') || (await ctx.repos.defaultBranch(name)); const sha = await ctx.repos.resolveRef(name, refInput); if (!sha) return apiError(reply, 404, 'ref_not_found', `unknown ref: ${refInput}`); const page = Math.max(1, Number(request.query.page) || 1); const { commits, hasNext } = await ctx.repos.log(name, sha, { page }); return { ref: refInput, sha, page, hasNext, commits }; }); app.get('/api/v1/stats', async () => siteStats(ctx)); app.post('/api/v1/repos', async (request, reply) => { if (!(await requireAuth(request, reply))) return reply; const body = parseBody(createSchema, request, reply); if (!body) return reply; if (!isValidRepoName(body.name)) { return apiError(reply, 400, 'invalid_name', 'repo names must match ^[a-z0-9][a-z0-9._-]{0,63}$'); } if (ctx.repos.exists(body.name)) return apiError(reply, 409, 'already_exists', 'repository already exists'); await ctx.repos.create(body.name, body.defaultBranch); installHooks(ctx.repos.dir(body.name), ctx.config.port); ctx.meta.upsert(body.name, { description: body.description, topics: body.topics, homepage: body.homepage, pinned: body.pinned, created: new Date().toISOString(), defaultBranch: body.defaultBranch, }); const overview = await repoOverview(ctx, body.name); return reply.code(201).send(overview); }); app.patch('/api/v1/repos/:name', async (request, reply) => { if (!(await requireAuth(request, reply))) return reply; const { name } = request.params; if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found'); const body = parseBody(patchSchema, request, reply); if (!body) return reply; if (body.defaultBranch) { const branches = await ctx.repos.branches(name); if (branches.length > 0 && !branches.some((b) => b.name === body.defaultBranch)) { return apiError(reply, 400, 'unknown_branch', `branch does not exist: ${body.defaultBranch}`); } await ctx.repos.setDefaultBranch(name, body.defaultBranch); } ctx.meta.upsert(name, body); ctx.cache.bustRepo(name); return repoOverview(ctx, name); }); app.delete('/api/v1/repos/:name', async (request, reply) => { if (!(await requireAuth(request, reply))) return reply; const { name } = request.params; if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found'); const trashPath = ctx.repos.softDelete(name); ctx.meta.remove(name); ctx.cache.bustRepo(name); ctx.releases.removeRepo(name); request.log.info({ name, trashPath }, 'repository soft-deleted'); return { deleted: name, recoverable: true, note: 'moved to trash — recoverable for 30 days' }; }); app.post('/api/v1/tokens', async (request, reply) => { if (!(await requireAuth(request, reply))) return reply; const body = parseBody(tokenSchema, request, reply); if (!body) return reply; const { token, record } = await ctx.tokens.create(body.label); const { hash: _hash, ...safe } = record; return reply.code(201).send({ token, ...safe }); }); app.get('/api/v1/tokens', async (request, reply) => { if (!(await requireAuth(request, reply))) return reply; return { tokens: ctx.tokens.list() }; }); app.delete('/api/v1/tokens/:id', async (request, reply) => { if (!(await requireAuth(request, reply))) return reply; const ok = ctx.tokens.revoke(String(request.params.id)); if (!ok) return apiError(reply, 404, 'not_found', 'token not found'); return { revoked: request.params.id }; }); // ── Releases: binary assets attached to tags (GitHub-style) ────── app.get('/api/v1/repos/:name/releases', async (request, reply) => { const { name } = request.params; if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found'); const tags = await ctx.repos.tags(name); const assets = ctx.releases.list(name); const releases = tags.map((tag) => { const key = tag.name.replaceAll('/', '_'); return { tag: tag.name, date: tag.date, subject: tag.subject, assets: (assets[key] ?? []).map((a) => ({ ...a, url: `${ctx.config.publicUrl}/releases/${name}/${key}/${encodeURIComponent(a.name)}`, })), }; }); return { releases }; }); app.put('/api/v1/repos/:name/releases/:tag/assets/:asset', { bodyLimit: ASSET_BODY_LIMIT, }, async (request, reply) => { if (!(await requireAuth(request, reply))) return reply; const { name, tag, asset } = request.params; if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found'); if (!isValidTagName(tag)) return apiError(reply, 400, 'invalid_tag', 'invalid tag name'); if (!isValidAssetName(asset)) { return apiError(reply, 400, 'invalid_asset', 'asset names must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$'); } const tags = await ctx.repos.tags(name); const tagKey = tag.replaceAll('/', '_'); if (!tags.some((t) => t.name.replaceAll('/', '_') === tagKey)) { return apiError(reply, 400, 'unknown_tag', `tag does not exist: ${tag} — push the tag first (git push --tags)`); } if (!request.body || typeof request.body.pipe !== 'function') { return apiError(reply, 400, 'no_body', 'send the file as the request body with Content-Type: application/octet-stream'); } try { const saved = await ctx.releases.save(name, tagKey, asset, request.body); request.log.info({ name, tag, asset, size: saved.size }, 'release asset uploaded'); return reply.code(201).send({ ...saved, tag, url: `${ctx.config.publicUrl}/releases/${name}/${tagKey}/${encodeURIComponent(asset)}`, }); } catch (err) { return apiError(reply, 400, 'upload_failed', err.message); } }); app.delete('/api/v1/repos/:name/releases/:tag/assets/:asset', async (request, reply) => { if (!(await requireAuth(request, reply))) return reply; const { name, tag, asset } = request.params; if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found'); if (!isValidTagName(tag) || !isValidAssetName(asset)) { return apiError(reply, 400, 'invalid_name', 'invalid tag or asset name'); } const ok = ctx.releases.remove(name, tag.replaceAll('/', '_'), asset); if (!ok) return apiError(reply, 404, 'not_found', 'asset not found'); return { deleted: asset, tag }; }); // Auth ping used by `spbgit doctor`. app.get('/api/v1/whoami', async (request, reply) => { const record = await requireAuth(request, reply); if (!record) return reply; return { owner: ctx.config.owner.name, tokenLabel: record.label, tokenId: record.id }; }); }