diff --git a/backend/package-lock.json b/backend/package-lock.json index 37d3296a..c1b35046 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -7,10 +7,15 @@ "name": "thothii-backend", "dependencies": { "@fastify/cors": "^11.2.0", - "fastify": "^5.0.0" + "fastify": "^5.0.0", + "yaml": "^2.9.0", + "yauzl": "^3.4.0", + "yazl": "^3.3.1", + "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^22.0.0", + "@types/yauzl": "^3.4.0", "tsx": "^4.19.0", "typescript": "^5.6.0", "vitest": "^2.1.0" @@ -969,6 +974,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -1160,6 +1175,15 @@ "fastq": "^1.17.1" } }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1604,6 +1628,12 @@ "node": ">= 14.16" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2607,6 +2637,51 @@ "engines": { "node": ">=8" } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yazl": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-3.3.1.tgz", + "integrity": "sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^1.0.0" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/package.json b/backend/package.json index 0ebaeabc..96ed8dba 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,10 +10,15 @@ }, "dependencies": { "@fastify/cors": "^11.2.0", - "fastify": "^5.0.0" + "fastify": "^5.0.0", + "yaml": "^2.9.0", + "yauzl": "^3.4.0", + "yazl": "^3.3.1", + "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^22.0.0", + "@types/yauzl": "^3.4.0", "tsx": "^4.19.0", "typescript": "^5.6.0", "vitest": "^2.1.0" diff --git a/backend/src/config.ts b/backend/src/config.ts index b5331436..616d8349 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import type { WorkspaceRegistryConfig } from "./workspaces/types.js"; export interface AppConfig { host: string; port: number; harnessDir: string; thtBin: string; piBin: string; @@ -22,7 +23,32 @@ export interface AppConfig { * pay the probe; the local dev launcher (run-stack.sh) opts in via THT_DWH_PRECHECK. */ dwhPrecheck: boolean; + workspaceRegistry: WorkspaceRegistryConfig; } + +function requiredRegistryValue(value: string, label: string): string { + if (value.length === 0 || value.trim() !== value || value.includes("\0")) { + throw new Error(`workspace registry ${label} configuration is invalid`); + } + return value; +} + +function absoluteRegistryPath(value: string, label: string): string { + const pathValue = requiredRegistryValue(value, label); + if (!path.isAbsolute(pathValue)) { + throw new Error(`workspace registry ${label} must be absolute`); + } + return pathValue; +} + +function positiveImportLimit(value: string | undefined, fallback: number): number { + const limit = Number(value ?? fallback); + if (!Number.isSafeInteger(limit) || limit <= 0) { + throw new Error("workspace import limit configuration is invalid"); + } + return limit; +} + export function loadConfig(env: Record): AppConfig { const authMode = env.AUTH_MODE ?? "none"; if (!(["none", "mock", "upstream"] as const).includes(authMode as AppConfig["authMode"])) { @@ -89,6 +115,39 @@ export function loadConfig(env: Record): AppConfig { "THT_VECTOR_MIGRATOR_PASSWORD_SECRET_FILE", "THT_VECTOR_READER_PASSWORD_SECRET_FILE", "THT_VECTOR_WRITER_PASSWORD_SECRET_FILE", ]) secretFiles[name] = env[name]; + const registryRoot = absoluteRegistryPath( + env.THT_WORKSPACE_REGISTRY_ROOT ?? "/data/workspace-registry", + "root", + ); + const registryBranch = requiredRegistryValue(env.THT_WORKSPACE_GIT_BRANCH ?? "main", "branch"); + const installationId = requiredRegistryValue( + env.THT_WORKSPACE_INSTALLATION_ID ?? "local", + "installation ID", + ); + const remoteUrl = env.THT_WORKSPACE_GIT_REMOTE === undefined + ? undefined + : requiredRegistryValue(env.THT_WORKSPACE_GIT_REMOTE, "remote"); + const secretRoots = (env.THT_WORKSPACE_SECRET_ROOTS ?? "") + .split(",") + .filter((root) => root.length > 0) + .map((root) => absoluteRegistryPath(root, "secret root")); + const workspaceRegistry: WorkspaceRegistryConfig = { + root: registryRoot, + remoteUrl, + branch: registryBranch, + gitAuthorName: requiredRegistryValue( + env.THT_WORKSPACE_GIT_AUTHOR_NAME ?? "Thoth Workspace Registry", + "Git author name", + ), + gitAuthorEmail: requiredRegistryValue( + env.THT_WORKSPACE_GIT_AUTHOR_EMAIL ?? "thoth-workspace-registry@localhost", + "Git author email", + ), + installationId, + secretRoots, + maxImportBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_IMPORT_BYTES, 10 * 1024 * 1024), + maxImportEntries: positiveImportLimit(env.THT_WORKSPACE_MAX_IMPORT_ENTRIES, 32), + }; return { host: env.HOST ?? "127.0.0.1", port: Number(env.PORT ?? 8787), @@ -106,5 +165,6 @@ export function loadConfig(env: Record): AppConfig { secretFiles, modelApiKeyFile, dwhPrecheck: env.THT_DWH_PRECHECK === "true" || env.THT_DWH_PRECHECK === "1", + workspaceRegistry, }; } diff --git a/backend/src/workspaces/types.ts b/backend/src/workspaces/types.ts new file mode 100644 index 00000000..780f266d --- /dev/null +++ b/backend/src/workspaces/types.ts @@ -0,0 +1,17 @@ +export interface WorkspaceRegistryConfig { + root: string; + remoteUrl?: string; + branch: string; + gitAuthorName: string; + gitAuthorEmail: string; + installationId: string; + secretRoots: readonly string[]; + maxImportBytes: number; + maxImportEntries: number; +} + +export type WorkspaceErrorCode = + | "workspace_invalid" | "binding_missing" | "workspace_not_activatable" + | "workspace_stale" | "workspace_conflict" | "git_unavailable" + | "git_auth_failed" | "git_non_fast_forward" | "git_push_rejected" + | "connector_unavailable" | "semantic_index_incompatible"; diff --git a/backend/test/config.test.ts b/backend/test/config.test.ts index 7db92b3c..9b0dadec 100644 --- a/backend/test/config.test.ts +++ b/backend/test/config.test.ts @@ -29,6 +29,12 @@ test("loadConfig keeps local development defaults", () => { thtBin: "tht", piBin: "pi", settingsFile: "data/settings.json", + workspaceRegistry: { + root: "/data/workspace-registry", + branch: "main", + maxImportBytes: 10 * 1024 * 1024, + maxImportEntries: 32, + }, }); expect(loadConfig({}).dataRoot).toBeUndefined(); }); diff --git a/backend/test/workspaces-config.test.ts b/backend/test/workspaces-config.test.ts new file mode 100644 index 00000000..c624b6e6 --- /dev/null +++ b/backend/test/workspaces-config.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "vitest"; +import { loadConfig } from "../src/config.js"; + +test("loads a safe Git workspace registry configuration", () => { + const cfg = loadConfig({ + THT_WORKSPACE_REGISTRY_ROOT: "/data/workspace-registry", + THT_WORKSPACE_GIT_REMOTE: "ssh://git@gitea.example/thoth/workspaces.git", + THT_WORKSPACE_GIT_BRANCH: "main", + THT_WORKSPACE_INSTALLATION_ID: "server-psd-1", + THT_WORKSPACE_SECRET_ROOTS: "/run/secrets,/data/secrets", + }); + + expect(cfg.workspaceRegistry).toMatchObject({ + root: "/data/workspace-registry", + remoteUrl: "ssh://git@gitea.example/thoth/workspaces.git", + branch: "main", + installationId: "server-psd-1", + secretRoots: ["/run/secrets", "/data/secrets"], + }); +}); + +test("uses safe workspace registry defaults", () => { + expect(loadConfig({}).workspaceRegistry).toMatchObject({ + root: "/data/workspace-registry", + branch: "main", + maxImportBytes: 10 * 1024 * 1024, + maxImportEntries: 32, + }); +}); + +test("rejects a relative registry root and invalid import limits", () => { + expect(() => loadConfig({ THT_WORKSPACE_REGISTRY_ROOT: "registry" })).toThrow(/registry/i); + expect(() => loadConfig({ + THT_WORKSPACE_REGISTRY_ROOT: "/data/registry", + THT_WORKSPACE_MAX_IMPORT_BYTES: "0", + })).toThrow(/import/i); + expect(() => loadConfig({ + THT_WORKSPACE_REGISTRY_ROOT: "/data/registry", + THT_WORKSPACE_MAX_IMPORT_ENTRIES: "1.5", + })).toThrow(/import/i); +}); + +test("rejects unsafe registry branch, installation ID, and secret roots", () => { + expect(() => loadConfig({ THT_WORKSPACE_GIT_BRANCH: "" })).toThrow(/branch/i); + expect(() => loadConfig({ THT_WORKSPACE_INSTALLATION_ID: "" })).toThrow(/installation/i); + expect(() => loadConfig({ THT_WORKSPACE_SECRET_ROOTS: "/run/secrets,relative" })) + .toThrow(/secret/i); +});