import path from "node:path"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { requirePermission, isPrincipalContext } from "../auth/authorization.js"; import type { ThtRunner } from "../tht/tht-runner.js"; import type { WorkspaceRegistry } from "../workspaces/registry.js"; import type { WorkspacePreprocessingService } from "../workspaces/preprocessing-service.js"; const params = z.object({ workspaceId: z.string().regex(/^[a-z][a-z0-9-]{2,62}$/), evidenceId: z.string().regex(/^evidence:[a-z0-9]+(?:-[a-z0-9]+)*$/).optional() }); const query = z.object({ q: z.string().max(1000).optional(), kind: z.enum(["domain", "glossary", "enum", "example", "mapping", "normalization", "formula", "reference"]).optional(), purpose: z.enum(["disambiguation", "rewriting", "schema_linking", "sql_generation"]).optional(), status: z.enum(["new", "modified", "active", "removed", "review_required", "legacy", "invalid"]).optional(), concept: z.string().max(300).optional(), table: z.string().max(300).optional(), column: z.string().max(300).optional(), source: z.string().max(300).optional(), language: z.string().max(30).optional(), sort: z.enum(["title", "id", "kind", "status"]).optional(), direction: z.enum(["asc", "desc"]).optional(), page: z.coerce.number().int().positive().optional(), page_size: z.coerce.number().int().min(1).max(100).optional(), }).strict(); const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; export function evidenceRoutes(app: FastifyInstance, deps: { runner: Pick; registry: Pick; registryRoot: string; hostRegistryRoot?: string; service: Partial>; }) { app.post("/workspaces/:workspaceId/evidence/sources", async (request, reply) => { const principal = requirePermission(request, reply, "evidence.manage"); if (!isPrincipalContext(principal)) return reply; try { const { workspaceId } = params.parse(request.params); const body = z.discriminatedUnion("action", [ z.object({ action: z.literal("refresh") }).strict(), z.object({ action: z.literal("decide"), sourceId: z.string().regex(/^[a-f0-9]{64}$/), revision: z.string().regex(/^[a-f0-9]{64}$/), decision: z.enum(["keep", "replace"]) }).strict(), ]).parse(request.body); if (!(await deps.registry.list()).some(w => w.id === workspaceId)) return reply.code(404).send({ code: "workspace_invalid" }); if (!deps.service.evidenceSources) throw new Error("Evidence service unavailable"); return await deps.service.evidenceSources({ workspaceId, ...body, actor: principal.subject }); } catch (error) { return reply.code(error instanceof z.ZodError ? 400 : 503).send({ code: "evidence_unavailable", message: error instanceof z.ZodError ? "Invalid source request." : "Evidence source service is unavailable." }); } }); app.get<{ Params: { workspaceId: string; evidenceId?: string } }>("/workspaces/:workspaceId/evidence", read); app.get<{ Params: { workspaceId: string; evidenceId?: string } }>("/workspaces/:workspaceId/evidence/:evidenceId", read); async function read(request: import("fastify").FastifyRequest, reply: import("fastify").FastifyReply) { const principal = requirePermission(request, reply, "evidence.manage"); if (!isPrincipalContext(principal)) return reply; try { const { workspaceId, evidenceId } = params.parse(request.params); const filters = query.parse(request.query); if (!(await deps.registry.list()).some(w => w.id === workspaceId)) return reply.code(404).send({ code: "workspace_invalid" }); const root = path.join(deps.registryRoot, "repo", workspaceId); const result = await deps.runner.withPrincipal(principal).runWithRuntimeSnapshot( ["evidence", "admin", "--workspace", workspaceId], JSON.stringify({ root, query: { ...filters, ...(evidenceId ? { id: evidenceId } : {}) } }), ); const payload = JSON.parse(result.stdout); if (result.code !== 0) return reply.code(503).send(payload); if (evidenceId && !payload.item) return reply.code(404).send({ code: "evidence_not_found", message: "Evidence was not found." }); const host = deps.hostRegistryRoot; const hostPath = host && (path.isAbsolute(host) || path.win32.isAbsolute(host)) ? (path.win32.isAbsolute(host) && !path.isAbsolute(host) ? path.win32 : path).join(host, "repo") : null; const hostJoin = hostPath && path.win32.isAbsolute(hostPath) && !path.isAbsolute(hostPath) ? path.win32.join : path.join; return { ...payload, location: { repository: hostPath, workspace: hostPath ? hostJoin(hostPath, workspaceId) : null, runtime_workspace: root, host: "Installation host", command: `tht workspace evidence consolidate --workspace ${workspaceId}`, git_commands: hostPath ? [ `git -C ${quote(hostPath)} status --short -- ${quote(workspaceId + "/evidence")}`, `git -C ${quote(hostPath)} diff -- ${quote(workspaceId + "/evidence")}`, `git -C ${quote(hostPath)} add -A -- ${quote(workspaceId + "/evidence")}`, `git -C ${quote(hostPath)} commit --only -m 'Curate Evidence' -- ${quote(workspaceId + "/evidence")}`, `git -C ${quote(hostPath)} push`, ] : [] } }; } catch (error) { return reply.code(error instanceof z.ZodError ? 400 : 503).send({ code: "evidence_unavailable", message: error instanceof z.ZodError ? "Evidence filters are invalid." : "Evidence archive is unavailable." }); } } app.post("/workspaces/:workspaceId/evidence/consolidate", async (request, reply) => { const principal = requirePermission(request, reply, "evidence.manage"); if (!isPrincipalContext(principal)) return reply; try { const { workspaceId } = params.parse(request.params); if (!(await deps.registry.list()).some(w => w.id === workspaceId)) return reply.code(404).send({ code: "workspace_invalid" }); if (!deps.service.consolidateEvidence) throw new Error("Evidence service unavailable"); return await deps.service.consolidateEvidence({ workspaceId }); } catch { return reply.code(503).send({ code: "evidence_unavailable", message: "Evidence consolidation is unavailable." }); } }); }